oxedyne/daimond/dev/verify_typstpack.mjs
30.8 KiB, 1 run
created by r2519314175:773, 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_typstpack.mjs — typesetting is bought, and every door to it knows. |
| 2 | // |
| 3 | // Typst typesetting moved from the free belt to a purchasable pack. THE CAPABILITY IS THE |
| 4 | // PRODUCT, not the function the model calls, and the capability has three doors: |
| 5 | // |
| 6 | // 1. the model's `typst_compile` tool, dispatched through `Tool::guard` in `src/tools.rs`; |
| 7 | // 2. the ⚙ Compile button in the Doc panel, which involves no model, costs nothing, and calls |
| 8 | // `window.DaimondTypst.compile` straight from JavaScript; |
| 9 | // 3. the driver itself, `window.DaimondTypst`, which the other two share so the 30 MB compiler |
| 10 | // wasm is built once between them. |
| 11 | // |
| 12 | // A gate on (1) alone stops the model and leaves the person compiling free, which is a gate on one |
| 13 | // door and not on the product. So each door is driven here, separately, and each is asked the |
| 14 | // question that matters: NOT "did it say something" but "did a PDF appear". |
| 15 | // |
| 16 | // The properties, each with its control: |
| 17 | // |
| 18 | // A. Unlocked, every door compiles. Without this the refusals below prove nothing -- a build |
| 19 | // whose compiler was simply broken would pass every locked check. |
| 20 | // B. Locked, no door compiles, and NO PDF IS WRITTEN. The file is removed before each attempt, |
| 21 | // so the assertion is on the world and not on the wording. |
| 22 | // C. The refusals are useful and in the reader's language: the tool's answers the MODEL in |
| 23 | // English naming the tool and the pack, the button's answers a PERSON from the catalogue the |
| 24 | // page has been translated into -- checked by reading it in a second language. |
| 25 | // D. The catalogue and the build name the SAME pack. `gateway/app.jdat` says what is on sale |
| 26 | // and `Tool::pack` says what a sale unlocks; they ship separately, and a build older than the |
| 27 | // catalogue fails open -- the page pushes a lock nothing recognises and the pack runs free. |
| 28 | // E. The SALE and not merely the gate: with the price standing in `gateway/app.jdat` the page |
| 29 | // reads it, the engine locks the tool and the compile is refused; with the price taken out of |
| 30 | // that same string the pack is no longer on sale AND IS STILL LOCKED, because what a build |
| 31 | // gates is decided by the build. The price buys a way out of the lock; it does not set it. |
| 32 | // F. SOLD IS NOT LOCKED. An EMPTY catalogue must lock the pack, not give it away -- and the |
| 33 | // control for it is the defect itself, reproduced in the same run: a listing assembled only |
| 34 | // from the catalogue, for that same empty string, unlocks the tool and compiles a PDF. That |
| 35 | // is the state the production gateway was in while a priced pack ran free on every device, |
| 36 | // and it is why E above is not enough on its own -- E only ever pushes the catalogue towards |
| 37 | // HAVING a price, and every failure that mattered was the catalogue losing one. |
| 38 | // |
| 39 | // node dev/verify_typstpack.mjs |
| 40 | // |
| 41 | // Prove it red before trusting it green. The two gates are layered, and each removal turns a |
| 42 | // DIFFERENT check red, which is the point: |
| 43 | // |
| 44 | // Remove `if let Some(refusal) = self.pack_refusal()` from `Tool::guard` and rebuild: no PDF |
| 45 | // still appears, because the driver stops it -- so "the tool does not compile" stays green and |
| 46 | // the ANSWER check goes red, the model having been handed a raw `UpstreamErr` naming a source |
| 47 | // line instead of something it can relay. Gated, and still broken. |
| 48 | // |
| 49 | // Remove the `if (await packLocked())` block from `www/js/typst.js`: the model's tool is still |
| 50 | // refused by Rust, and both of the other two doors -- the driver and the ⚙ button -- go red, |
| 51 | // compiling a PDF for an account that did not buy one. |
| 52 | // |
| 53 | // Neither removal turns everything red, and a verifier that only watched one of them would have |
| 54 | // signed off a half-gated product. |
| 55 | |
| 56 | import fs from 'node:fs'; |
| 57 | import path from 'node:path'; |
| 58 | import { fileURLToPath } from 'node:url'; |
| 59 | import { open, shot, SCRATCH } from './harness.mjs'; |
| 60 | |
| 61 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 62 | |
| 63 | const ok = [], bad = []; |
| 64 | const check = (name, pass, detail) => { |
| 65 | (pass ? ok : bad).push(name + (detail ? ' — ' + detail : '')); |
| 66 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 67 | }; |
| 68 | const sleep = ms => new Promise(r => setTimeout(r, ms)); |
| 69 | |
| 70 | // A namespace of this verifier's own, so the files it makes and removes are nobody else's. |
| 71 | const NS = 'd~typstpack'; |
| 72 | const SRC = 'paper.typ'; |
| 73 | const PDF = 'paper.pdf'; |
| 74 | const BODY = '= A heading\n\nA paragraph, and $x^2 + y^2$.\n'; |
| 75 | |
| 76 | /// Every pack key the gateway's shipped catalogue sells, read out of `gateway/app.jdat` -- the |
| 77 | /// configuration a customer would actually be charged against. |
| 78 | /// |
| 79 | /// This file used to hard-code the key. It cannot any more, because the key is now half of an |
| 80 | /// agreement between two files that are deployed separately: the catalogue names the pack, and the |
| 81 | /// BUILD decides which tool that pack locks. A catalogue selling `drop01` against a build whose |
| 82 | /// `Tool::pack` still answers an earlier key fails OPEN -- the page pushes a lock the engine does |
| 83 | /// not recognise, and the pack runs free on every device. That is the same defect this gate was |
| 84 | /// written to close, wearing a different hat, so the two are read independently here and compared. |
| 85 | /// It is also why the key is the pack's DROP rather than its theme: the display name may be |
| 86 | /// rewritten in the catalogue on the day, and nothing here or in the build moves when it is. |
| 87 | /// The `tools` string exactly as `gateway/app.jdat` states it. |
| 88 | function catalogueString() { |
| 89 | const src = fs.readFileSync(path.join(HERE, '..', 'gateway', 'app.jdat'), 'utf8'); |
| 90 | const m = /"tools":\s*"([^"]*)"/.exec(src); |
| 91 | return m ? m[1] : ''; |
| 92 | } |
| 93 | |
| 94 | /// The catalogue string read the way `gateway/src/catalogue.rs::parse` reads it: |
| 95 | /// `tool:price_minor:Name:Blurb`, comma separated, and an entry with no price is DROPPED -- |
| 96 | /// "a tool with no price is a tool nobody can buy". Deliberately dumb, because it is a mirror of |
| 97 | /// Rust that Rust's own tests already cover; what it is here for is to put the SHIPPED |
| 98 | /// configuration in front of the page rather than a fixture somebody typed. |
| 99 | function parseCatalogue(str) { |
| 100 | return str.split(',').map((entry) => { |
| 101 | const parts = entry.trim().split(':'); |
| 102 | const tool = (parts.shift() || '').trim(); |
| 103 | const price = parseInt((parts.shift() || '').trim(), 10); |
| 104 | const name = (parts.shift() || '').trim(); |
| 105 | const blurb = parts.join(':').trim(); |
| 106 | if (!tool || !(price > 0)) return null; |
| 107 | return { tool, name: name || 'Daimond tool', blurb, price_minor: price }; |
| 108 | }).filter(Boolean); |
| 109 | } |
| 110 | |
| 111 | const catalogueKeys = () => parseCatalogue(catalogueString()).map(t => t.tool); |
| 112 | |
| 113 | /// Every pack key the GATEWAY gates, read out of `gateway/src/catalogue.rs`. |
| 114 | /// |
| 115 | /// A third source, and it has to be third: the catalogue says what is on SALE and the build says |
| 116 | /// what a sale unlocks, and neither of those is what the gateway locks. That register is compiled |
| 117 | /// into the gateway precisely so that a catalogue edit cannot empty it -- which means it can now |
| 118 | /// disagree with the build, and a gateway gating `drop02` against a build gating `drop01` fails |
| 119 | /// open exactly as an empty catalogue used to. So all three are read here and compared. |
| 120 | /// |
| 121 | /// The scan is on the `key` arms rather than on the enum, because the arms are the mapping and the |
| 122 | /// enum is only its spelling. |
| 123 | function gatedKeys() { |
| 124 | const src = fs.readFileSync(path.join(HERE, '..', 'gateway', 'src', 'catalogue.rs'), 'utf8'); |
| 125 | const body = /pub const fn key\(self\)[\s\S]*?match self \{([\s\S]*?)\n\s*\}/.exec(src); |
| 126 | if (!body) return []; |
| 127 | const out = []; |
| 128 | const re = /Self::\w+\s*=>\s*"([^"]+)"/g; |
| 129 | let m; |
| 130 | while ((m = re.exec(body[1])) !== null) out.push(m[1]); |
| 131 | return out; |
| 132 | } |
| 133 | |
| 134 | const s = await open({ name: 'typstpack', connect: false }); |
| 135 | const p = s.page; |
| 136 | await p.waitForTimeout(1500); |
| 137 | |
| 138 | // ── The instrument, before anything is measured with it ────────────── |
| 139 | // |
| 140 | // Everything below reads the world through these three helpers. If any of them is lying -- a |
| 141 | // namespace that is not applied, an existence check that always answers no -- every "no PDF was |
| 142 | // written" is vacuous. So they are proved against a file that IS there before they are trusted to |
| 143 | // report one that is not. |
| 144 | |
| 145 | /// Point the wasm at this verifier's namespace and hand back the module. |
| 146 | await p.evaluate(async (ns) => { |
| 147 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 148 | mod.set_account_ns(ns); |
| 149 | }, NS); |
| 150 | |
| 151 | /// Whether a file exists in the namespace, asked of OPFS directly rather than of the app. |
| 152 | const exists = (name) => p.evaluate(async ({ ns, n }) => { |
| 153 | const root = await navigator.storage.getDirectory(); |
| 154 | let dir; |
| 155 | try { dir = await root.getDirectoryHandle(ns); } catch (e) { return false; } |
| 156 | try { await dir.getFileHandle(n); return true; } catch (e) { return false; } |
| 157 | }, { ns: NS, n: name }); |
| 158 | |
| 159 | /// Remove a file if it is there, so the next attempt starts from nothing. |
| 160 | const remove = (name) => p.evaluate(async ({ ns, n }) => { |
| 161 | const root = await navigator.storage.getDirectory(); |
| 162 | try { |
| 163 | const dir = await root.getDirectoryHandle(ns); |
| 164 | await dir.removeEntry(n); |
| 165 | } catch (e) { /* already absent */ } |
| 166 | }, { ns: NS, n: name }); |
| 167 | |
| 168 | /// Lock or unlock the pack, exactly as the page does after reading `/api/tools`. |
| 169 | const setLocked = (csv) => p.evaluate(async (v) => { |
| 170 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 171 | mod.set_locked_packs(v); |
| 172 | return { locked: mod.locked_packs(), tool: mod.tool_locked('typst_compile') }; |
| 173 | }, csv); |
| 174 | |
| 175 | /// Run a tool the way the model does: through the registry's dispatch, which is what applies the |
| 176 | /// guard. NOT a direct call to the compile -- that would test a path no model takes. |
| 177 | const runTool = (name, args) => p.evaluate(async ({ n, a }) => { |
| 178 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 179 | const app = new mod.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 256, '', true); |
| 180 | return await app.run_tool(n, JSON.stringify(a)); |
| 181 | }, { n: name, a: args }); |
| 182 | |
| 183 | // Prove the instrument on a file that is definitely there, then on its absence. |
| 184 | await runTool('file_write', { path: SRC, content: BODY }); |
| 185 | const seesPresent = await exists(SRC); |
| 186 | await remove(PDF); |
| 187 | const seesAbsent = await exists(PDF); |
| 188 | check('the existence check can see a file that is there, and only that file', |
| 189 | seesPresent === true && seesAbsent === false, |
| 190 | `source seen: ${seesPresent}, absent PDF seen: ${seesAbsent}`); |
| 191 | if (!seesPresent || seesAbsent) { |
| 192 | console.log('\nthe instrument is unsound, so nothing below would mean anything'); |
| 193 | await s.close(); |
| 194 | process.exit(1); |
| 195 | } |
| 196 | |
| 197 | // ── The key the two halves have to agree on ────────────────────────── |
| 198 | |
| 199 | const beltEarly = await p.evaluate(async () => { |
| 200 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 201 | return JSON.parse(mod.builtin_tools()); |
| 202 | }); |
| 203 | const beltEntry = beltEarly.find(t => t.tool === 'typst_compile'); |
| 204 | const PACK = beltEntry && beltEntry.pack ? beltEntry.pack : ''; |
| 205 | const SELLS = catalogueKeys(); |
| 206 | const GATES = gatedKeys(); |
| 207 | check('the build locks on the pack key the gateway\'s catalogue actually sells', |
| 208 | !!PACK && SELLS.indexOf(PACK) >= 0, |
| 209 | `the build locks on "${PACK || '(nothing)'}", the catalogue sells ${ |
| 210 | SELLS.length ? SELLS.map(k => '"' + k + '"').join(', ') : '(nothing)' |
| 211 | } — a build older than the catalogue fails OPEN, so rebuild the wasm before switching the price on`); |
| 212 | check('and the gateway GATES that same key, whatever the catalogue is doing', |
| 213 | !!PACK && GATES.indexOf(PACK) >= 0, |
| 214 | `the gateway's register gates ${ |
| 215 | GATES.length ? GATES.map(k => '"' + k + '"').join(', ') : '(nothing)' |
| 216 | } — a register that does not name "${PACK}" reports the pack to nobody, so nothing locks it`); |
| 217 | if (!PACK) { |
| 218 | console.log('\nthe build sells nothing, so there is no gate below to measure'); |
| 219 | await s.close(); |
| 220 | process.exit(1); |
| 221 | } |
| 222 | |
| 223 | const armed = await setLocked(PACK); |
| 224 | check('the build can be told the pack is locked, and says so about the tool', |
| 225 | armed.locked === PACK && armed.tool === true, JSON.stringify(armed)); |
| 226 | |
| 227 | // ── A. Unlocked, every door compiles ───────────────────────────────── |
| 228 | // |
| 229 | // The control for everything after it. This is the slow part: the first compile through any door |
| 230 | // builds the 30 MB compiler, and all three doors then share it. |
| 231 | |
| 232 | await setLocked(''); |
| 233 | |
| 234 | await remove(PDF); |
| 235 | const toolFree = await runTool('typst_compile', { path: SRC }); |
| 236 | const toolFreePdf = await exists(PDF); |
| 237 | check('unlocked: the model\'s tool compiles, and a PDF lands in the workspace', |
| 238 | toolFreePdf === true, toolFree.slice(0, 160)); |
| 239 | |
| 240 | await remove(PDF); |
| 241 | const driverFree = await p.evaluate(async (body) => { |
| 242 | const r = await window.DaimondTypst.compile(body); |
| 243 | return { |
| 244 | error: r.error || '', |
| 245 | magic: r.pdf ? String.fromCharCode.apply(null, Array.from(r.pdf.slice(0, 5))) : '', |
| 246 | }; |
| 247 | }, BODY); |
| 248 | check('unlocked: the driver compiles a real document to real PDF bytes', |
| 249 | driverFree.error === '' && driverFree.magic === '%PDF-', |
| 250 | driverFree.error || `magic "${driverFree.magic}"`); |
| 251 | |
| 252 | // ── The button, which involves no model at all ─────────────────────── |
| 253 | // |
| 254 | // Opened the way a person opens it: the row in the Workspace tree, then the ⚙ in the header. The |
| 255 | // panel is what draws the button, so a fixture that called `compileTypst` directly would be |
| 256 | // testing a function nobody clicks. |
| 257 | |
| 258 | async function openTheTypFile() { |
| 259 | await p.evaluate(() => window.DaimondPanels.show('work')); |
| 260 | await sleep(400); |
| 261 | await p.evaluate(() => { |
| 262 | const r = document.querySelector('#panel-work [data-act="refresh"]'); |
| 263 | if (r) r.click(); |
| 264 | }); |
| 265 | await sleep(900); |
| 266 | return await p.evaluate((src) => { |
| 267 | const rows = Array.from(document.querySelectorAll('#panel-work .files-row')); |
| 268 | const row = rows.find(r => new RegExp(src.replace('.', '\\.')).test(r.textContent || '')); |
| 269 | if (!row) return 'not in the tree: ' + rows.map(r => (r.textContent || '').trim()).join(','); |
| 270 | row.click(); |
| 271 | return true; |
| 272 | }, SRC); |
| 273 | } |
| 274 | |
| 275 | /// Click ⚙ and wait for the header line to settle on an answer, whichever kind it is. |
| 276 | async function clickCompile(waitMs = 90000) { |
| 277 | const clicked = await p.evaluate(() => { |
| 278 | const b = document.querySelector('[data-act="compile"]'); |
| 279 | if (!b) return false; |
| 280 | b.click(); |
| 281 | return true; |
| 282 | }); |
| 283 | if (!clicked) return { clicked: false, msg: '' }; |
| 284 | const t0 = Date.now(); |
| 285 | for (;;) { |
| 286 | const st = await p.evaluate(() => { |
| 287 | const b = document.querySelector('[data-act="compile"]'); |
| 288 | const m = document.querySelector('.files-view-msg'); |
| 289 | return { busy: !!(b && b.disabled), msg: m ? (m.textContent || '') : '' }; |
| 290 | }); |
| 291 | // The handler re-enables the button in its `finally`, so "not busy" is the answer having |
| 292 | // arrived by either route -- compiled, or refused. |
| 293 | if (!st.busy && st.msg && !/^…/.test(st.msg)) return { clicked: true, msg: st.msg }; |
| 294 | if (Date.now() - t0 > waitMs) return { clicked: true, msg: st.msg, timedOut: true }; |
| 295 | await sleep(400); |
| 296 | } |
| 297 | } |
| 298 | |
| 299 | const inTree = await openTheTypFile(); |
| 300 | // The view renders after the click resolves, so the button is waited FOR rather than looked for |
| 301 | // once. Looked for once, this reported "no Compile button" on a build that has one -- which is the |
| 302 | // verifier failing, not the app, and the kind of red that gets a real check deleted. |
| 303 | let hasButton = false; |
| 304 | for (let i = 0; i < 40 && !hasButton; i++) { |
| 305 | hasButton = await p.evaluate(() => !!document.querySelector('[data-act="compile"]')); |
| 306 | if (!hasButton) await sleep(250); |
| 307 | } |
| 308 | check('the .typ file opens in the Doc panel and offers a Compile button', |
| 309 | inTree === true && hasButton, `in the tree: ${inTree}, button drawn: ${hasButton}`); |
| 310 | |
| 311 | await remove(PDF); |
| 312 | const btnFree = await clickCompile(); |
| 313 | const btnFreePdf = await exists(PDF); |
| 314 | check('unlocked: clicking ⚙ compiles, and a PDF lands in the workspace', |
| 315 | btnFree.clicked === true && btnFreePdf === true, btnFree.msg.slice(0, 160)); |
| 316 | |
| 317 | // ── B. Locked, no door compiles and no PDF is written ──────────────── |
| 318 | |
| 319 | await setLocked(PACK); |
| 320 | |
| 321 | await remove(PDF); |
| 322 | const toolLocked = await runTool('typst_compile', { path: SRC }); |
| 323 | const toolLockedPdf = await exists(PDF); |
| 324 | check('LOCKED: the model\'s tool does not compile — no PDF was written', |
| 325 | toolLockedPdf === false, `a PDF at ${PDF}: ${toolLockedPdf}`); |
| 326 | // The refusal is the model's to relay, so it has to carry what the model needs to say: which tool |
| 327 | // was refused, and which pack it is sold in. Asserted as content, not as a fixed sentence. |
| 328 | // |
| 329 | // AND it must be an ANSWER rather than an error. This is the check that the Rust gate earns its |
| 330 | // place with: take `pack_refusal` out of `Tool::guard` and the driver below still stops the |
| 331 | // compile, so no PDF appears and the check above stays green -- but the model is handed a raw |
| 332 | // `UpstreamErr` naming a source line, which it cannot relay to anyone. The capability would still |
| 333 | // be gated and the product would still be broken, and only this assertion says so. |
| 334 | check('and it tells the model which tool was refused and which pack sells it, as an answer not an error', |
| 335 | toolLocked.includes('typst_compile') && toolLocked.includes(PACK) |
| 336 | && /has not bought/.test(toolLocked) |
| 337 | && !/^Error:/.test(toolLocked.trim()), |
| 338 | toolLocked.slice(0, 200)); |
| 339 | |
| 340 | await remove(PDF); |
| 341 | const driverLocked = await p.evaluate(async (body) => { |
| 342 | const r = await window.DaimondTypst.compile(body); |
| 343 | return { error: r.error || '', bytes: r.pdf ? r.pdf.length : 0 }; |
| 344 | }, BODY); |
| 345 | check('LOCKED: the driver itself refuses, and hands back no bytes at all', |
| 346 | driverLocked.bytes === 0 && driverLocked.error !== '', |
| 347 | `${driverLocked.bytes} bytes, error: ${driverLocked.error.slice(0, 120)}`); |
| 348 | |
| 349 | await remove(PDF); |
| 350 | const btnLocked = await clickCompile(20000); |
| 351 | const btnLockedPdf = await exists(PDF); |
| 352 | check('LOCKED: clicking ⚙ does not compile — no PDF was written', |
| 353 | btnLocked.clicked === true && btnLockedPdf === false, |
| 354 | `clicked: ${btnLocked.clicked}, said: ${btnLocked.msg.slice(0, 140)}`); |
| 355 | |
| 356 | // ── C. The refusal a person reads, in the language they read in ────── |
| 357 | // |
| 358 | // The button's message is shown to a PERSON, so it comes out of the catalogue rather than out of |
| 359 | // Rust. Two claims: it IS the catalogue's sentence, and it MOVES when the language does. The |
| 360 | // second is what catches a "localised" string that was hard-coded in English -- a bug class this |
| 361 | // app has shipped before, where a locale change left the text where it was. |
| 362 | |
| 363 | const englishSaid = await p.evaluate(() => ({ |
| 364 | msg: (document.querySelector('.files-view-msg') || {}).textContent || '', |
| 365 | cat: window.DaimondI18n ? window.DaimondI18n.t('typst.pack_locked') : '', |
| 366 | loc: window.DaimondI18n ? window.DaimondI18n.locale() : '', |
| 367 | })); |
| 368 | check('the refusal a person reads is the catalogue\'s sentence, not one built in the code', |
| 369 | englishSaid.msg.trim() === englishSaid.cat.trim() && englishSaid.cat.length > 20, |
| 370 | `shown "${englishSaid.msg.slice(0, 60)}" vs catalogue "${englishSaid.cat.slice(0, 60)}"`); |
| 371 | // It has to say the three things a refused reader needs: that it is a pack, where to get it, and |
| 372 | // that it is not their credits being spent. |
| 373 | check('and it says it is a pack, names where to get it, and that credits are not spent', |
| 374 | /pack/i.test(englishSaid.cat) && /Tools/.test(englishSaid.cat) && /credits/i.test(englishSaid.cat), |
| 375 | englishSaid.cat); |
| 376 | // No dialog ambushes the reader: the answer arrives in the header line where they clicked. |
| 377 | const noDialog = await p.evaluate(() => { |
| 378 | const open = Array.from(document.querySelectorAll('dialog')).filter(d => d.open); |
| 379 | return open.length === 0; |
| 380 | }); |
| 381 | check('and nothing is put up in front of them to deliver it', noDialog === true); |
| 382 | |
| 383 | await p.evaluate(async () => { |
| 384 | if (window.DaimondI18n) await window.DaimondI18n.setLocale('de'); |
| 385 | }); |
| 386 | await sleep(600); |
| 387 | await remove(PDF); |
| 388 | const btnDe = await clickCompile(20000); |
| 389 | const germanSaid = await p.evaluate(() => ({ |
| 390 | cat: window.DaimondI18n ? window.DaimondI18n.t('typst.pack_locked') : '', |
| 391 | loc: window.DaimondI18n ? window.DaimondI18n.locale() : '', |
| 392 | })); |
| 393 | check('a reader in another language is refused in THEIR language', |
| 394 | germanSaid.loc === 'de' |
| 395 | && btnDe.msg.trim() === germanSaid.cat.trim() |
| 396 | && germanSaid.cat.trim() !== englishSaid.cat.trim(), |
| 397 | `locale ${germanSaid.loc}, shown "${btnDe.msg.slice(0, 60)}"`); |
| 398 | check('and it is still refused in that language — no PDF was written', |
| 399 | (await exists(PDF)) === false); |
| 400 | |
| 401 | await p.evaluate(async () => { |
| 402 | if (window.DaimondI18n) await window.DaimondI18n.setLocale('en'); |
| 403 | }); |
| 404 | await sleep(400); |
| 405 | |
| 406 | // ── Buying it gives it back ────────────────────────────────────────── |
| 407 | // |
| 408 | // The last control, and the one that proves the gate is the ENTITLEMENT and not the code path: the |
| 409 | // same click, on the same file, in the same session, once the pack is held. |
| 410 | |
| 411 | await setLocked(''); |
| 412 | await remove(PDF); |
| 413 | const btnBought = await clickCompile(); |
| 414 | const btnBoughtPdf = await exists(PDF); |
| 415 | check('bought: the very same click compiles again, in the same sitting', |
| 416 | btnBought.clicked === true && btnBoughtPdf === true, btnBought.msg.slice(0, 160)); |
| 417 | |
| 418 | await remove(PDF); |
| 419 | const toolBought = await runTool('typst_compile', { path: SRC }); |
| 420 | check('bought: and so does the model\'s tool', |
| 421 | (await exists(PDF)) === true, toolBought.slice(0, 160)); |
| 422 | |
| 423 | // ── E. Driven by the PRICE in the shipped catalogue ────────────────── |
| 424 | // |
| 425 | // Everything above sets the lock by hand, which proves the gate and not the SALE. This phase |
| 426 | // starts from `gateway/app.jdat` -- the file an operator edits to put a pack on sale -- and lets |
| 427 | // the page do the rest: `/api/tools` answers what that catalogue sells, `www/js/tools.js` reads |
| 428 | // it, pushes the shortfall into the wasm, and the compile is attempted for real. Nothing between |
| 429 | // the price and the refusal is simulated except the gateway's own parse, which its Rust tests |
| 430 | // cover. |
| 431 | // |
| 432 | // And then the control, which CHANGED when the gate did. Taking the price out used to make the |
| 433 | // same run compile: the listing was built from the catalogue alone, so an entry nobody could buy |
| 434 | // was an entry nobody was locked out of. That was the defect, not the design -- phase F below |
| 435 | // reproduces it deliberately -- and the fix put the gated register in the BUILD, where a catalogue |
| 436 | // edit cannot empty it. So a pack with no price is still a gated pack, and what the price decides |
| 437 | // now is whether it can be BOUGHT. The two checks below assert exactly that, which is the property |
| 438 | // the sale rests on: the lock does not move with the price. Revert the fix and they go red, because |
| 439 | // the pack would fall out of the listing with its price. |
| 440 | // |
| 441 | // The anti-vacuity control for the whole phase is therefore not here but in phase A above, which |
| 442 | // compiles through all three doors unlocked, and in phase F's first pair, which reaches the same |
| 443 | // engine through the same listing route and DOES unlock it. |
| 444 | |
| 445 | /// Serve `/api/tools` with exactly this listing and let the panel push what it makes of it into |
| 446 | /// the engine. The low half of `serveCatalogue`, separated so the OLD rule can be served too. |
| 447 | async function serveListing(tools) { |
| 448 | await p.unroute('**/api/tools').catch(() => {}); |
| 449 | await p.route('**/api/tools', r => r.fulfill({ |
| 450 | status: 200, contentType: 'application/json', |
| 451 | body: JSON.stringify({ ok: true, credits_minor: 0, tools }), |
| 452 | })); |
| 453 | await p.evaluate(() => window.DaimondTools && window.DaimondTools.reload()); |
| 454 | await sleep(900); |
| 455 | return p.evaluate(async () => { |
| 456 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 457 | return { locked: mod.locked_packs(), tool: mod.tool_locked('typst_compile') }; |
| 458 | }); |
| 459 | } |
| 460 | |
| 461 | /// The listing the gateway builds from a catalogue string, for an account holding nothing. |
| 462 | /// |
| 463 | /// A UNION of two sets and not a walk over one, mirroring `catalogue::listing`: everything the |
| 464 | /// catalogue sells, then every gated pack nothing sells. The mirror is deliberately dumb -- the |
| 465 | /// gateway's own tests cover the rule -- and what it is here for is to put the shape the gateway |
| 466 | /// now emits in front of the real page and the real engine. |
| 467 | function listingFor(str) { |
| 468 | const sold = parseCatalogue(str).map(t => ({ |
| 469 | tool: t.tool, name: t.name, blurb: t.blurb, price_minor: t.price_minor, |
| 470 | unlocked: false, purchasable: true, gated: GATES.indexOf(t.tool) >= 0, currency: 'usd', |
| 471 | })); |
| 472 | GATES.forEach((k) => { |
| 473 | if (sold.some(t => t.tool.toLowerCase() === k.toLowerCase())) return; |
| 474 | sold.push({ |
| 475 | tool: k, name: '', blurb: '', price_minor: 0, |
| 476 | unlocked: false, purchasable: false, gated: true, currency: 'usd', |
| 477 | }); |
| 478 | }); |
| 479 | return sold; |
| 480 | } |
| 481 | |
| 482 | /// Serve `/api/tools` from a catalogue string, as the gateway would for an account holding |
| 483 | /// nothing, and let the panel push what it makes of it into the engine. |
| 484 | async function serveCatalogue(str) { |
| 485 | return serveListing(listingFor(str)); |
| 486 | } |
| 487 | |
| 488 | const SHIPPED = catalogueString(); |
| 489 | if (SELLS.indexOf(PACK) < 0) { |
| 490 | console.log(' skip the shipped catalogue sells no pack this build locks on, so the ' |
| 491 | + 'end-to-end phase was NOT RUN — see the first failure above'); |
| 492 | } else { |
| 493 | await setLocked(''); |
| 494 | const engPriced = await serveCatalogue(SHIPPED); |
| 495 | check('priced in the catalogue: the page reads it and the engine locks the tool', |
| 496 | engPriced.tool === true && engPriced.locked.split(',').indexOf(PACK) >= 0, |
| 497 | `catalogue "${SHIPPED.slice(0, 60)}…" → ${JSON.stringify(engPriced)}`); |
| 498 | |
| 499 | await remove(PDF); |
| 500 | const soldRefusal = await runTool('typst_compile', { path: SRC }); |
| 501 | check('priced and unbought: the compile is refused and no PDF is written', |
| 502 | (await exists(PDF)) === false && soldRefusal.includes(PACK), |
| 503 | soldRefusal.slice(0, 140)); |
| 504 | |
| 505 | // The control: the same catalogue with the price taken out. The entry is dropped from the |
| 506 | // sale -- a tool with no price is a tool nobody can buy -- and the pack is then reported by |
| 507 | // the BUILD's register instead, unbought and so still locked. |
| 508 | const unpriced = SHIPPED.replace(/^([^:,]+):\d+:/, '$1::'); |
| 509 | const engFree = await serveCatalogue(unpriced); |
| 510 | check('price removed: the pack drops out of the sale and STAYS locked', |
| 511 | engFree.tool === true && engFree.locked.split(',').indexOf(PACK) >= 0, |
| 512 | `catalogue "${unpriced.slice(0, 60)}…" → ${JSON.stringify(engFree)}` |
| 513 | + ' — a listing built from the catalogue alone reports nothing here and gives the pack away'); |
| 514 | |
| 515 | await remove(PDF); |
| 516 | const stillRefused = await runTool('typst_compile', { path: SRC }); |
| 517 | check('price removed: the very same call is still refused, and no PDF is written', |
| 518 | (await exists(PDF)) === false && stillRefused.includes(PACK), |
| 519 | stillRefused.slice(0, 140)); |
| 520 | |
| 521 | await p.unroute('**/api/tools').catch(() => {}); |
| 522 | await setLocked(''); |
| 523 | } |
| 524 | |
| 525 | // ── F. Sold is not locked ──────────────────────────────────────────── |
| 526 | // |
| 527 | // Phase E moves the catalogue towards HAVING a price. Every failure that has actually happened |
| 528 | // moved it the other way: the running gateway's catalogue was the empty string, so nothing was on |
| 529 | // sale, so nothing was locked, so a $15 pack compiled documents for free on every device and no |
| 530 | // log line said so. This phase therefore runs OUTSIDE the guard above -- a shipped catalogue that |
| 531 | // has lost its price is exactly the state it is here to measure, and skipping it then would skip |
| 532 | // it precisely when it matters. |
| 533 | // |
| 534 | // The control is run FIRST and is the defect itself, so the phase cannot pass by refusing |
| 535 | // everything: the same empty catalogue, listed the old way -- one line per entry, which for an |
| 536 | // empty catalogue is no lines at all -- unlocks the tool and writes a PDF. |
| 537 | |
| 538 | await setLocked(''); |
| 539 | const engOldRule = await serveListing([]); |
| 540 | check('THE DEFECT, reproduced: a listing built only from the catalogue unlocks the pack', |
| 541 | engOldRule.tool === false && engOldRule.locked === '', |
| 542 | `empty listing → ${JSON.stringify(engOldRule)}`); |
| 543 | await remove(PDF); |
| 544 | const gaveItAway = await runTool('typst_compile', { path: SRC }); |
| 545 | check('and the tool compiles for an account that bought nothing — this is what was live', |
| 546 | (await exists(PDF)) === true, gaveItAway.slice(0, 140)); |
| 547 | |
| 548 | // And now the same empty catalogue through the rule that separates the two. |
| 549 | const engEmpty = await serveCatalogue(''); |
| 550 | check('EMPTY catalogue: the pack is still gated, so the engine still locks the tool', |
| 551 | engEmpty.tool === true && engEmpty.locked.split(',').indexOf(PACK) >= 0, |
| 552 | `empty catalogue → ${JSON.stringify(engEmpty)}`); |
| 553 | await remove(PDF); |
| 554 | const emptyRefusal = await runTool('typst_compile', { path: SRC }); |
| 555 | check('EMPTY catalogue: the compile is refused and no PDF is written', |
| 556 | (await exists(PDF)) === false && emptyRefusal.includes(PACK), |
| 557 | emptyRefusal.slice(0, 140)); |
| 558 | |
| 559 | // A catalogue nobody can parse an entry out of is the same case wearing a different hat: a |
| 560 | // mistyped price is not a price, so nothing is on sale -- and nothing being on sale must not be |
| 561 | // what decides whether the tool runs. |
| 562 | const engJunk = await serveCatalogue(PACK + ':free:Publishing,,:1500:Nameless'); |
| 563 | check('UNPARSEABLE catalogue: the pack is locked rather than released', |
| 564 | engJunk.tool === true && engJunk.locked.split(',').indexOf(PACK) >= 0, |
| 565 | `junk catalogue → ${JSON.stringify(engJunk)}`); |
| 566 | await remove(PDF); |
| 567 | const junkRefusal = await runTool('typst_compile', { path: SRC }); |
| 568 | check('UNPARSEABLE catalogue: the compile is refused and no PDF is written', |
| 569 | (await exists(PDF)) === false && junkRefusal.includes(PACK), |
| 570 | junkRefusal.slice(0, 140)); |
| 571 | |
| 572 | // NOT checked here, and deliberately: what the PANEL draws for a pack it cannot sell. The gateway |
| 573 | // now says `purchasable: false` and quotes no price, and `www/js/tools.js` does not read that |
| 574 | // field yet -- it draws a Buy button from `unlocked` alone, so in this state it offers the pack at |
| 575 | // $0.00 and the checkout refuses the click. That is a cosmetic fault in a state that only exists |
| 576 | // when the catalogue is broken, it belongs to the panel's own lane, and a red line here would |
| 577 | // block a release on it. `dev/verify_toolspanel.mjs` is where it should land once the panel |
| 578 | // honours the field. |
| 579 | |
| 580 | await p.unroute('**/api/tools').catch(() => {}); |
| 581 | await setLocked(''); |
| 582 | |
| 583 | // ── The panel no longer calls it free ──────────────────────────────── |
| 584 | // |
| 585 | // The belt is what the Tools panel draws from, so a sold tool has to be MARKED sold there or the |
| 586 | // panel keeps listing it under "Built in" with no price on it. |
| 587 | |
| 588 | const belt = await p.evaluate(async () => { |
| 589 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 590 | return JSON.parse(mod.builtin_tools()); |
| 591 | }); |
| 592 | const typstEntry = belt.find(t => t.tool === 'typst_compile'); |
| 593 | const freeEntries = belt.filter(t => !t.pack); |
| 594 | check('the belt marks the sold tool with its pack, and marks nothing else', |
| 595 | !!typstEntry && typstEntry.pack === PACK |
| 596 | && freeEntries.length === belt.length - 1 |
| 597 | && !freeEntries.some(t => t.tool === 'typst_compile'), |
| 598 | typstEntry ? `pack "${typstEntry.pack}", ${freeEntries.length} free of ${belt.length}` : 'no entry'); |
| 599 | |
| 600 | await shot(s, 'typstpack'); |
| 601 | |
| 602 | await p.evaluate(async () => { |
| 603 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 604 | mod.set_locked_packs(''); |
| 605 | mod.set_account_ns(''); |
| 606 | }); |
| 607 | |
| 608 | const errs = s.errs.filter(e => !/favicon|404|401|net::ERR/.test(e)); |
| 609 | console.log('\nconsole errors:', errs.slice(0, 4)); |
| 610 | console.log('scratch:', SCRATCH); |
| 611 | await s.close(); |
| 612 | |
| 613 | console.log(`\n${ok.length} passed, ${bad.length} failed`); |
| 614 | if (bad.length) console.log('FAILED:\n ' + bad.join('\n ')); |
| 615 | process.exit(bad.length ? 1 : 0); |