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 |
| 50 | import fs from 'node:fs'; |
| 51 | import path from 'node:path'; |
| 52 | import { fileURLToPath } from 'node:url'; |
| 53 | import { open, signInAs, shot } from './harness.mjs'; |
| 54 | |
| 55 | const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); |
| 56 | const die = (why) => { console.error('ABORT: ' + why); process.exit(2); }; |
| 57 | |
| 58 | const ok = [], bad = []; |
| 59 | const check = (name, pass, detail) => { |
| 60 | (pass ? ok : bad).push(name + (detail ? ' — ' + detail : '')); |
| 61 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 62 | }; |
| 63 | |
| 64 | const 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. |
| 106 | const 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. |
| 109 | const 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 | |
| 113 | const 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 | }; |
| 194 | if (BREAK && !BREAKS[BREAK]) die(`no break called "${BREAK}"`); |
| 195 | |
| 196 | /// The damaged file the browser will be given, or null when nothing is broken. |
| 197 | const 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 | })(); |
| 205 | if (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. |
| 224 | const 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. |
| 231 | const 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 | }; |
| 244 | const CATALOGUE = JSON.stringify({ data: [{ id: 'listed-a' }, { id: 'listed-b' }] }); |
| 245 | |
| 246 | const 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 | }); |
| 293 | const p = s.page; |
| 294 | await 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 | |
| 301 | await 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 | }); |
| 313 | await p.reload({ waitUntil: 'domcontentloaded' }); |
| 314 | await signInAs(s, 'models'); |
| 315 | await p.waitForTimeout(2000); |
| 316 | |
| 317 | const 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 | }); |
| 329 | check('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)); |
| 332 | check('its key survives the change of shape', migrated.key === 'old-single-key'); |
| 333 | check('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}`); |
| 336 | check('the app can still run', migrated.ready === true); |
| 337 | |
| 338 | // ── The status row counts models, not providers ───────────────────────── |
| 339 | |
| 340 | const rail = await p.evaluate(() => { |
| 341 | const r = document.getElementById('astat-model'); |
| 342 | return { text: r ? r.textContent.trim() : '(none)', count: window.DaimondModels.count() }; |
| 343 | }); |
| 344 | check('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 | |
| 350 | const 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 | }); |
| 369 | check('a second provider is added alongside the first', |
| 370 | second.provs.length === 2, JSON.stringify(second.provs)); |
| 371 | check('and does not evict the first provider’s key', |
| 372 | second.fireworksKey === 'old-single-key' && second.groqKey === 'the-groq-key'); |
| 373 | check('the model count is the sum across providers', |
| 374 | second.count === 4, second.count + ' models'); |
| 375 | check('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 | |
| 380 | const 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 | }); |
| 387 | check('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 })); |
| 392 | check('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 | |
| 399 | await p.evaluate(() => { document.getElementById('astat-model').click(); }); |
| 400 | await p.waitForTimeout(700); |
| 401 | |
| 402 | const 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 | }); |
| 411 | check('the Models row opens the models form', panel.shown === true); |
| 412 | check('and it lists every provider', panel.heads.length === 2, panel.heads.join(', ')); |
| 413 | check('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. |
| 417 | await p.evaluate(() => { |
| 418 | [...document.querySelectorAll('.models-prov-head')] |
| 419 | .find(h => /Groq/.test(h.textContent)).click(); |
| 420 | }); |
| 421 | await p.waitForTimeout(400); |
| 422 | const 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 | }); |
| 427 | check('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 | |
| 436 | const 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 | }); |
| 445 | check('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'); |
| 449 | check('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 | |
| 454 | const form = await p.evaluate(() => ({ |
| 455 | input: !!document.querySelector('.models-credit-input'), |
| 456 | set: !!document.querySelector('.models-credit-form .models-refetch'), |
| 457 | })); |
| 458 | check('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. |
| 463 | const 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 | }); |
| 480 | check('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'); |
| 484 | check('and the message is on screen, not merely in the document', |
| 485 | typed.seen === true, typed.there ? '' : 'no element'); |
| 486 | check('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. |
| 492 | await p.evaluate(() => { window.DaimondModels.render(); }); |
| 493 | await p.waitForTimeout(400); |
| 494 | const survived = await p.evaluate(() => { |
| 495 | const n = document.getElementById('models-note'); |
| 496 | return { there: !!n, text: n ? n.textContent.trim() : '' }; |
| 497 | }); |
| 498 | check('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. |
| 504 | const 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 | }); |
| 512 | check('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 | |
| 525 | const STAMP = Date.UTC(2026, 6, 14, 3, 25); // a fixed moment, so the age line can be read back |
| 526 | |
| 527 | await 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. |
| 544 | const 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. |
| 557 | const 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 | |
| 582 | const 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 | |
| 589 | await openRow('groq'); |
| 590 | const listed = await readRow('groq'); |
| 591 | check('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)}`); |
| 595 | check('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}”`); |
| 597 | check('and it is live, because this row can ask', listed.off === false, 'disabled=' + listed.off); |
| 598 | check('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 | |
| 603 | await openRow('together'); |
| 604 | const keyless = await readRow('together'); |
| 605 | check('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)); |
| 609 | check('drawn disabled, and saying what is missing', |
| 610 | keyless.off === true && keyless.text === words.nokey, `“${keyless.text}” disabled=${keyless.off}`); |
| 611 | check('and a list never asked for says so', |
| 612 | keyless.age && keyless.ageText === words.never, `“${keyless.ageText}”`); |
| 613 | |
| 614 | await openRow('credits'); |
| 615 | const minted = await readRow('credits'); |
| 616 | check('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. |
| 622 | const 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 | }); |
| 632 | check('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. |
| 639 | const 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); |
| 655 | check('the age line interpolates the moment the list was asked for', |
| 656 | /^A<.+>B$/.test(dated.asked), `“${dated.asked}”`); |
| 657 | check('and the moment is THIS list’s, not some other date', |
| 658 | dated.asked.includes(dated.day), `“${dated.asked}” should carry “${dated.day}”`); |
| 659 | check('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 | |
| 674 | const NOW = Date.now(); |
| 675 | const DAY = 24 * 60 * 60 * 1000; |
| 676 | const OLD = NOW - 3 * DAY; // three days: stale by any reading of the threshold |
| 677 | const 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. |
| 680 | const 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. |
| 683 | const 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 | |
| 692 | await 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 | |
| 715 | await p.reload({ waitUntil: 'domcontentloaded' }); |
| 716 | await signInAs(s, 'models'); |
| 717 | await settle('/boot/v1/models', 1, 6000); |
| 718 | await p.waitForTimeout(600); |
| 719 | |
| 720 | const booted = await askedIn(); |
| 721 | check('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`); |
| 724 | check('a catalogue asked for an hour ago is left alone', |
| 725 | !booted['/fresh/v1/models'], `${booted['/fresh/v1/models'] || 0} asks`); |
| 726 | check('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. |
| 734 | const gateAfterBoot = await p.evaluate(() => window.DaimondModels.listAsks()); |
| 735 | check('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)}`); |
| 738 | check('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. |
| 744 | const 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 | }); |
| 752 | await p.waitForTimeout(500); |
| 753 | const quiet = await askedIn(); |
| 754 | check('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 | |
| 762 | await p.evaluate(() => { document.getElementById('astat-model').click(); }); |
| 763 | await settle('/fixture/v1/models', 1, 5000); |
| 764 | await p.waitForTimeout(500); |
| 765 | |
| 766 | const opened = await askedIn(); |
| 767 | check('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`); |
| 770 | const gateAfterOpen = await p.evaluate(() => window.DaimondModels.listAsks()); |
| 771 | check('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 | |
| 781 | const 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 | }); |
| 787 | check('the answer is kept: the row now lists what the provider just said', |
| 788 | landed.stale.join() === 'listed-a,listed-b', landed.stale.join()); |
| 789 | check('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 | |
| 798 | const 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 | }); |
| 809 | check('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`); |
| 811 | check('and says nothing whatever on the panel', |
| 812 | refused.note === '' && refused.seen === false, `“${refused.note}”`); |
| 813 | check('and the list it already had still stands', |
| 814 | (refused.models || []).join() === 'listed-old', (refused.models || []).join()); |
| 815 | check('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 | |
| 821 | const 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 | }); |
| 830 | await p.waitForTimeout(400); |
| 831 | const offline = await askedIn(); |
| 832 | check('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}`); |
| 835 | await 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 | |
| 851 | const REDRAWS = 1000; |
| 852 | const 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); |
| 859 | await p.waitForTimeout(1500); |
| 860 | |
| 861 | const 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); |
| 869 | await p.waitForTimeout(1200); |
| 870 | |
| 871 | const loop = await askedIn(); |
| 872 | const asks = loop['/loop/v1/models'] || 0; |
| 873 | const wire = hits['/loop/v1/models'] || 0; |
| 874 | console.log(` loop drive: ${REDRAWS} redraws in ${drove.ms}ms + 5 hide/show cycles` |
| 875 | + ` -> ${asks} asks (page), ${wire} requests (browser)`); |
| 876 | check('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`); |
| 880 | check('and the browser agrees with the page about how many that was', |
| 881 | wire === asks, `page ${asks}, browser ${wire}`); |
| 882 | check('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 | |
| 889 | await shot(s, 'models'); |
| 890 | const errs = s.errs.filter(e => !/favicon|404|401|net::ERR/.test(e)); |
| 891 | console.log('\nconsole errors:', errs.slice(0, 4)); |
| 892 | await s.close(); |
| 893 | |
| 894 | console.log(`\n${ok.length} passed, ${bad.length} failed`); |
| 895 | if (bad.length) console.log('FAILED:\n ' + bad.join('\n ')); |
| 896 | if (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 | } |
| 901 | process.exit(bad.length ? 1 : 0); |