Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_models.mjs

45.5 KiB, 1 run

created by r2519314175:539, 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_models.mjs — a key per provider, and one model starred as the default.
2//
3// Daimond used to hold ONE provider: a base URL, a key, a model. The model you want for a cheap
4// classification is not the one you want for a hard refactor, and they rarely sit behind the same
5// key — so a key is held per provider now, and exactly one model is the default a new chat starts
6// on. This drives that: the migration off the old single config (which a real user HAS, with a
7// real key in it), adding a second provider without evicting the first, starring a default, and
8// the status row that counts what all of them can run.
9//
10// AND THE PANEL'S MESSAGE LINE, which for a month did not exist. `models.js` looked up
11// `#models-note` and NOTHING in the tree created one — no markup, no JS, not even a CSS rule —
12// so `if (n)` swallowed all three of the messages behind it: a provider that would not say what
13// credit is left, a models list that would not load, and a credit figure that is not a number.
14// A user typed `abc`, pressed Set, and the field simply did not take.
15//
16// The checks below assert the line EXISTS and says what. Never that no error is showing: an
17// element that was never created reports itself to a browser locator as HIDDEN, which is exactly
18// what a guard doing its job produces, so absence-only checks pass on a panel struck dumb.
19//
20// THE SAME MISTAKE, ONE PANEL OVER, and found on 2026-08-20: the button that asks a provider
21// for its models was drawn inside `if (!p.count)`, so a provider that had answered once could
22// never be asked again and a model released this morning appeared on nobody's screen. The owner
23// had to call `DaimondModels.fetchModels('openrouter')` from the browser console. The checks at
24// the end assert the button EXISTS on a row with a list — drawn disabled where it cannot be
25// used, never hidden, for the reason in the paragraph above — and that the row says when the
26// list was last asked for.
27//
28// AND THE CATALOGUE THAT ASKS FOR ITSELF, added 2026-08-20. The button is a person deciding to
29// ask; a list nobody presses the button for is a list frozen at the day the key was pasted. So a
30// stale one is re-asked when the panel opens and when the app starts — which is a request loop
31// with a fuse in it, because `fetchModels` ends in `save()`, `save()` redraws this panel, and a
32// redraw asks again. The credit probe beside it measured that loop at four thousand requests. So
33// the last checks below COUNT REQUESTS rather than reading the panel: the page's own `fetch` is
34// wrapped before the app loads, every catalogue request is intercepted, and the loop check drives
35// a thousand redraws and asserts the count stays at ONE.
36//
37// node dev/verify_models.mjs --break notemissing # the line taken back out of the markup
38// node dev/verify_models.mjs --break notewiped # the line eaten by the next redraw
39// node dev/verify_models.mjs --break emptyonly # the ask offered only to an empty provider
40// node dev/verify_models.mjs --break hidesealed # the ask hidden, not disabled, when unusable
41// node dev/verify_models.mjs --break mintedtoo # a second button on the credits row
42// node dev/verify_models.mjs --break nogate # THE LOOP: in-flight and floor taken off
43// node dev/verify_models.mjs --break alwaysstale # every list treated as stale, however new
44// node dev/verify_models.mjs --break mintedauto # the credits row auto-asked as well
45// node dev/verify_models.mjs --break sealedauto # a keyless or sealed provider auto-asked
46// node dev/verify_models.mjs --break ignoreoffline # asked with no network to ask over
47// node dev/verify_models.mjs --break loudfail # an ask nobody made putting its error up
48// node dev/verify_models.mjs --break nobootcall # the boot never asks; only the panel does
49// node dev/verify_models.mjs # and then, clean
50import fs from 'node:fs';
51import path from 'node:path';
52import { fileURLToPath } from 'node:url';
53import { open, signInAs, shot } from './harness.mjs';
54
55const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
56const die = (why) => { console.error('ABORT: ' + why); process.exit(2); };
57
58const ok = [], bad = [];
59const check = (name, pass, detail) => {
60 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
61 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
62};
63
64const BREAK = (() => {
65 const i = process.argv.indexOf('--break');
66 return i > 0 ? String(process.argv[i + 1] || '') : '';
67})();
68
69// ── The patches that prove the message-line checks can fail ────────────
70//
71// Each damages ONE shipped file, served to the browser in place of the real one.
72//
73// notemissing the defect exactly as it shipped: no `#models-note` anywhere, so every message
74// is swallowed by a guard that looks correct.
75// notewiped the line is there, but `render()` blanks it — the arrangement you get by
76// putting the message INSIDE the list that render rewrites wholesale. The
77// immediate message survives; the one the user is still reading does not.
78// emptyonly the catalogue defect exactly as it shipped: the ask, and the age line under
79// it, drawn only for a provider listing nothing.
80// hidesealed the ask hidden rather than disabled when the row cannot use it — the shape of
81// mistake that made the first one invisible to a locator.
82// mintedtoo the guard that keeps the credits row to its one affordance taken off, so Top
83// up and an ask sit side by side offering work that has already happened.
84//
85// And the six that damage the automatic ask. Five of them take one rule out of `listDue`, which
86// is the whole of the policy; the sixth cuts the boot off from it.
87//
88// nogate in-flight suppression AND the floor removed, leaving staleness alone to stop
89// the loop — which it cannot, because a redraw arrives long before an answer
90// does. This is the one that matters: it turns one request into a thousand.
91// alwaysstale every catalogue counted as stale, so a list asked for an hour ago is asked
92// for again on the next redraw.
93// mintedauto the credits row asked automatically as well, duplicating the ask `syncCredits`
94// has already made with a key that did not exist before the mint.
95// sealedauto the key check removed, so a keyless row and a locked one are both asked.
96// Nothing reaches the network — `fetchModels` throws for want of a key before it
97// gets there — which is why those two checks read the GATE as well as the count:
98// a failure has been recorded against a credential that was never there.
99// ignoreoffline asked with no network to ask over, which is a guaranteed failure recorded
100// against a provider that did nothing wrong.
101// loudfail the silent failure made loud: an ask nobody requested puts the provider's
102// error on the panel's message line, in front of a user who just opened it.
103//
104// The one line three of the breaks below rewrite. Held once, so a break that stops matching
105// fails loudly at `hurt` rather than quietly patching nothing.
106const ASK_GUARD = "if (!p.minted) {\n\t\t\t\t\tvar refetch";
107
108/// The in-flight test and the floor, as one block, so `nogate` cannot silently patch half of it.
109const LIST_GATE = "\t\tvar st = lists[id];\n\t\tif (st) {\n"
110 + "\t\t\tif (st.busy) return false;\t\t\t\t// one in flight is one request already\n"
111 + "\t\t\tif ((Date.now() - st.at) < listWait(id)) return false;\n\t\t}\n";
112
113const BREAKS = {
114 notemissing: {
115 what: 'the message line taken back out of the markup, exactly as it shipped',
116 file: 'www/index.html',
117 type: 'text/html; charset=utf-8',
118 edit: (src) => src.replace(/\n[ \t]*<div id="models-note"[^>]*><\/div>/, ''),
119 },
120 notewiped: {
121 what: 'render() blanking the message line, as it would if the line lived inside the list',
122 file: 'www/js/models.js',
123 type: 'text/javascript',
124 edit: (src) => src.replace(
125 "\t\telse askWhenShown();\n\t\tel.innerHTML = '';\n",
126 "\t\telse askWhenShown();\n\t\tel.innerHTML = '';\n\t\tnote('');\n"),
127 },
128 emptyonly: {
129 what: 'the ask offered only to a provider that lists nothing, exactly as it shipped',
130 file: 'www/js/models.js',
131 type: 'text/javascript',
132 edit: (src) => src.replace(ASK_GUARD, "if (!p.count) {\n\t\t\t\t\tvar refetch"),
133 },
134 hidesealed: {
135 what: 'the ask hidden rather than disabled on a row that cannot use it',
136 file: 'www/js/models.js',
137 type: 'text/javascript',
138 edit: (src) => src.replace(ASK_GUARD, "if (!p.minted && p.ready) {\n\t\t\t\t\tvar refetch"),
139 },
140 mintedtoo: {
141 what: 'a second button on the credits row, beside the Top up that is its one affordance',
142 file: 'www/js/models.js',
143 type: 'text/javascript',
144 edit: (src) => src.replace(ASK_GUARD, "if (true) {\n\t\t\t\t\tvar refetch"),
145 },
146 nogate: {
147 what: 'the in-flight test and the floor taken off the automatic ask, leaving the loop open',
148 file: 'www/js/models.js',
149 type: 'text/javascript',
150 edit: (src) => src.replace(LIST_GATE, ''),
151 },
152 alwaysstale: {
153 what: 'every catalogue counted as stale, however recently it was asked for',
154 file: 'www/js/models.js',
155 type: 'text/javascript',
156 edit: (src) => src.replace(
157 'return (Date.now() - ms(p.fetched)) >= LIST_STALE_MS;', 'return true;'),
158 },
159 mintedauto: {
160 what: 'the credits row asked automatically too, on top of the ask every mint already makes',
161 file: 'www/js/models.js',
162 type: 'text/javascript',
163 edit: (src) => src.replace('\t\tif (id === CREDITS) return false;\n', ''),
164 },
165 sealedauto: {
166 what: 'a provider with no readable key asked anyway, to be told what the app already knew',
167 file: 'www/js/models.js',
168 type: 'text/javascript',
169 edit: (src) => src.replace('\t\tif (!canRun(id)) return false;\n', ''),
170 },
171 ignoreoffline: {
172 what: 'asked with no network to ask over, and the certain failure counted against the row',
173 file: 'www/js/models.js',
174 type: 'text/javascript',
175 edit: (src) => src.replace(
176 "\t\tif (typeof navigator !== 'undefined' && navigator.onLine === false) return;\n", ''),
177 },
178 loudfail: {
179 what: 'an ask nobody made putting the provider\u2019s error on the panel\u2019s message line',
180 file: 'www/js/models.js',
181 type: 'text/javascript',
182 edit: (src) => src.replace(
183 '\t\t\t/* a revoked key, a typo in a URL, a rate limit: none of it was asked for */\n',
184 '\t\t\tnote(e && e.message ? e.message : String(e));\n'),
185 },
186 nobootcall: {
187 what: 'the boot cut off from the ask, so only somebody opening the panel refreshes a list',
188 file: 'www/js/daimond.js',
189 type: 'text/javascript',
190 edit: (src) => src.replace(
191 '\t\tif (window.DaimondModels) DaimondModels.refreshLists();\n', ''),
192 },
193};
194if (BREAK && !BREAKS[BREAK]) die(`no break called "${BREAK}"`);
195
196/// The damaged file the browser will be given, or null when nothing is broken.
197const hurt = (() => {
198 if (!BREAKS[BREAK]) return null;
199 const b = BREAKS[BREAK];
200 const src = fs.readFileSync(path.join(ROOT, b.file), 'utf8');
201 const out = b.edit(src);
202 if (out === src) die(`the "${BREAK}" break no longer matches ${b.file}`);
203 return { ...b, body: out };
204})();
205if (hurt) console.log(`BREAK ${BREAK}: ${hurt.what}\n`);
206
207// ── Counting the catalogue requests, twice ─────────────────────────────
208//
209// The loop check has to be able to count to four thousand, so it counts REQUESTS and never
210// anything on the panel. Two instruments, because each one alone can lie:
211//
212// * `page.route` in Node, which is the browser's own interception and therefore the honest
213// answer — but a thousand requests to one host queue behind Chrome's six-connection limit,
214// so a run that does not wait for them to drain reads low.
215// * a wrapper round the page's `fetch`, installed before any of the app's scripts run. It
216// counts the moment the app calls, with no queueing in between, which is what a synchronous
217// run of redraws needs. `models.js` looks `fetch` up globally at each call, so a wrapper on
218// `window` is the call it makes.
219//
220// Both are reported. They should agree; where they do not, the wrapper is the count of asks the
221// app MADE and the route is the count that reached the network.
222
223/// Every catalogue request the browser issued, by path, as Playwright saw it.
224const hits = {};
225/// A fixture endpoint, and what it should do when asked.
226///
227/// `answer` stands in for a provider that publishes a list. `refuse` is a key the provider will
228/// not accept — the ordinary failure, which must be silent. `drop` never gets off the machine at
229/// all, so a thousand of them cannot queue behind six connections, and it never writes `fetched`,
230/// which is what keeps the row stale for the whole of the loop check.
231const FIXTURES = {
232 '/boot/v1/models': 'answer',
233 '/fixture/v1/models': 'answer',
234 '/loop/v1/models': 'drop',
235 '/refuse/v1/models': 'refuse',
236 // None of these may ever be asked. They answer rather than refuse so that a break which
237 // leaks a request leaks a SUCCESSFUL one, and the damage shows in the panel as well as here.
238 '/fresh/v1/models': 'answer',
239 '/nokey/v1/models': 'answer',
240 '/sealed/v1/models': 'answer',
241 '/credits/v1/models': 'answer',
242 '/off/v1/models': 'answer',
243};
244const CATALOGUE = JSON.stringify({ data: [{ id: 'listed-a' }, { id: 'listed-b' }] });
245
246const s = await open({
247 name: 'models', connect: false,
248 route: async (pg) => {
249 // Before any of the app's own scripts: `models.js` reads `window.fetch` at the moment it
250 // asks, so this is the call it makes.
251 await pg.addInitScript(() => {
252 window.__asked = {};
253 const real = window.fetch;
254 window.fetch = function (input) {
255 try {
256 const u = typeof input === 'string' ? input : (input && input.url) || '';
257 const path = new URL(u, location.href).pathname;
258 if (/\/models$/.test(path)) window.__asked[path] = (window.__asked[path] || 0) + 1;
259 } catch (e) { /* not a URL this test is about */ }
260 return real.apply(this, arguments);
261 };
262 });
263 // EVERY catalogue request, wherever it is bound. The fixture endpoints are same-origin
264 // on purpose — a cross-origin one carries an `authorization` header and so drags a CORS
265 // preflight in front of it, which is a second request this test would have to reason
266 // about. Anything else asking for a `/models` is aborted rather than allowed out: this
267 // change makes the app ask the real Fireworks and the real Groq for their catalogues at
268 // boot, and a test suite must not become a client of somebody else's API.
269 await pg.route((url) => /\/models$/.test(url.pathname), (r) => {
270 const at = new URL(r.request().url()).pathname;
271 hits[at] = (hits[at] || 0) + 1;
272 const kind = FIXTURES[at];
273 if (kind === 'answer') {
274 return r.fulfill({ status: 200, contentType: 'application/json', body: CATALOGUE });
275 }
276 if (kind === 'refuse') {
277 return r.fulfill({ status: 401, contentType: 'application/json', body: '{"error":"no"}' });
278 }
279 return r.abort();
280 });
281 if (!hurt) return;
282 // The document is served at `/`, not at `/index.html`, so a glob on the file name
283 // cannot match it.
284 if (hurt.file.endsWith('index.html')) {
285 await pg.route((url) => url.pathname === '/' || url.pathname === '/index.html',
286 (r) => r.fulfill({ status: 200, contentType: hurt.type, body: hurt.body }));
287 } else {
288 await pg.route('**/' + path.basename(hurt.file),
289 (r) => r.fulfill({ status: 200, contentType: hurt.type, body: hurt.body }));
290 }
291 },
292});
293const p = s.page;
294await p.waitForTimeout(1500);
295
296// ── The migration ───────────────────────────────────────────────────────
297//
298// A user on the old build has a provider, a key and a model in `daimond-byok`. The shape changed
299// underneath them; losing any of it would be the app forgetting something they told it.
300
301await p.evaluate(() => {
302 localStorage.setItem('daimond-byok', JSON.stringify({
303 baseUrl: 'https://api.fireworks.ai/inference/v1/chat/completions',
304 apiKey: 'old-single-key',
305 model: 'accounts/fireworks/models/glm-5p2',
306 maxTokens: 4096, tools: true,
307 }));
308 localStorage.setItem('daimond-models', JSON.stringify([
309 'accounts/fireworks/models/glm-5p2', 'accounts/fireworks/models/other',
310 ]));
311 localStorage.removeItem('daimond-models-v2'); // as if this build had never run
312});
313await p.reload({ waitUntil: 'domcontentloaded' });
314await signInAs(s, 'models');
315await p.waitForTimeout(2000);
316
317const migrated = await p.evaluate(() => {
318 const M = window.DaimondModels;
319 const provs = M.providers();
320 const d = M.getDefault();
321 return {
322 provs: provs.map(x => ({ id: x.id, hasKey: x.hasKey, count: x.count })),
323 def: d,
324 key: M.keyFor('fireworks'),
325 ready: M.ready(),
326 count: M.count(),
327 };
328});
329check('the single provider is carried into the store that holds many',
330 migrated.provs.length === 1 && migrated.provs[0].id === 'fireworks' && migrated.provs[0].hasKey,
331 JSON.stringify(migrated.provs));
332check('its key survives the change of shape', migrated.key === 'old-single-key');
333check('and the model they chose is still the default',
334 migrated.def.provider === 'fireworks' && /glm-5p2$/.test(migrated.def.model),
335 `${migrated.def.provider} / ${migrated.def.model}`);
336check('the app can still run', migrated.ready === true);
337
338// ── The status row counts models, not providers ─────────────────────────
339
340const rail = await p.evaluate(() => {
341 const r = document.getElementById('astat-model');
342 return { text: r ? r.textContent.trim() : '(none)', count: window.DaimondModels.count() };
343});
344check('the status row says Models and counts them',
345 /Models/.test(rail.text) && rail.text.includes(String(rail.count)),
346 `${rail.text} (count ${rail.count})`);
347
348// ── A second provider joins; it does not evict the first ────────────────
349
350const second = await p.evaluate(async () => {
351 const M = window.DaimondModels;
352 M.addProvider('groq', {});
353 await M.setKey('groq', 'the-groq-key');
354 // Stand in for the provider's /models answer: the network is not under test here.
355 M.providers();
356 const store = JSON.parse(localStorage.getItem('daimond-models-v2'));
357 store.providers.groq.models = ['llama-3.3-70b', 'mixtral-8x7b'];
358 localStorage.setItem('daimond-models-v2', JSON.stringify(store));
359 M.init({}); // reload the store from disk
360 await M.unseal();
361 return {
362 provs: M.providers().map(x => ({ id: x.id, count: x.count, hasKey: x.hasKey })),
363 count: M.count(),
364 fireworksKey: M.keyFor('fireworks'),
365 groqKey: M.keyFor('groq'),
366 def: M.getDefault(),
367 };
368});
369check('a second provider is added alongside the first',
370 second.provs.length === 2, JSON.stringify(second.provs));
371check('and does not evict the first provider’s key',
372 second.fireworksKey === 'old-single-key' && second.groqKey === 'the-groq-key');
373check('the model count is the sum across providers',
374 second.count === 4, second.count + ' models');
375check('and adding a provider does not silently move the default',
376 second.def.provider === 'fireworks', second.def.provider);
377
378// ── Starring a different default ────────────────────────────────────────
379
380const starred = await p.evaluate(() => {
381 const M = window.DaimondModels;
382 M.setDefault('groq', 'llama-3.3-70b');
383 const r = M.resolve('', ''); // what a NEW chat would run on
384 const keep = M.resolve('fireworks', 'accounts/fireworks/models/glm-5p2');
385 return { def: M.getDefault(), resolved: r, kept: keep };
386});
387check('starring a model makes it what a new chat runs on',
388 starred.resolved && starred.resolved.provider === 'groq'
389 && starred.resolved.model === 'llama-3.3-70b'
390 && starred.resolved.apiKey === 'the-groq-key',
391 JSON.stringify(starred.resolved && { p: starred.resolved.provider, m: starred.resolved.model }));
392check('a chat already running on another provider still resolves to it',
393 starred.kept && starred.kept.provider === 'fireworks'
394 && starred.kept.apiKey === 'old-single-key',
395 'an existing chat is not dragged onto the new default');
396
397// ── The panel ───────────────────────────────────────────────────────────
398
399await p.evaluate(() => { document.getElementById('astat-model').click(); });
400await p.waitForTimeout(700);
401
402const panel = await p.evaluate(() => {
403 const list = document.getElementById('models-list');
404 const heads = [...document.querySelectorAll('.models-prov-name')].map(e => e.textContent);
405 return {
406 shown: !!(document.getElementById('admin-models') || {}).offsetParent,
407 heads: heads,
408 footer: (document.querySelector('.models-default') || {}).textContent || '',
409 };
410});
411check('the Models row opens the models form', panel.shown === true);
412check('and it lists every provider', panel.heads.length === 2, panel.heads.join(', '));
413check('and says plainly what a new chat will start on',
414 /Groq/.test(panel.footer) && /llama-3\.3-70b/.test(panel.footer), panel.footer.trim());
415
416// Expand one and check the star is on the model itself.
417await p.evaluate(() => {
418 [...document.querySelectorAll('.models-prov-head')]
419 .find(h => /Groq/.test(h.textContent)).click();
420});
421await p.waitForTimeout(400);
422const expanded = await p.evaluate(() => {
423 const models = [...document.querySelectorAll('.models-model')].map(m => m.textContent.trim());
424 const on = (document.querySelector('.models-model.on') || {}).textContent || '';
425 return { models, on };
426});
427check('expanding a provider shows its models, with the default starred',
428 expanded.models.length === 2 && /★/.test(expanded.on) && /llama-3\.3-70b/.test(expanded.on),
429 expanded.on.replace(/\s+/g, ' '));
430
431// ── The panel's message line ────────────────────────────────────────────
432//
433// EXISTENCE FIRST, because that is the whole defect. `models-note` appeared exactly once in the
434// tree — in the lookup that could never find it.
435
436const line = await p.evaluate(() => {
437 const n = document.getElementById('models-note');
438 const list = document.getElementById('models-list');
439 return {
440 there: !!n,
441 inList: !!(n && list && list.contains(n)),
442 live: n ? n.getAttribute('aria-live') : '',
443 };
444});
445check('the Models panel HAS a message line, under the id the code looks it up by',
446 line.there,
447 line.there ? `aria-live="${line.live}"`
448 : 'NO #models-note — every message this panel can make is swallowed by its own guard');
449check('and it sits outside the list that render() rewrites wholesale',
450 line.there && !line.inList,
451 !line.there ? 'there is no line to place'
452 : line.inList ? 'inside #models-list, where the next redraw will take it' : 'beside the list');
453
454const form = await p.evaluate(() => ({
455 input: !!document.querySelector('.models-credit-input'),
456 set: !!document.querySelector('.models-credit-form .models-refetch'),
457}));
458check('the expanded provider offers the “I have this much” field',
459 form.input && form.set, JSON.stringify(form));
460
461// `abc` is not an amount. Until this landed the field simply did not take: an early return with
462// no message, no styling and nothing whatever to say what was wrong with what had been typed.
463const typed = await p.evaluate(() => {
464 const M = window.DaimondModels;
465 const want = window.DaimondI18n ? DaimondI18n.t('models.credit_base_bad') : '';
466 const inp = document.querySelector('.models-credit-input');
467 const btn = document.querySelector('.models-credit-form .models-refetch');
468 const held = () => JSON.stringify(M.providers().map(x => [x.id, (x.credit || {}).baseUsd]));
469 const before = held();
470 inp.value = 'abc';
471 btn.click();
472 const n = document.getElementById('models-note');
473 return {
474 want, before, after: held(),
475 there: !!n,
476 text: n ? n.textContent.trim() : '',
477 seen: !!(n && n.offsetParent !== null),
478 };
479});
480check('a credit figure that is not a number is refused IN WORDS, not by silence',
481 typed.there && typed.text.length > 0 && typed.text === typed.want,
482 typed.there ? `“${typed.text || '(the panel said nothing at all)'}”`
483 : 'there is no message line to say it on');
484check('and the message is on screen, not merely in the document',
485 typed.seen === true, typed.there ? '' : 'no element');
486check('and nothing was written: “abc” did not become a balance',
487 typed.after === typed.before, typed.after.slice(0, 90));
488
489// The list is rewritten wholesale on every render — collapsing a row is enough — and the
490// complaint belongs to what the user just typed, not to the list. A background redraw (a sync
491// pull, a change of language) must not take it off the screen while they are reading it.
492await p.evaluate(() => { window.DaimondModels.render(); });
493await p.waitForTimeout(400);
494const survived = await p.evaluate(() => {
495 const n = document.getElementById('models-note');
496 return { there: !!n, text: n ? n.textContent.trim() : '' };
497});
498check('and it is still on screen after the provider list redraws',
499 survived.there && survived.text === typed.want,
500 survived.there ? `“${survived.text || '(the redraw ate it)'}”` : 'the message line is gone');
501
502// And a figure that IS a number clears it: a panel that goes on complaining about something
503// already fixed teaches the user to stop reading it.
504const cleared = await p.evaluate(() => {
505 const inp = document.querySelector('.models-credit-input');
506 const btn = document.querySelector('.models-credit-form .models-refetch');
507 inp.value = '12.50';
508 btn.click();
509 const n = document.getElementById('models-note');
510 return { text: n ? n.textContent.trim() : '(no message line)' };
511});
512check('and a figure that IS a number clears the complaint', cleared.text === '', `“${cleared.text}”`);
513
514// ── Asking a provider again ─────────────────────────────────────────────
515//
516// Groq already lists two models, so it is exactly the row the old `if (!p.count)` silenced.
517// `together` is added with no key, to prove the button is DRAWN and disabled rather than left
518// out: an element that was never created reports itself to a locator as hidden, which is
519// indistinguishable from a guard doing its job — the mistake this whole file was written about.
520// A credits row is put in beside them because it is the one row that must NOT gain the button.
521//
522// Every "is it there" test below reads a bounding rectangle. A computed `display` does not
523// cascade, so an element inside a hidden parent still reports `display: block`.
524
525const STAMP = Date.UTC(2026, 6, 14, 3, 25); // a fixed moment, so the age line can be read back
526
527await p.evaluate((stamp) => {
528 const M = window.DaimondModels;
529 M.addProvider('together', {}); // configured, keyless, and therefore not ready
530 const raw = JSON.parse(localStorage.getItem('daimond-models-v2'));
531 raw.providers.groq.fetched = stamp; // a catalogue with a date on it
532 // The minted row as it sits on disk: name, host and models are ordinary and ARE stored;
533 // the key never is. `credits.state` stays '', so no Top up is drawn either.
534 raw.providers.credits = {
535 name: 'Daimond credits', url: '', key: '', keyEnc: '',
536 models: ['z-ai/glm-5.2'], fetched: 0,
537 };
538 localStorage.setItem('daimond-models-v2', JSON.stringify(raw));
539 M.init({});
540 return M.unseal();
541}, STAMP);
542
543/// Open one provider row by id, and leave it open.
544const openRow = async (id) => {
545 await p.evaluate((pid) => {
546 window.DaimondModels.render();
547 const row = document.querySelector('.models-prov[data-prov="' + pid + '"]');
548 if (row && !row.querySelector('.models-prov-body')) row.querySelector('.models-prov-head').click();
549 }, id);
550 await p.waitForTimeout(250);
551};
552
553/// What one provider row draws: the ask, its label and state, and the age line under it.
554///
555/// Direct children only. The credit block's own Set button is a `.models-refetch` too, nested
556/// inside `.models-credit-form`, and counting it would let the ask disappear unnoticed.
557const readRow = (id) => p.evaluate((pid) => {
558 const box = (e) => {
559 if (!e) return null;
560 const r = e.getBoundingClientRect();
561 return { w: Math.round(r.width), h: Math.round(r.height) };
562 };
563 const row = document.querySelector('.models-prov[data-prov="' + pid + '"]');
564 if (!row) return { row: false };
565 const body = row.querySelector('.models-prov-body');
566 const own = body ? [...body.children].filter(e => e.classList.contains('models-refetch')) : [];
567 const btn = own[0] || null;
568 const age = body ? body.querySelector('.models-list-age') : null;
569 return {
570 row: true,
571 open: !!body,
572 asks: own.length,
573 text: btn ? btn.textContent.trim() : '',
574 off: btn ? btn.disabled : null,
575 btnBox: box(btn),
576 age: !!age,
577 ageText: age ? age.textContent.trim() : '',
578 ageBox: box(age),
579 };
580}, id);
581
582const words = await p.evaluate(() => ({
583 again: DaimondI18n.t('models.ask_provider_again'),
584 first: DaimondI18n.t('models.ask_provider'),
585 nokey: DaimondI18n.t('models.add_key_first'),
586 never: DaimondI18n.t('models.list_never'),
587}));
588
589await openRow('groq');
590const listed = await readRow('groq');
591check('a provider that ALREADY lists models is still offered a way to ask again',
592 listed.asks === 1 && !!listed.btnBox && listed.btnBox.w > 0 && listed.btnBox.h > 0,
593 listed.asks === 0 ? 'no ask on a row with a list — it can be asked once and never again'
594 : `${listed.asks} asks, box ${JSON.stringify(listed.btnBox)}`);
595check('and the label says AGAIN, not the words for a provider that has never answered',
596 listed.text === words.again && listed.text !== words.first, `“${listed.text}”`);
597check('and it is live, because this row can ask', listed.off === false, 'disabled=' + listed.off);
598check('the row says when the list was last asked for',
599 listed.age && !!listed.ageBox && listed.ageBox.h > 0
600 && listed.ageText.length > 0 && listed.ageText !== words.never,
601 listed.age ? `“${listed.ageText}”` : 'no .models-list-age — a count with no date on it');
602
603await openRow('together');
604const keyless = await readRow('together');
605check('a provider with no key still DRAWS the ask, rather than hiding it',
606 keyless.asks === 1 && !!keyless.btnBox && keyless.btnBox.w > 0 && keyless.btnBox.h > 0,
607 keyless.asks === 0 ? 'nothing drawn — indistinguishable from a guard working'
608 : JSON.stringify(keyless.btnBox));
609check('drawn disabled, and saying what is missing',
610 keyless.off === true && keyless.text === words.nokey, `“${keyless.text}” disabled=${keyless.off}`);
611check('and a list never asked for says so',
612 keyless.age && keyless.ageText === words.never, `“${keyless.ageText}”`);
613
614await openRow('credits');
615const minted = await readRow('credits');
616check('the credits row keeps its one affordance and gains no ask',
617 minted.open && minted.asks === 0 && !minted.age,
618 minted.open ? `${minted.asks} asks, age=${minted.age}` : 'the row did not open');
619
620// The button is wired, not decoration. The label flips the instant it is pressed; what the
621// provider then says is the provider's business and is not waited for here.
622const pressed = await p.evaluate(() => {
623 const asking = DaimondI18n.t('models.asking');
624 const body = document.querySelector('.models-prov[data-prov="groq"] .models-prov-body');
625 const btn = body ? [...body.children].find(e => e.classList.contains('models-refetch')) : null;
626 // A missing button is a FAILURE to report, not an exception to die on: a break that
627 // removes it must still reach the summary and the browser must still be closed.
628 if (!btn) return { asking, there: false, now: '', off: null };
629 btn.click();
630 return { asking, there: true, now: btn.textContent.trim(), off: btn.disabled };
631});
632check('pressing it actually asks — the label and the button both change at once',
633 pressed.there && pressed.now === pressed.asking && pressed.off === true,
634 pressed.there ? `“${pressed.now}”` : 'there is no button to press');
635
636// THE MOMENT ITSELF REACHES THE LINE. The words belong to the locale files; the wiring belongs
637// here. A stand-in table with a marked placeholder proves the stamp is interpolated whatever
638// the sentence around it turns out to say, and does it now rather than after the locales land.
639const dated = await p.evaluate(async (stamp) => {
640 DaimondI18n.register('de', {
641 'models.list_asked': 'A<{when}>B',
642 'models.list_never': 'NEVER',
643 });
644 await DaimondI18n.setLocale('de');
645 window.DaimondModels.render();
646 const at = (pid) => {
647 const e = document.querySelector('.models-prov[data-prov="' + pid + '"] .models-list-age');
648 return e ? e.textContent.trim() : '';
649 };
650 const out = { asked: at('groq'), never: at('together'),
651 day: new Date(stamp).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) };
652 await DaimondI18n.setLocale('en');
653 return out;
654}, STAMP);
655check('the age line interpolates the moment the list was asked for',
656 /^A<.+>B$/.test(dated.asked), `“${dated.asked}”`);
657check('and the moment is THIS list’s, not some other date',
658 dated.asked.includes(dated.day), `“${dated.asked}” should carry “${dated.day}”`);
659check('while a list never asked for takes the other sentence entirely',
660 dated.never === 'NEVER', `“${dated.never}”`);
661
662// ── A catalogue that has gone stale asks for itself ─────────────────────
663//
664// Everything above is somebody pressing something. This is the app deciding, which is why the
665// checks below count requests rather than reading the panel: the failure mode of a self-refresh
666// is not "nothing happened", it is "four thousand things happened", and no amount of looking at
667// the screen can tell those apart from the one thing that should have.
668//
669// The fixture providers are same-origin, so a request to one is a request this test fully
670// controls and no CORS preflight comes with it. Four of them are seeded WITHOUT a key and given
671// one later, which is the only way to hold a row back from the boot pass and still have it due
672// when the panel opens.
673
674const NOW = Date.now();
675const DAY = 24 * 60 * 60 * 1000;
676const OLD = NOW - 3 * DAY; // three days: stale by any reading of the threshold
677const NEW = NOW - 60 * 60 * 1000; // an hour: not stale by any reading of it
678
679/// What the browser has asked for, by path, from inside the page. See the two instruments above.
680const askedIn = () => p.evaluate(() => JSON.parse(JSON.stringify(window.__asked || {})));
681
682/// Wait until a path has been asked for `want` times, or give up and let the check say so.
683const settle = async (at, want, ms = 4000) => {
684 const until = Date.now() + ms;
685 for (;;) {
686 const a = await askedIn();
687 if ((a[at] || 0) >= want || Date.now() > until) return a;
688 await p.waitForTimeout(120);
689 }
690};
691
692await p.evaluate((t) => {
693 const raw = JSON.parse(localStorage.getItem('daimond-models-v2'));
694 const at = (leaf) => location.origin + '/' + leaf + '/v1/chat/completions';
695 const row = (leaf, key, fetched, extra) => Object.assign({
696 name: leaf, url: at(leaf), key: key, keyEnc: '', models: ['listed-old'], fetched: fetched,
697 }, extra || {});
698 // Asked at boot: a key already in place, and a list three days old.
699 raw.providers.bootp = row('boot', 'boot-key', t.old);
700 // Never asked, each for its own reason.
701 raw.providers.fresh = row('fresh', 'fresh-key', t.new); // asked an hour ago
702 raw.providers.nokeyp = row('nokey', '', t.old); // nothing to ask with
703 raw.providers.sealedp = row('sealed', '', t.old, { keyEnc: 'not-a-real-blob' }); // locked
704 // The minted row, given a readable key ON PURPOSE. Without one it would be skipped for want
705 // of a key and the rule that actually protects it would never be reached.
706 raw.providers.credits = row('credits', 'minted-key', t.old, { name: 'Daimond credits' });
707 // Keyless for now, so the boot pass leaves them alone and the panel finds them due.
708 raw.providers.stale = row('fixture', '', t.old);
709 raw.providers.refuse = row('refuse', '', t.old);
710 raw.providers.loopp = row('loop', '', t.old);
711 raw.providers.offp = row('off', '', t.old);
712 localStorage.setItem('daimond-models-v2', JSON.stringify(raw));
713}, { old: OLD, new: NEW });
714
715await p.reload({ waitUntil: 'domcontentloaded' });
716await signInAs(s, 'models');
717await settle('/boot/v1/models', 1, 6000);
718await p.waitForTimeout(600);
719
720const booted = await askedIn();
721check('a stale catalogue is asked for when the app starts, with nobody watching',
722 (booted['/boot/v1/models'] || 0) === 1,
723 `${booted['/boot/v1/models'] || 0} asks — the boot is where a key pasted last year gets a list`);
724check('a catalogue asked for an hour ago is left alone',
725 !booted['/fresh/v1/models'], `${booted['/fresh/v1/models'] || 0} asks`);
726check('the credits row is never asked automatically, however stale its list',
727 !booted['/credits/v1/models'],
728 `${booted['/credits/v1/models'] || 0} asks — every mint already refetches it`);
729// A REQUEST COUNT IS NOT ENOUGH HERE, and the break proved it: `fetchModels` throws for want of
730// a key before it reaches the network, so a provider asked in error issues nothing and looks, to
731// a counter, exactly like one that was never asked. The gate record is what tells them apart —
732// an ask that got as far as being stamped spent this row's floor and put a failure against a
733// credential that was simply not there.
734const gateAfterBoot = await p.evaluate(() => window.DaimondModels.listAsks());
735check('a provider with no key is never asked',
736 !booted['/nokey/v1/models'] && !gateAfterBoot.nokeyp,
737 `${booted['/nokey/v1/models'] || 0} asks, gate ${JSON.stringify(gateAfterBoot.nokeyp || null)}`);
738check('nor is one whose key is sealed',
739 !booted['/sealed/v1/models'] && !gateAfterBoot.sealedp,
740 `${booted['/sealed/v1/models'] || 0} asks, gate ${JSON.stringify(gateAfterBoot.sealedp || null)}`);
741
742// The four keyless rows get their keys now, with the panel shut. Nothing may ask on the strength
743// of a key alone: the two triggers are the boot, which has been and gone, and the panel opening.
744const armed = await p.evaluate(async () => {
745 const M = window.DaimondModels;
746 // `loopp` and `offp` are left keyless: each belongs to a check that has to own the first
747 // ask its provider ever gets, and a key given here would spend it on this panel opening.
748 for (const id of ['stale', 'refuse']) await M.setKey(id, id + '-key');
749 const list = document.getElementById('models-list');
750 return { onScreen: !!(list && (list.offsetParent || list.getClientRects().length)) };
751});
752await p.waitForTimeout(500);
753const quiet = await askedIn();
754check('a key arriving with the panel shut asks for nothing by itself',
755 armed.onScreen === false
756 && !quiet['/fixture/v1/models'] && !quiet['/refuse/v1/models'],
757 armed.onScreen ? 'the panel was on screen; this proves nothing'
758 : `${quiet['/fixture/v1/models'] || 0} / ${quiet['/refuse/v1/models'] || 0} asks`);
759
760// ── Trigger one: the panel comes up ─────────────────────────────────────
761
762await p.evaluate(() => { document.getElementById('astat-model').click(); });
763await settle('/fixture/v1/models', 1, 5000);
764await p.waitForTimeout(500);
765
766const opened = await askedIn();
767check('opening the panel asks the provider whose list has gone stale',
768 (opened['/fixture/v1/models'] || 0) === 1,
769 `${opened['/fixture/v1/models'] || 0} asks`);
770const gateAfterOpen = await p.evaluate(() => window.DaimondModels.listAsks());
771check('and still asks nothing of the fresh, the keyless, the sealed or the minted',
772 !opened['/fresh/v1/models'] && !opened['/nokey/v1/models']
773 && !opened['/sealed/v1/models'] && !opened['/credits/v1/models']
774 && !opened['/loop/v1/models'] && !opened['/off/v1/models']
775 && !gateAfterOpen.nokeyp && !gateAfterOpen.sealedp && !gateAfterOpen.credits,
776 JSON.stringify({ fresh: opened['/fresh/v1/models'] || 0, nokey: opened['/nokey/v1/models'] || 0,
777 sealed: opened['/sealed/v1/models'] || 0, credits: opened['/credits/v1/models'] || 0,
778 loop: opened['/loop/v1/models'] || 0, off: opened['/off/v1/models'] || 0,
779 stamped: Object.keys(gateAfterOpen).filter(k => ['nokeyp', 'sealedp', 'credits'].includes(k)) }));
780
781const landed = await p.evaluate(() => {
782 const M = window.DaimondModels;
783 const by = {};
784 M.providers().forEach(x => { by[x.id] = x.models.slice(); });
785 return { stale: by.stale, refuse: by.refuse, fresh: by.fresh };
786});
787check('the answer is kept: the row now lists what the provider just said',
788 landed.stale.join() === 'listed-a,listed-b', landed.stale.join());
789check('and a row nobody asked about is untouched', landed.fresh.join() === 'listed-old',
790 landed.fresh.join());
791
792// ── A refusal nobody asked for says nothing ─────────────────────────────
793//
794// The button reports the provider's own words, because somebody is waiting for them. This did
795// not ask. An error across the panel of a user who has just opened it is the app blaming them
796// for arriving, and the old list is still perfectly good.
797
798const refused = await p.evaluate(() => {
799 const n = document.getElementById('models-note');
800 const M = window.DaimondModels;
801 const st = M.listAsks().refuse || {};
802 return {
803 note: n ? n.textContent.trim() : '(no message line)',
804 seen: !!(n && n.offsetParent !== null && n.textContent.trim()),
805 models: (M.providers().find(x => x.id === 'refuse') || {}).models,
806 fails: st.fails, ok: st.ok, busy: st.busy,
807 };
808});
809check('a provider that refuses an ask nobody made is asked exactly once',
810 (opened['/refuse/v1/models'] || 0) === 1, `${opened['/refuse/v1/models'] || 0} asks`);
811check('and says nothing whatever on the panel',
812 refused.note === '' && refused.seen === false, `“${refused.note}”`);
813check('and the list it already had still stands',
814 (refused.models || []).join() === 'listed-old', (refused.models || []).join());
815check('while the refusal is COUNTED, so the next one waits longer',
816 refused.fails === 1 && refused.ok === false && refused.busy === false,
817 JSON.stringify({ fails: refused.fails, ok: refused.ok }));
818
819// ── Nothing is asked with nothing to ask over ───────────────────────────
820
821const off = await p.evaluate(async () => {
822 Object.defineProperty(navigator, 'onLine', { get: () => false, configurable: true });
823 // The key arrives with the browser already knowing it is offline, so this row's very first
824 // chance to be asked is one it must not take.
825 await window.DaimondModels.setKey('offp', 'off-key');
826 window.DaimondModels.render();
827 const st = window.DaimondModels.listAsks().offp;
828 return { onLine: navigator.onLine, stamped: !!st };
829});
830await p.waitForTimeout(400);
831const offline = await askedIn();
832check('a browser that knows it is offline asks nobody, and blames nobody for it',
833 off.onLine === false && !offline['/off/v1/models'] && off.stamped === false,
834 `${offline['/off/v1/models'] || 0} asks, gate stamped=${off.stamped}`);
835await p.evaluate(() => { delete navigator.onLine; });
836
837// ── THE LOOP TERMINATES ─────────────────────────────────────────────────
838//
839// This is the check the whole change is about. `fetchModels` ends in `save()`, daimond.js
840// redraws the panel when the store is saved, and a redraw asks again — so the ask is inside its
841// own trigger. The credit probe beside it measured that at four thousand requests in the seconds
842// it took to hide and show a tab five times.
843//
844// A THOUSAND REDRAWS IN ONE SYNCHRONOUS RUN, which is the worst case and not a contrived one:
845// nothing can answer while JavaScript is on the stack, so the list is exactly as stale at redraw
846// one thousand as it was at redraw one, and only the in-flight test knows the difference. Then
847// five hide-and-show cycles with a redraw each, once the answers have landed, which is what the
848// floor is for. The endpoint is dropped rather than answered on purpose: an answer would stamp
849// `fetched` and staleness would stop the loop on its own, hiding whether the gate works at all.
850
851const REDRAWS = 1000;
852const drove = await p.evaluate(async (n) => {
853 const M = window.DaimondModels;
854 await M.setKey('loopp', 'loop-key'); // its `save()` is the first redraw, and the first ask
855 const t0 = performance.now();
856 for (let i = 0; i < n; i++) M.render(); // nothing can answer while this runs
857 return { ms: Math.round(performance.now() - t0) };
858}, REDRAWS);
859await p.waitForTimeout(1500);
860
861const cycled = await p.evaluate(async (n) => {
862 for (let i = 0; i < n; i++) {
863 document.dispatchEvent(new Event('visibilitychange'));
864 window.DaimondModels.render();
865 await new Promise(r => setTimeout(r, 60));
866 }
867 return true;
868}, 5);
869await p.waitForTimeout(1200);
870
871const loop = await askedIn();
872const asks = loop['/loop/v1/models'] || 0;
873const wire = hits['/loop/v1/models'] || 0;
874console.log(` loop drive: ${REDRAWS} redraws in ${drove.ms}ms + 5 hide/show cycles`
875 + ` -> ${asks} asks (page), ${wire} requests (browser)`);
876check('THE LOOP TERMINATES: a thousand redraws over one stale provider is ONE request',
877 asks === 1,
878 asks === 0 ? 'nothing was asked at all — the trigger is not wired'
879 : `${asks} asks from ${REDRAWS} redraws, ${wire} of them reaching the network`);
880check('and the browser agrees with the page about how many that was',
881 wire === asks, `page ${asks}, browser ${wire}`);
882check('the gate holds one ask, one failure and nothing in flight',
883 await p.evaluate(() => {
884 const st = window.DaimondModels.listAsks().loopp || {};
885 return st.busy === false && st.fails === 1 && st.ok === false;
886 }),
887 JSON.stringify(await p.evaluate(() => window.DaimondModels.listAsks().loopp)));
888
889await shot(s, 'models');
890const errs = s.errs.filter(e => !/favicon|404|401|net::ERR/.test(e));
891console.log('\nconsole errors:', errs.slice(0, 4));
892await s.close();
893
894console.log(`\n${ok.length} passed, ${bad.length} failed`);
895if (bad.length) console.log('FAILED:\n ' + bad.join('\n '));
896if (BREAK) {
897 if (bad.length) { console.log('the break was caught, as it should be'); process.exit(0); }
898 console.log('THE BREAK WAS NOT CAUGHT: this check proves nothing');
899 process.exit(1);
900}
901process.exit(bad.length ? 1 : 0);