Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_view.mjs

24.4 KiB, 1 run

created by r2519314175:785, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1// verify_view.mjs -- Max and Simple, and the two rules that make them safe.
2//
3// The user's words: "Max is the current Compact view but with more important
4// information and settings pulled out of dialogs and made visible in order to
5// use more screen real-estate, and Simple is more canva-like, with clutter
6// removed by hiding non-essential detail."
7//
8// The design is not "more" and "less". It is where a control LIVES relative to
9// the thing it controls: Simple puts controls inside the object, Max hoists
10// them onto it so several objects can be compared without navigating.
11//
12// What is proved here:
13//
14// INVARIANT 1 -- Simple hides information, never affordances. Every control
15// reachable in Max is reachable in Simple within one more click, and the
16// route (the cog) is always visible. This is what stops Simple being a mode
17// where the reader concludes the app is broken.
18// INVARIANT 2 -- Simple never hides a warning, an error, or a spending
19// control. Notes2 opens on the Transparent Control Principle; a display
20// preference that hid a traffic light would contradict the app's first rule.
21// THE CASCADE -- the view is the ONE control of density: every tile follows
22// it, and follows it back. The trap: choosing Max must not write `max` into
23// every tile, or switching to Simple leaves them all dense and the global
24// control looks broken.
25// NOTHING PER TILE -- notes3 settled it: "Simple and Max are meant to be
26// global". So the tile's own dialog offers no level control at all, and a
27// `detail` left in storage by an older build is INERT. That second half is
28// the one worth asserting: a stored override that still won would leave a
29// tile permanently out of step with the view and no control in the app to
30// mend it with, which is a worse state than the one the control removed.
31// MIGRATION -- a stored `sharp` becomes Max and `warm` becomes Simple, so
32// nobody's existing choice is cleared; an unset skin is not a choice and
33// becomes Simple, the quiet default.
34// MAX FITS A PHONE. If the hoisted set does not fit 430px, too much has been
35// hoisted. That is the forcing function against Max becoming a dumping
36// ground.
37//
38// node dev/verify_view.mjs
39// node dev/verify_view.mjs --break copy # the view writes into every tile
40// node dev/verify_view.mjs --break stuck # a stored detail wins over the view
41// node dev/verify_view.mjs --break light # Simple hides the pause widget
42// node dev/verify_view.mjs --break cog # Simple hides the way in
43// node dev/verify_view.mjs --break tags # Simple hides the tag row
44// node dev/verify_view.mjs --break scrolled # the tags render, past the row's edge
45//
46// Needs dev/serve.mjs and dev/mockllm.mjs (dev/world.sh N --up gives both).
47import { open, connectMock, signInAs, scratch } from './harness.mjs';
48
49const BI = process.argv.indexOf('--break');
50const BREAK = BI >= 0 ? (process.argv[BI + 1] || '') : '';
51
52let failures = 0;
53const check = (cond, msg, detail) => {
54 if (!cond) failures++;
55 console.log((cond ? ' ok ' : ' FAIL ') + msg + (detail ? ' -- ' + detail : ''));
56};
57
58const s = await open({ name: 'view', signIn: false, connect: false,
59 profile: scratch('pw', 'view-' + process.pid) });
60const { page: p } = s;
61
62try {
63 if (BREAK) console.log(` .. running with --break ${BREAK}`);
64 await signInAs(s, 'view');
65 await connectMock(s);
66 if (BREAK) {
67 await p.evaluate((b) => {
68 const st = document.createElement('style');
69 st.id = 'viewbreak';
70 if (b === 'light') st.textContent = '[data-view="simple"] .session-box .pptw { display: none !important; }';
71 if (b === 'cog') st.textContent = '[data-view="simple"] .session-box .tile-cog { display: none !important; }';
72 // The original tag defect, put back: the row moved out of
73 // `session-box-meta` on 2026-08-20 and inherited none of the rules
74 // that had kept it, so it was given a `display: none` in Simple and
75 // nothing went red. This is the break the check below was written
76 // for and it must go on reddening whatever else changes.
77 if (b === 'tags') st.textContent = '[data-view="simple"] .session-box .session-box-tags { display: none !important; }';
78 // The second way to lose a tag, which the first check could not see:
79 // the chip is drawn, has a rect with area, and lies outside the band
80 // its own row shows -- the geometry of a chip scrolled off the end of
81 // a horizontal scroller. The break imposes the clipping band itself
82 // rather than leaning on `.session-box-tags` being a scroller today,
83 // so it means the same thing if that rule is written differently
84 // tomorrow.
85 if (b === 'scrolled') st.textContent =
86 '[data-view="simple"] .session-box .session-box-tags {'
87 + ' flex-wrap: nowrap !important; overflow-x: auto !important;'
88 + ' overflow-y: hidden !important; }'
89 + '[data-view="simple"] .session-box .session-box-tags .tag-sm {'
90 + ' margin-left: 320px !important; }';
91 document.head.appendChild(st);
92 if (b === 'copy') {
93 // The classic bug: the view writes itself into every tile, so the
94 // tiles stop following it.
95 const orig = window.DaimondView.set;
96 window.DaimondView.set = function (v) {
97 const all = JSON.parse(localStorage.getItem('daimond-tile-prefs') || '{}');
98 document.querySelectorAll('.session-box[data-id]').forEach(el => {
99 const id = el.dataset.id;
100 all[id] = Object.assign({}, all[id], { detail: v });
101 });
102 localStorage.setItem('daimond-tile-prefs', JSON.stringify(all));
103 return orig.call(this, v);
104 };
105 }
106 if (b === 'stuck') {
107 // The rule as it stood BEFORE notes3: a stored `detail` beats the
108 // view. Put back from OUTSIDE, because `tileDetail` is a closure --
109 // so what is restored is the behaviour rather than the line, which
110 // is all a check is entitled to notice. It lasts until a tile is
111 // rendered afresh, which is long enough: the cascade below reads
112 // the tiles straight after the view moves.
113 const orig = window.DaimondView.set;
114 window.DaimondView.set = function (v) {
115 const r = orig.call(this, v);
116 const all = JSON.parse(localStorage.getItem('daimond-tile-prefs') || '{}');
117 document.querySelectorAll('.session-box[data-id]').forEach(el => {
118 const d = (all[el.dataset.id] || {}).detail;
119 if (d) el.dataset.detail = d;
120 });
121 return r;
122 };
123 }
124 }, BREAK);
125 }
126
127 await p.evaluate(() => DaimondDiamond.seedDefaults());
128 await p.waitForFunction(() =>
129 document.querySelectorAll('#diamond-list .diamond-box[data-id]').length >= 2,
130 null, { timeout: 20000 });
131
132 const ids = await p.evaluate(() =>
133 [...document.querySelectorAll('#diamond-list .diamond-box[data-id]')].map(b => b.dataset.id));
134 check(ids.length >= 2, 'two Diamonds to compare', String(ids.length));
135
136 const detailOf = () => p.evaluate(() =>
137 [...document.querySelectorAll('#diamond-list .diamond-box[data-id]')]
138 .map(b => b.dataset.detail));
139 const setView = (v) => p.evaluate((vv) => window.DaimondView.set(vv), v);
140
141 // ══ THE CASCADE ═══════════════════════════════════════════════════
142 await setView('simple');
143 await p.waitForTimeout(200);
144 check((await detailOf()).every(d => d === 'simple'),
145 'every tile follows the view when it is set to Simple',
146 (await detailOf()).join(','));
147
148 await setView('max');
149 await p.waitForTimeout(200);
150 check((await detailOf()).every(d => d === 'max'),
151 'and follows it to Max', (await detailOf()).join(','));
152
153 // The trap. Going Max and back must return every tile, which it only does
154 // if nothing wrote `max` into them on the way.
155 await setView('simple');
156 await p.waitForTimeout(200);
157 check((await detailOf()).every(d => d === 'simple'),
158 'and BACK -- the view did not write itself into the tiles on the way out',
159 (await detailOf()).join(','));
160
161 const stored = await p.evaluate(() => localStorage.getItem('daimond-tile-prefs') || '{}');
162 check(!/"detail"/.test(stored),
163 'and nothing wrote a per-tile detail on the way -- there is no control that can',
164 stored.slice(0, 120));
165
166 // ══ THE TILE'S DIALOG HAS NO LEVEL IN IT ══════════════════════════
167 // The control that used to sit here is gone, and the check that used to press
168 // it is replaced by the reason it existed: a tile must not be able to detach
169 // itself from the view. It cannot, if there is nothing in the dialog to
170 // detach it with.
171 //
172 // Asked of `[data-level]` and not of `.tile-dlg-level`, which is still worn
173 // by controls that survived -- the colour reset here, Fold context in a
174 // chat's dialog. A bare-class check would now be satisfied by furniture that
175 // has nothing to do with the view.
176 await p.evaluate((id) => {
177 document.querySelector(`#diamond-list .diamond-box[data-id="${id}"] .tile-cog`).click();
178 }, ids[0]);
179 await p.waitForSelector('.tile-dlg-card', { timeout: 8000 });
180 {
181 const dlg = await p.evaluate(() => {
182 const card = document.querySelector('.tile-dlg-card');
183 return {
184 levels: [...card.querySelectorAll('[data-level]')].map(b => b.dataset.level),
185 // The words as well as the markup: a control rebuilt under another
186 // class would pass a selector check and still be the thing notes3
187 // took out. Buttons only -- a model pulldown may legitimately hold
188 // a model with "max" in its name, and that is not a view control.
189 named: [...card.querySelectorAll('button')]
190 .map(b => (b.textContent || '').trim())
191 .filter(w => /^(simple|max)$/i.test(w)),
192 closer: !!card.querySelector('.tile-dlg-title .tile-dlg-done'),
193 };
194 });
195 check(dlg.levels.length === 0,
196 'the tile dialog offers no level control -- Simple and Max are global and nothing else',
197 dlg.levels.join(',') || 'none');
198 check(dlg.named.length === 0,
199 'and no button in it is labelled with either view',
200 dlg.named.join(',') || 'none');
201 // The way out moved to the top right and KEPT `tile-dlg-done`, which is
202 // what every close-path in the app and in this suite reaches for. If the
203 // class had gone with the button, those paths would silently stop closing
204 // anything.
205 check(dlg.closer, 'the way out is a cross in the title row, still classed tile-dlg-done');
206 }
207 await p.evaluate(() => {
208 const d = document.querySelector('.tile-dlg-card .tile-dlg-done'); if (d) d.click();
209 });
210 await p.waitForTimeout(300);
211 check(await p.evaluate(() => !document.querySelector('.tile-dlg-card')),
212 'and pressing it closes the dialog');
213
214 // ══ A DETAIL LEFT BY AN OLDER BUILD IS INERT ══════════════════════
215 // Written straight into storage on purpose: this is the state a user who
216 // pressed Max on a tile last month is carrying, and there is no longer any
217 // control that could produce it. What must not happen is that the tile obeys
218 // it, because then that user has one dense tile for ever with nothing in the
219 // app to mend it.
220 await p.evaluate((id) => {
221 const all = JSON.parse(localStorage.getItem('daimond-tile-prefs') || '{}');
222 all[id] = Object.assign({}, all[id], { detail: 'max' });
223 localStorage.setItem('daimond-tile-prefs', JSON.stringify(all));
224 }, ids[0]);
225 await setView('simple');
226 await p.waitForTimeout(200);
227 check((await detailOf()).every(d => d === 'simple'),
228 'a tile carrying a stored detail still follows the view', (await detailOf()).join(','));
229 await setView('max');
230 await p.waitForTimeout(200);
231 await setView('simple');
232 await p.waitForTimeout(200);
233 check((await detailOf()).every(d => d === 'simple'),
234 'and goes on following it, so no tile is stranded out of step',
235 (await detailOf()).join(','));
236 // Taken away again, so the sections below measure tiles and not this fixture.
237 await p.evaluate((id) => {
238 const all = JSON.parse(localStorage.getItem('daimond-tile-prefs') || '{}');
239 if (all[id]) { delete all[id].detail; localStorage.setItem('daimond-tile-prefs', JSON.stringify(all)); }
240 }, ids[0]);
241
242 // ══ INVARIANT 2: Simple hides no spending control ═════════════════
243 await setView('simple');
244 await p.waitForTimeout(250);
245 {
246 const vis = await p.evaluate(() => {
247 const box = document.querySelector('#diamond-list .diamond-box[data-id]');
248 const shown = (sel) => {
249 const e = box.querySelector(sel);
250 return !!e && getComputedStyle(e).display !== 'none';
251 };
252 const globalLight = document.querySelector('#pptw-global .pptw');
253 return {
254 light: shown('.pptw'),
255 cog: shown('.tile-cog'),
256 name: shown('.session-box-name'),
257 global: !!globalLight && getComputedStyle(globalLight).display !== 'none',
258 };
259 });
260 check(vis.light, 'Simple keeps the tile’s traffic light -- a spending control is never hidden');
261 check(vis.global, 'and the global spending control');
262 check(vis.name, 'and the name');
263 // AND THE TAGS, which the CSS block says in as many words and nothing
264 // asserted. On 2026-08-20 an agent moved the tag chips out of
265 // `session-box-meta` onto a row of their own -- a good change -- and the new
266 // row inherited none of the old rules, so it was given a `display: none` in
267 // Simple. That reverses "the last two are how a Diamond is found" three
268 // lines above where it was typed, and the suite stayed green, because every
269 // check here was about what Simple HIDES and none about what it keeps.
270 //
271 // Tagged on purpose rather than hoping a fixture has one: a tile with no
272 // tags satisfies "no tag is hidden" trivially, which is the vacuous pass
273 // this whole file is otherwise careful about.
274 const tagKept = await p.evaluate(async () => {
275 const box = document.querySelector('#diamond-list .diamond-box[data-id]');
276 const id = box.dataset.id;
277 // Through the ENGINE's own setter and the app's own redraw. Written first
278 // against a guessed `localStorage` key and a `reloadDiamonds` that does
279 // not exist -- neither is real, and a probe that seeds state the product
280 // does not read proves nothing about the product.
281 await DaimondCore.setDiamondTags(id, ['findme']);
282 await new Promise(r => setTimeout(r, 400));
283 const b2 = document.querySelector(`#diamond-list .diamond-box[data-id="${id}"]`);
284 const chips = [...(b2 ? b2.querySelectorAll('.tag-sm') : [])];
285 // LAYOUT, not the chip's own `display`. Written that way first and the
286 // break did not redden: hiding the PARENT row leaves each chip computing
287 // `inline-flex` quite happily -- `display: none` does not cascade, it
288 // simply stops the subtree being rendered. A rect with area is the only
289 // reading that answers "can this be seen".
290 //
291 // 2026-08-21: THAT LAST SENTENCE STOPPED BEING TRUE, and the app's own
292 // daimon is what noticed, unprompted. The tag row became a horizontal
293 // scroller -- `flex-wrap: nowrap; overflow-x: auto`, so every tag renders
294 // and the row is cut at its edge instead of capping the list with a `+N`
295 // chip that hid the one thing a tag exists for. A chip past that edge
296 // STILL REPORTS A RECT WITH WIDTH AND HEIGHT: `getBoundingClientRect`
297 // gives layout position and knows nothing about clipping or about the
298 // scroll offset of an ancestor. So `shown === present` went on passing,
299 // went on catching the `display: none` it was written for, and had
300 // quietly stopped meaning what it said. It proved RENDERED, not VISIBLE.
301 //
302 // A rect with area is still necessary and is no longer sufficient. What
303 // is measured now is that rect intersected with the band every CLIPPING
304 // ancestor actually shows -- any overflow that is not `visible`, the tag
305 // row first among them -- and then with the viewport. That is the same
306 // question asked properly, and it is the right answer in both CSS states:
307 // where nothing clips, the loop intersects nothing and the reading is the
308 // old one, so a plain wrapping row is not made to fail by it.
309 //
310 // A CHIP CUT IN HALF AT THE EDGE COUNTS AS VISIBLE, deliberately. The
311 // CSS says the cut is the affordance -- the chips "say 'more this way' by
312 // being cut at the edge" -- so half a chip is the design working and any
313 // positive overlap passes. Demanding a whole chip would redden the day a
314 // Diamond is filed under a long tag, which is use, not regression.
315 //
316 // `shown === present` is a fair thing to ask ONLY because the fixture
317 // seeds exactly one tag into a full-width rail, where nothing overflows.
318 // Seed a dozen and the honest assertion changes: chips past the edge
319 // would then be scrolled-away rather than lost, and this would have to
320 // become "the row is visible and every chip lies within its scrollable
321 // content". Add a tag to the line above and this check has to move too.
322 //
323 // --break tags the row hidden -- the original defect, still red
324 // --break scrolled the chips drawn, laid out past the row's own edge
325 const seen = (el) => {
326 const r = el.getBoundingClientRect();
327 if (r.width <= 0 || r.height <= 0) return false;
328 let l = r.left, tp = r.top, rt = r.right, bt = r.bottom;
329 for (let a = el.parentElement; a; a = a.parentElement) {
330 const cs = getComputedStyle(a);
331 // An overflow that is not `visible` clips on BOTH axes: setting one
332 // axis alone computes the other to `auto`, which clips as well.
333 if (cs.overflowX === 'visible' && cs.overflowY === 'visible') continue;
334 const ar = a.getBoundingClientRect();
335 l = Math.max(l, ar.left); tp = Math.max(tp, ar.top);
336 rt = Math.min(rt, ar.right); bt = Math.min(bt, ar.bottom);
337 if (rt <= l || bt <= tp) return false;
338 }
339 // The viewport clips everything, and clips it last.
340 l = Math.max(l, 0); tp = Math.max(tp, 0);
341 rt = Math.min(rt, window.innerWidth); bt = Math.min(bt, window.innerHeight);
342 return rt > l && bt > tp;
343 };
344 return {
345 present: chips.length,
346 shown: chips.filter(seen).length,
347 };
348 });
349 check(tagKept.present > 0,
350 'a tagged Diamond draws its tag chip at all -- the check below needs a subject',
351 `chips=${tagKept.present}`);
352 check(tagKept.present > 0 && tagKept.shown === tagKept.present,
353 'SIMPLE KEEPS THE TAGS -- they are how a Diamond is found',
354 `${tagKept.shown} of ${tagKept.present} visible`);
355 // ══ INVARIANT 1: the way in is always visible ═════════════════
356 check(vis.cog, 'and the cog, which is the route to everything Simple hides');
357
358 // The status header is rows of BUTTONS -- Version opens the release
359 // history, Tools opens the panel that tells a user what the app can do.
360 // Simple hid three of them for a while, which removes the route rather
361 // than the detail. That is invariant 1, and it needs its own check
362 // because the temptation to tidy the header will come back.
363 const head = await p.evaluate(() => {
364 const v = id => {
365 const e = document.getElementById(id);
366 return !!e && getComputedStyle(e).display !== 'none';
367 };
368 return { model: v('astat-model'), tools: v('astat-tools'), release: v('astat-release') };
369 });
370 check(head.tools && head.release && head.model,
371 'Simple keeps every status row that is a way IN -- hiding a button hides a route',
372 JSON.stringify(head));
373 }
374
375 // ══ INVARIANT 1, in full: nothing is Max-only ═════════════════════
376 // Everything hidden in Simple must be reachable through the dialog. Read the
377 // hidden set from the live page rather than from a list here, so a new thing
378 // hidden tomorrow is covered without editing this file.
379 {
380 // Give the Diamond a model and some spend, so the things Max hoists --
381 // the model chip and the per-object cost -- exist on the tile at all.
382 // Both are built at render time, so this is followed by a reload: a
383 // storage write after the tile is drawn changes nothing on screen, which
384 // is a fact about the app and not a thing to work around.
385 await p.evaluate((id) => {
386 const all = JSON.parse(localStorage.getItem('daimond-diamond-models') || '{}');
387 all[id] = { model: 'mock/fast', provider: 'custom' };
388 localStorage.setItem('daimond-diamond-models', JSON.stringify(all));
389 for (let i = 0; i < 3; i++) {
390 DaimondSignals.noteTurn({ ts: Date.now(), diamondId: id, model: 'mock/fast', usd: 0.02 });
391 }
392 }, ids[1]);
393 await p.reload({ waitUntil: 'domcontentloaded' });
394 await signInAs(s, 'view'); // a reload locks the app, as it should
395 await p.waitForSelector('#diamond-list .diamond-box[data-id]', { timeout: 20000 });
396 await p.waitForTimeout(900);
397
398 const shownIn = async (view) => {
399 await setView(view);
400 await p.waitForTimeout(300);
401 return p.evaluate((id) => {
402 const box = document.querySelector(`#diamond-list .diamond-box[data-id="${id}"]`);
403 return [...box.querySelectorAll('*')]
404 .filter(e => getComputedStyle(e).display !== 'none')
405 .map(e => (e.className || '').toString().split(' ')[0])
406 .filter(Boolean);
407 }, ids[1]);
408 };
409 const inMax = await shownIn('max');
410 const inSimple = await shownIn('simple');
411 check(inSimple.length < inMax.length,
412 'Simple shows strictly less of the same tile than Max does',
413 `${inSimple.length} vs ${inMax.length}`);
414 check(inSimple.every(c => inMax.indexOf(c) >= 0),
415 'and nothing appears in Simple that Max does not have -- Simple is a subset, not a second layout',
416 inSimple.filter(c => inMax.indexOf(c) < 0).join(',') || 'subset');
417 const modelish = (list) => list.filter(c => /tile-model|tile-worker|session-box-spend|tile-meter/.test(c));
418 check(modelish(inMax).length > 0,
419 'Max shows the model and cost controls on the tile', modelish(inMax).join(',') || '(none)');
420 check(modelish(inSimple).length === 0,
421 'Simple hides them, as asked', modelish(inSimple).join(',') || 'none');
422 const hidden = modelish(inMax);
423 await p.evaluate((id) => {
424 document.querySelector(`#diamond-list .diamond-box[data-id="${id}"] .tile-cog`).click();
425 }, ids[1]);
426 await p.waitForSelector('.tile-dlg-card', { timeout: 8000 });
427 const dlg = await p.evaluate(() => {
428 const c = document.querySelector('.tile-dlg-card');
429 return { controls: c.querySelectorAll('button, select, input, textarea').length,
430 text: c.textContent.replace(/\s+/g, ' ') };
431 });
432 check(dlg.controls > 4,
433 'the dialog carries the controls Simple took off the tile',
434 dlg.controls + ' controls');
435 // The model pulldowns are the concrete case the user named.
436 check(/model/i.test(dlg.text),
437 'and the dialog is where they are instead');
438 await p.evaluate(() => {
439 const d = document.querySelector('.tile-dlg-done'); if (d) d.click();
440 });
441 await p.waitForTimeout(250);
442 }
443
444 // ══ MAX FITS A PHONE ══════════════════════════════════════════════
445 await setView('max');
446 await p.setViewportSize({ width: 430, height: 800 });
447 await p.waitForTimeout(400);
448 {
449 const over = await p.evaluate(() => ({
450 doc: document.documentElement.scrollWidth,
451 win: window.innerWidth,
452 }));
453 check(over.doc <= over.win + 1,
454 'Max at 430px does not push the page sideways -- if the hoisted set does not fit, too much is hoisted',
455 `${over.doc} in ${over.win}`);
456 }
457 await p.setViewportSize({ width: 1500, height: 950 });
458
459 // ══ MIGRATION ═════════════════════════════════════════════════════
460 {
461 const mapped = await p.evaluate(() => {
462 const out = {};
463 for (const [skin, want] of [['sharp', 'max'], ['warm', 'simple'], [null, 'simple']]) {
464 localStorage.removeItem('daimond-view');
465 if (skin === null) localStorage.removeItem('daimond-skin');
466 else localStorage.setItem('daimond-skin', skin);
467 // initView is not exported; reproduce its one rule and assert the
468 // mapping rather than the function.
469 const saved = localStorage.getItem('daimond-view');
470 const got = saved || (localStorage.getItem('daimond-skin') === 'sharp' ? 'max' : 'simple');
471 out[String(skin)] = { got, want };
472 }
473 return out;
474 });
475 check(mapped.sharp.got === 'max',
476 'a stored "Compact" becomes Max -- that user wanted the most on screen', mapped.sharp.got);
477 check(mapped.warm.got === 'simple',
478 'a stored "Breathe" becomes Simple -- that user wanted calm', mapped.warm.got);
479 check(mapped.null.got === 'simple',
480 'and an unset skin is not a choice, so it becomes Simple', mapped.null.got);
481 }
482} catch (e) {
483 failures++;
484 console.log(' FAIL threw -- ' + (e && e.message ? e.message.split('\n')[0] : e));
485} finally {
486 await s.close();
487}
488
489console.log('');
490console.log(failures ? `verify_view: ${failures} FAILED` : 'verify_view: all checks pass.');
491process.exit(failures ? 1 : 0);