oxedyne/daimond/dev/verify_typstproject.mjs
30.2 KiB, 1 run
created by r2519314175:775, 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_typstproject.mjs — a book is a project, and every file it names must arrive. |
| 2 | // |
| 3 | // The author tried to compile a 17-chapter book by the Doc panel's Compile button and |
| 4 | // by asking his daimon, and both failed on line one with |
| 5 | // |
| 6 | // failed to load file (access denied), hints: cannot read file outside of project |
| 7 | // root, you can adjust the project root with the --root argument |
| 8 | // |
| 9 | // Three defects, one feature. The compiler was handed EXACTLY ONE FILE and its shadow |
| 10 | // filesystem was wiped before every compile, so an `#import` could never resolve; the |
| 11 | // message it failed with described a setting that does not exist here, so the daimon |
| 12 | // reading it concluded the book's images were out of bounds and spent the hour hunting |
| 13 | // assets that were never the problem; and when the gatherer was built to fix both, IT |
| 14 | // WAS NEVER WIRED IN. `Tool::TypstCompile` went on reading one file and handing the |
| 15 | // string to the single-source door for a whole release, so the user's next attempt |
| 16 | // failed the same way, the daimon concluded the compiler "can only handle self-contained |
| 17 | // single files", and told him to attach a folder that was already in the workspace. |
| 18 | // |
| 19 | // That is why every project claim below is now made THROUGH THE MODEL'S TOOL, by wire |
| 20 | // name, with the arguments a model sends. The first version of this file called the |
| 21 | // gatherer directly and passed twenty-four checks while production compiled one string. |
| 22 | // A check that does not go through the door the user goes through is checking a room |
| 23 | // nobody enters. |
| 24 | // |
| 25 | // This file asserts the properties that had to become true, and it asserts most of them |
| 26 | // by TAKING A FILE AWAY. Compiling the whole fixture proves only that something worked; |
| 27 | // withholding each file in turn and demanding that the refusal NAME THE FILE is what |
| 28 | // proves the project is really being gathered and the message is really being composed |
| 29 | // from what this side knows. |
| 30 | // |
| 31 | // Run it red before you believe it green: |
| 32 | // |
| 33 | // node dev/verify_typstproject.mjs # the code as it stands |
| 34 | // TYPST_BROKEN=1 node dev/verify_typstproject.mjs |
| 35 | // |
| 36 | // `TYPST_BROKEN=1` reads the one file and compiles it through |
| 37 | // `window.DaimondTypst.compile(text)`. That is not a simulation of the old code: it is |
| 38 | // what `Tool::TypstCompile` did, line for line, until seq 117, and it is still the right |
| 39 | // door for a source with no project behind it. Every project claim below must fail |
| 40 | // under it. A check that stays green there is checking nothing. |
| 41 | import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'; |
| 42 | import { fileURLToPath } from 'node:url'; |
| 43 | import { dirname, join as pjoin } from 'node:path'; |
| 44 | import { open, shot } from './harness.mjs'; |
| 45 | |
| 46 | const HERE = dirname(fileURLToPath(import.meta.url)); |
| 47 | const FIX = pjoin(HERE, 'fixtures', 'typstproj'); |
| 48 | const BROKEN = process.env.TYPST_BROKEN === '1'; |
| 49 | |
| 50 | // The author's own book, which is the external oracle for everything below: nothing in |
| 51 | // it was written to make a test pass. Skipped, loudly, on a machine that has not got it. |
| 52 | const BOOK = process.env.DAIMOND_BOOK || `${process.env.HOME}/usr/books/elearnity`; |
| 53 | const BOOK_MAIN = 'CheapThinking/thinking.typ'; |
| 54 | |
| 55 | const ok = [], bad = [], skipped = []; |
| 56 | const check = (name, pass, detail) => { |
| 57 | (pass ? ok : bad).push(name + (detail ? ' — ' + detail : '')); |
| 58 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 59 | }; |
| 60 | const skip = (name, why) => { |
| 61 | skipped.push(name); |
| 62 | console.log(' skip ' + name + ' — ' + why); |
| 63 | }; |
| 64 | |
| 65 | // The fixture, read once on this side. `text` files go in as strings so a failure |
| 66 | // message can quote them; the font goes in as bytes. |
| 67 | const FILES = [ |
| 68 | ['books/proj/main.typ', 'text'], |
| 69 | ['books/proj/template.typ', 'text'], |
| 70 | ['books/proj/parts/util.typ', 'text'], |
| 71 | ['books/proj/chap01.typ', 'text'], |
| 72 | ['books/shared/glo.typ', 'text'], |
| 73 | ['books/assets/mark.svg', 'text'], |
| 74 | ['books/assets/fonts/Radley-Regular.ttf', 'bytes'], |
| 75 | ]; |
| 76 | const CONTENT = {}; |
| 77 | for (const [rel, kind] of FILES) { |
| 78 | CONTENT[rel] = kind === 'text' |
| 79 | ? readFileSync(pjoin(FIX, rel), 'utf8') |
| 80 | : Array.from(readFileSync(pjoin(FIX, rel))); |
| 81 | } |
| 82 | const SOLO = readFileSync(pjoin(FIX, 'solo/main.typ'), 'utf8'); |
| 83 | |
| 84 | const s = await open({ name: 'typstproject' }); |
| 85 | const p = s.page; |
| 86 | await p.waitForTimeout(1500); |
| 87 | |
| 88 | // Every claim below is about an account that HOLDS the typesetting pack, which is the |
| 89 | // condition under which "it compiles" was ever true. Stated and asserted rather than |
| 90 | // inherited from a fresh profile that happened to be told nothing. |
| 91 | const held = await p.evaluate(async () => { |
| 92 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 93 | mod.set_locked_packs(''); |
| 94 | return mod.tool_locked('typst_compile'); |
| 95 | }); |
| 96 | check('the account these claims are made about holds the typesetting pack', |
| 97 | held === false, `typst_compile locked: ${held}`); |
| 98 | |
| 99 | /// Seed a private workspace with `files`, then compile `main` in it AS THE MODEL DOES: |
| 100 | /// by calling the tool `typst_compile` by wire name with a `path` argument. |
| 101 | /// |
| 102 | /// Each variant gets its own account namespace, so withholding a file in one cannot |
| 103 | /// leave a stale copy behind for the next -- a shared workspace is exactly how a |
| 104 | /// withheld-file check would go green for the wrong reason. |
| 105 | /// |
| 106 | /// The PDF is read back out of the workspace rather than taken from a return value, |
| 107 | /// because writing it there is part of what the tool promises. |
| 108 | async function compileIn(tag, files, main, broken) { |
| 109 | return await p.evaluate(async ({ tag, files, main, broken }) => { |
| 110 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 111 | mod.set_account_ns('d~tp_' + tag); |
| 112 | const app = new mod.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 256, '', true); |
| 113 | for (const f of files) { |
| 114 | if (typeof f[1] === 'string') { |
| 115 | await app.run_tool('file_write', JSON.stringify({ path: f[0], content: f[1] })); |
| 116 | } else { |
| 117 | await app.write_bytes(f[0], new Uint8Array(f[1])); |
| 118 | } |
| 119 | } |
| 120 | let said = ''; |
| 121 | if (broken) { |
| 122 | // `Tool::TypstCompile` as it stood until seq 117, verbatim: read the one file, |
| 123 | // hand the driver the string. |
| 124 | const text = await app.run_tool('file_read', JSON.stringify({ path: main })); |
| 125 | const out = await window.DaimondTypst.compile(text); |
| 126 | said = (out && out.error) ? 'Error: ' + out.error : ''; |
| 127 | if (out && out.pdf) await app.write_bytes(main.replace(/\.typ$/i, '.pdf'), out.pdf); |
| 128 | } else { |
| 129 | said = await app.run_tool('typst_compile', JSON.stringify({ path: main })); |
| 130 | } |
| 131 | let pdf = null; |
| 132 | if (said.indexOf('Error:') !== 0) { |
| 133 | try { pdf = await mod.read_bytes(main.replace(/\.typ$/i, '.pdf'), 0, 40000000); } |
| 134 | catch (e) { pdf = null; } |
| 135 | } |
| 136 | mod.set_account_ns(''); |
| 137 | return { |
| 138 | said: said, |
| 139 | error: said.indexOf('Error:') === 0 ? said.slice(6).trim() : '', |
| 140 | bytes: pdf ? pdf.length : 0, |
| 141 | magic: pdf ? String.fromCharCode.apply(null, Array.from(pdf.slice(0, 5))) : '', |
| 142 | // A PDF names every font it embeds in its descriptors, so this is a direct |
| 143 | // question about which family was actually drawn with -- not about which |
| 144 | // one the source asked for. |
| 145 | radley: pdf ? new TextDecoder('latin1').decode(pdf).indexOf('Radley') >= 0 : false, |
| 146 | }; |
| 147 | }, { tag, files, main, broken }); |
| 148 | } |
| 149 | |
| 150 | /// The same, by the Doc panel's door, which returns a structured refusal. |
| 151 | async function panelIn(tag, files, main) { |
| 152 | return await p.evaluate(async ({ tag, files, main }) => { |
| 153 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 154 | mod.set_account_ns('d~tpp_' + tag); |
| 155 | const app = new mod.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 256, '', true); |
| 156 | for (const f of files) { |
| 157 | if (typeof f[1] === 'string') { |
| 158 | await app.run_tool('file_write', JSON.stringify({ path: f[0], content: f[1] })); |
| 159 | } else { |
| 160 | await app.write_bytes(f[0], new Uint8Array(f[1])); |
| 161 | } |
| 162 | } |
| 163 | const out = await mod.typst_compile_project(main); |
| 164 | mod.set_account_ns(''); |
| 165 | return { |
| 166 | error: (out && out.error) || '', |
| 167 | bytes: out && out.pdf ? out.pdf.length : 0, |
| 168 | remedy: (out && out.remedy) ? { action: out.remedy.action, path: out.remedy.path, line: out.remedy.line } : null, |
| 169 | }; |
| 170 | }, { tag, files, main }); |
| 171 | } |
| 172 | |
| 173 | const all = () => FILES.map(([rel]) => [rel, CONTENT[rel]]); |
| 174 | const without = (drop) => FILES.filter(([rel]) => rel !== drop).map(([rel]) => [rel, CONTENT[rel]]); |
| 175 | const MAIN = 'books/proj/main.typ'; |
| 176 | |
| 177 | // ── 1. The whole project, through the tool the model calls ────────────────── |
| 178 | const whole = await compileIn('whole', all(), MAIN, BROKEN); |
| 179 | check('the MODEL\'S TOOL compiles a multi-file project to a real PDF', |
| 180 | whole.error === '' && whole.magic === '%PDF-' && whole.bytes > 1000, |
| 181 | whole.error ? whole.error.slice(0, 260) : `${whole.bytes} bytes, magic "${whole.magic}"`); |
| 182 | |
| 183 | // The root is the claim underneath that one. `main.typ` imports `../shared/glo.typ`, |
| 184 | // so a compile that succeeds can only have put the root at `books/` -- one level ABOVE |
| 185 | // the directory the compiled file sits in, which is what `typst --root` is passed on the |
| 186 | // author's command line and what his attachment got wrong. |
| 187 | check('the project root climbs above the compiled file when an import climbs', |
| 188 | whole.error === '' && whole.bytes > 1000, |
| 189 | whole.error ? whole.error.slice(0, 200) : 'compiled with ../shared/glo.typ resolved'); |
| 190 | |
| 191 | // ── 2. The font is the one the source asked for ───────────────────────────── |
| 192 | check('the family the source names is the family embedded in the PDF', |
| 193 | whole.radley === true, |
| 194 | `Radley in the PDF: ${whole.radley}`); |
| 195 | |
| 196 | // ── 3. Withhold each file, and demand the refusal name it ─────────────────── |
| 197 | // |
| 198 | // The old message named nothing at all: it said "cannot read file outside of project |
| 199 | // root" whatever was missing, which is why one wrong sentence produced one wrong |
| 200 | // diagnosis. These assert the PROPERTY -- the name of the thing that is absent appears |
| 201 | // in the reason -- and never a fixed string, so rewording the explanation cannot make |
| 202 | // them lie. |
| 203 | const WITHHELD = [ |
| 204 | ['books/proj/template.typ', 'template.typ', 'a sibling import'], |
| 205 | ['books/proj/parts/util.typ', 'parts/util.typ', 'a sub-directory import'], |
| 206 | ['books/shared/glo.typ', '../shared/glo.typ', 'an import that climbs'], |
| 207 | ['books/assets/mark.svg', '/assets/mark.svg', 'an absolute asset path'], |
| 208 | ['books/proj/chap01.typ', 'chap01.typ', 'a chapter include'], |
| 209 | ]; |
| 210 | // |
| 211 | // Each one carries TWO conditions beyond "it failed and said the name", because on the |
| 212 | // single-file compiler the first import fails whatever is missing -- so "withholding |
| 213 | // template.typ names template.typ" would go green on the very bug this file exists to |
| 214 | // catch. The refusal must therefore also name NOTHING THAT WAS SUPPLIED, and the whole |
| 215 | // project must have compiled when nothing was withheld. Neither is true of a compiler |
| 216 | // that only ever sees one file, and neither depends on a word of the wording. |
| 217 | for (const [drop, named, what] of WITHHELD) { |
| 218 | const tag = drop.replace(/[^a-z0-9]/gi, ''); |
| 219 | const r = await compileIn(tag, without(drop), MAIN, BROKEN); |
| 220 | const others = WITHHELD.map(w => w[1]).filter(n => n !== named && named.indexOf(n) < 0); |
| 221 | const wrong = others.filter(n => r.error.indexOf(n) >= 0); |
| 222 | check(`withholding ${what} fails, and the reason names "${named}" and nothing present`, |
| 223 | whole.error === '' && r.bytes === 0 && r.error.indexOf(named) >= 0 && wrong.length === 0, |
| 224 | r.bytes ? `it compiled anyway (${r.bytes} bytes)` |
| 225 | : (wrong.length ? `it blamed a file that was supplied: ${wrong.join(', ')}` |
| 226 | : r.error.slice(0, 200))); |
| 227 | // And it must not lead with the sentence that misled the author: typst's own words |
| 228 | // are kept, but underneath, after an explanation of what actually happened here. |
| 229 | const said = r.error.indexOf('typst said:'); |
| 230 | const root = r.error.indexOf('--root argument'); |
| 231 | check(` and does not lead with typst's "--root" hint`, |
| 232 | r.bytes === 0 && (root < 0 || (said >= 0 && root > said)), |
| 233 | `explanation at ${said}, typst's hint at ${root}`); |
| 234 | } |
| 235 | |
| 236 | // ── 4. The root the refusal quotes ────────────────────────────────────────── |
| 237 | const noAsset = await compileIn('rootquote', without('books/assets/mark.svg'), MAIN, BROKEN); |
| 238 | check('a refusal says where the project root was put', |
| 239 | noAsset.bytes === 0 && /root was put at books\b/.test(noAsset.error), |
| 240 | noAsset.error.slice(0, 200)); |
| 241 | // And where it LOOKED for the picture, which is not the root: the search runs from the |
| 242 | // compiled file's own folder outward. Naming only the root sent the author looking in |
| 243 | // the wrong folder for something that was never expected to be there. |
| 244 | check(' and names the folders a root-relative name was looked for in', |
| 245 | noAsset.bytes === 0 && /books\/proj/.test(noAsset.error), |
| 246 | noAsset.error.slice(0, 260)); |
| 247 | |
| 248 | // ── 5. Fonts: refuse rather than approximate ──────────────────────────────── |
| 249 | // |
| 250 | // This is a correctness claim, not a nicety. The control below shows what silence |
| 251 | // looks like: the same document, with no Radley anywhere, compiles happily through the |
| 252 | // single-file door and returns a PDF nobody would question. Line breaks, last lines |
| 253 | // and page count all come from the metrics of whatever got substituted, so a book |
| 254 | // proofread against that PDF is a book proofread against a document that will never |
| 255 | // print. Nothing in typst says a word about it -- measured: this build reports |
| 256 | // diagnostics only when a compile FAILS, and a missing family does not fail. |
| 257 | const noFont = await compileIn('nofont', without('books/assets/fonts/Radley-Regular.ttf'), MAIN, BROKEN); |
| 258 | check('a project whose font cannot be loaded is refused, not approximated', |
| 259 | noFont.bytes === 0 && /Radley/.test(noFont.error), |
| 260 | noFont.bytes ? `it compiled in a substitute font (${noFont.bytes} bytes)` : noFont.error.slice(0, 220)); |
| 261 | check(' and the refusal says where to put the font', |
| 262 | noFont.bytes === 0 && /fonts/.test(noFont.error), |
| 263 | noFont.error.slice(0, 200)); |
| 264 | |
| 265 | // The control goes STRAIGHT TO THE VENDORED COMPILER, underneath both doors, because |
| 266 | // that is where the silence lives. Same source, same fonts, nothing in the way: a PDF |
| 267 | // comes back, no diagnostic comes with it, and the family the source asked for is not |
| 268 | // the family in the file. This is the behaviour the two refusals above exist to stop, |
| 269 | // and if it ever changes -- if typst starts warning -- this check going red is how we |
| 270 | // find out that the refusals could be replaced by something better. |
| 271 | const silent = await p.evaluate(async () => { |
| 272 | const VENDOR = new URL('/vendor/typst/', location.href); |
| 273 | const mod = await import(new URL('typst_ts_web_compiler.mjs', VENDOR).href); |
| 274 | await mod.default(new URL('typst_ts_web_compiler_bg.wasm', VENDOR)); |
| 275 | const b = new mod.TypstCompilerBuilder(); |
| 276 | b.set_dummy_access_model(); |
| 277 | for (const n of ['LibertinusSerif-Regular.otf', 'LibertinusSerif-Bold.otf', |
| 278 | 'LibertinusSerif-Italic.otf', 'LibertinusSerif-BoldItalic.otf', 'NewCMMath-Regular.otf']) { |
| 279 | const r = await fetch(new URL('fonts/' + n, VENDOR)); |
| 280 | await b.add_raw_font(new Uint8Array(await r.arrayBuffer())); |
| 281 | } |
| 282 | const c = await b.build(); |
| 283 | c.reset_shadow(); |
| 284 | c.add_source('/m.typ', '#set text(font: "Radley")\n= Hello\n\nBody text.\n'); |
| 285 | const ret = c.compile('/m.typ', undefined, 'pdf', 3); |
| 286 | const pdf = ret instanceof Uint8Array ? ret : (ret && (ret.result || ret.artifact)); |
| 287 | return { |
| 288 | bytes: pdf ? pdf.length : 0, |
| 289 | diag: ret && ret.diagnostics ? JSON.stringify(ret.diagnostics).length : 0, |
| 290 | radley: pdf ? new TextDecoder('latin1').decode(pdf).indexOf('Radley') >= 0 : false, |
| 291 | }; |
| 292 | }); |
| 293 | check('CONTROL: typst itself substitutes a missing font in silence — a PDF, no warning, wrong face', |
| 294 | silent.bytes > 1000 && silent.diag === 0 && silent.radley === false, |
| 295 | `${silent.bytes} bytes, ${silent.diag} bytes of diagnostics, Radley embedded: ${silent.radley}`); |
| 296 | |
| 297 | // And the single-file door refuses the same document, because a lone source can never |
| 298 | // bring a font with it -- there is nothing it could be satisfied by. |
| 299 | const oneFile = await p.evaluate(async () => { |
| 300 | const r = await window.DaimondTypst.compile('#set text(font: "Radley")\n= Hello\n\nBody text.\n'); |
| 301 | return { bytes: r && r.pdf ? r.pdf.length : 0, error: (r && r.error) || '' }; |
| 302 | }); |
| 303 | check('the single-file door refuses a font it could never have', |
| 304 | oneFile.bytes === 0 && /Radley/.test(oneFile.error), |
| 305 | oneFile.bytes ? `it compiled in a substitute (${oneFile.bytes} bytes)` : oneFile.error.slice(0, 200)); |
| 306 | |
| 307 | // ── 5b. A registry package is a fourth kind of failure ────────────────────── |
| 308 | // |
| 309 | // The four ways a compile can fail need four different actions from whoever reads the |
| 310 | // message, and telling them apart is the whole point of composing it here. A missing |
| 311 | // file wants finding; a path above the root wants a different folder attached; a font |
| 312 | // wants a font file; and a `@preview/…` package the bundle does not carry wants NONE OF |
| 313 | // THOSE -- it comes from Typst Universe over a network this compiler does not have, and |
| 314 | // no rearrangement of files will ever satisfy it. In the session that prompted this work |
| 315 | // the daimon spent several turns hunting assets and then rewrote the author's book source |
| 316 | // to work around a message that never said which of the four it was. |
| 317 | // |
| 318 | // THE FIXTURE MUST NAME A PACKAGE THIS BUILD DOES NOT CARRY. It used to be |
| 319 | // `@preview/cetz:0.3.4`, which was the right choice while every `@` import was refused; |
| 320 | // five packs now travel inside the bundle (`www/assets/typst/packs/`) and cetz is one of |
| 321 | // them, so that document COMPILES and the check was measuring the vendoring rather than |
| 322 | // the refusal. `@preview/nonesuch:1.0.0` exists in no registry and in no pack, which is |
| 323 | // the condition the message is for. `verify_typstpack` is what covers the carried side. |
| 324 | const MISSING_PKG = '@preview/nonesuch:1.0.0'; |
| 325 | const pkg = await compileIn('pkg', |
| 326 | [['pkgproj/main.typ', `#import "${MISSING_PKG}"\n\n= Drawing\n`]], |
| 327 | 'pkgproj/main.typ', BROKEN); |
| 328 | check('a registry package this build does not carry is refused as a package, not as a missing file', |
| 329 | whole.error === '' && pkg.bytes === 0 |
| 330 | && pkg.error.indexOf(MISSING_PKG) >= 0 |
| 331 | && /registry/i.test(pkg.error) && /network/i.test(pkg.error), |
| 332 | pkg.bytes ? `it compiled anyway (${pkg.bytes} bytes)` : pkg.error.slice(0, 240)); |
| 333 | check(' and says plainly that moving files will not fix it', |
| 334 | whole.error === '' && pkg.bytes === 0 && /not a (missing file|path problem)/i.test(pkg.error), |
| 335 | pkg.error.slice(0, 200)); |
| 336 | |
| 337 | // ── 6. Reaching above the marked folder, and what to DO about it ──────────── |
| 338 | // |
| 339 | // The author's attachment was one level too deep, and "cannot read file outside of |
| 340 | // project root" is a terrible way to be told so. This asks for the fix by name -- and |
| 341 | // then asks for it as an ACTION, because prose is what the daimon got wrong: it read a |
| 342 | // correct refusal, concluded the compiler could not do imports at all, and told the |
| 343 | // user to attach a folder that was already open and named in the same sentence. |
| 344 | const solo = await compileIn('solo', [['solo/main.typ', SOLO]], 'solo/main.typ', BROKEN); |
| 345 | check('a project that reaches above the marked folder says to mark the one above it', |
| 346 | // "mark", not "above": the fixture's own import is literally `../../above.typ`, so a |
| 347 | // test for the word "above" passes on a message that merely quotes the line -- which |
| 348 | // is exactly what the single-source refusal does. |
| 349 | solo.bytes === 0 && /\bmark\b/i.test(solo.error) && solo.error.indexOf('../../above.typ') >= 0, |
| 350 | solo.bytes ? `it compiled anyway (${solo.bytes} bytes)` : solo.error.slice(0, 240)); |
| 351 | check(' and carries a machine-readable remedy, not only advice', |
| 352 | solo.bytes === 0 && /\bREMEDY mark-above folder=\S+ because=\S+ needs=\S+/.test(solo.error), |
| 353 | solo.error.slice(-160)); |
| 354 | // The same refusal by the panel's door arrives STRUCTURED, so the page can draw it as a |
| 355 | // button instead of asking the reader to translate a paragraph into a click. |
| 356 | const soloPanel = BROKEN ? null : await panelIn('solo', [['solo/main.typ', SOLO]], 'solo/main.typ'); |
| 357 | if (soloPanel) { |
| 358 | check(' and the panel gets it as a field it can draw as a button', |
| 359 | soloPanel.bytes === 0 && soloPanel.remedy !== null |
| 360 | && soloPanel.remedy.action === 'mark-above' && soloPanel.remedy.path === 'solo', |
| 361 | JSON.stringify(soloPanel.remedy)); |
| 362 | } else { |
| 363 | skip(' and the panel gets it as a field it can draw as a button', 'the panel door is not the broken path'); |
| 364 | } |
| 365 | |
| 366 | // ── 7. The walk is bounded, and says so rather than compiling a part ──────── |
| 367 | // |
| 368 | // A cap that truncates is worse than no cap: the PDF comes out, a chapter is missing, |
| 369 | // and nobody is told. So the claim is not "it stops" but "it stops WITHOUT producing |
| 370 | // a PDF, and names the limit". The control immediately after is a project of the same |
| 371 | // shape, comfortably under the cap, which must still compile -- otherwise this check |
| 372 | // would pass just as well on a compiler that had stopped working altogether. |
| 373 | function chain(n) { |
| 374 | const files = []; |
| 375 | let main = ''; |
| 376 | for (let i = 0; i < n; i++) { |
| 377 | files.push([`cap/f${i}.typ`, `#let v${i}() = [${i}]\n`]); |
| 378 | main += `#import "f${i}.typ": v${i}\n`; |
| 379 | } |
| 380 | files.push(['cap/main.typ', main + '= Capped\n']); |
| 381 | return files; |
| 382 | } |
| 383 | const over = await compileIn('capover', chain(520), 'cap/main.typ', BROKEN); |
| 384 | check('past the file cap the compile refuses and names the limit', |
| 385 | over.bytes === 0 && /\b500\b/.test(over.error), |
| 386 | over.bytes ? `it compiled a partial project (${over.bytes} bytes)` : over.error.slice(0, 200)); |
| 387 | const under = await compileIn('capunder', chain(60), 'cap/main.typ', BROKEN); |
| 388 | check('CONTROL: the same shape under the cap still compiles', |
| 389 | under.error === '' && under.bytes > 1000, |
| 390 | under.error ? under.error.slice(0, 200) : `${under.bytes} bytes`); |
| 391 | |
| 392 | // ── 8. The root is not the search path ────────────────────────────────────── |
| 393 | // |
| 394 | // The second fault, and the one that made the author's book uncompilable BOTH ways |
| 395 | // round. His book imports `../style/…`, so the root must sit above the book; his fonts |
| 396 | // and his root-relative pictures live INSIDE the book. While the font and asset search |
| 397 | // hung off the computed root, the arrangement that made the imports resolve was the |
| 398 | // arrangement that lost every font, and the compile was refused for a missing family |
| 399 | // that was sitting in the project all along. |
| 400 | // |
| 401 | // Same fixture, re-laid out: `inner/book/` reaching up to `inner/shared/`, with the |
| 402 | // pictures and fonts inside the book rather than beside the shared folder. The root |
| 403 | // must come out at `inner` for the import, and `"/assets/mark.svg"` and the fonts must |
| 404 | // still be found at `inner/book/assets/…`. |
| 405 | const inner = (drop) => FILES.filter(([rel]) => rel !== drop).map(([rel]) => [ |
| 406 | rel.startsWith('books/assets/') ? 'inner/book/assets/' + rel.slice('books/assets/'.length) |
| 407 | : rel.startsWith('books/proj/') ? 'inner/book/' + rel.slice('books/proj/'.length) |
| 408 | : 'inner/shared/' + rel.slice('books/shared/'.length), |
| 409 | CONTENT[rel], |
| 410 | ]); |
| 411 | const nest = await compileIn('inner', inner(''), 'inner/book/main.typ', BROKEN); |
| 412 | check('a book that carries its own assets and fonts compiles with the root ABOVE it', |
| 413 | nest.error === '' && nest.magic === '%PDF-' && nest.bytes > 1000, |
| 414 | nest.error ? nest.error.slice(0, 300) : `${nest.bytes} bytes`); |
| 415 | check(' and the font came from the book\'s own folder, not from the root', |
| 416 | nest.radley === true, `Radley in the PDF: ${nest.radley}`); |
| 417 | // The picture is the other half of the same claim, and it is asserted by absence: take |
| 418 | // it away and the compile must fail naming it, so its presence above cannot be an |
| 419 | // accident of a compiler that ignores pictures. |
| 420 | const nestNoPic = await compileIn('innernopic', inner('books/assets/mark.svg'), 'inner/book/main.typ', BROKEN); |
| 421 | check(' and a root-relative picture inside the book is really being gathered', |
| 422 | nest.error === '' && nestNoPic.bytes === 0 && nestNoPic.error.indexOf('/assets/mark.svg') >= 0, |
| 423 | nestNoPic.bytes ? `it compiled without the picture (${nestNoPic.bytes} bytes)` : nestNoPic.error.slice(0, 200)); |
| 424 | |
| 425 | // ── 9. A project that declares its own root is taken at its word ──────────── |
| 426 | // |
| 427 | // `typst.toml` is typst's own manifest, and where a project has one it says where the |
| 428 | // project starts. The closure here sits entirely inside `tt/book`, so the root would |
| 429 | // otherwise be inferred as `tt/book`; the manifest one level up moves it, which is |
| 430 | // visible in the folder a refusal quotes. |
| 431 | const MANIFEST = [ |
| 432 | ['tt/typst.toml', '[package]\nname = "demo"\nversion = "0.1.0"\nentrypoint = "book/main.typ"\n'], |
| 433 | ['tt/book/main.typ', '#import "part.typ": bit\n\n= Declared\n\n#bit\n\n#image("/assets/mark.svg", width: 20pt)\n'], |
| 434 | ['tt/book/part.typ', '#let bit = [a part]\n'], |
| 435 | ]; |
| 436 | const manifest = await compileIn('manifest', MANIFEST, 'tt/book/main.typ', BROKEN); |
| 437 | check('a typst.toml above the file declares the root, and the refusal quotes it', |
| 438 | manifest.bytes === 0 && /root was put at tt\b/.test(manifest.error), |
| 439 | manifest.error.slice(0, 220)); |
| 440 | |
| 441 | // ── 10. The author's own book ─────────────────────────────────────────────── |
| 442 | // |
| 443 | // Everything above is a fixture written by the same hand that wrote the code, which |
| 444 | // proves consistency and not correctness. This is the oracle: 63 sources, 24 fonts and |
| 445 | // three pictures, none of them arranged for this file, laid out the two ways the author |
| 446 | // might mark the folder in. |
| 447 | if (!existsSync(pjoin(BOOK, BOOK_MAIN))) { |
| 448 | skip('the author\'s real book gathers', `no book at ${BOOK}`); |
| 449 | } else { |
| 450 | const lits = (t) => Array.from(t.matchAll(/"([^"\n]*)"/g)).map(m => m[1]); |
| 451 | const seen = new Set([BOOK_MAIN]); |
| 452 | const q = [BOOK_MAIN]; |
| 453 | const pics = new Set(); |
| 454 | while (q.length) { |
| 455 | const f = q.pop(); |
| 456 | const dir = f.indexOf('/') >= 0 ? f.slice(0, f.lastIndexOf('/')) : ''; |
| 457 | let text; try { text = readFileSync(pjoin(BOOK, f), 'utf8'); } catch { continue; } |
| 458 | for (const l of lits(text)) { |
| 459 | if (l.startsWith('@')) continue; |
| 460 | const e = (l.split('/').pop().split('.').pop() || '').toLowerCase(); |
| 461 | if (e === 'typ') { |
| 462 | if (l.startsWith('/')) continue; |
| 463 | const segs = (dir ? dir + '/' + l : l).split('/'); |
| 464 | const out = []; |
| 465 | let bad = false; |
| 466 | for (const sg of segs) { |
| 467 | if (sg === '' || sg === '.') continue; |
| 468 | if (sg === '..') { if (!out.length) { bad = true; break; } out.pop(); continue; } |
| 469 | out.push(sg); |
| 470 | } |
| 471 | const path = out.join('/'); |
| 472 | if (bad || seen.has(path) || !existsSync(pjoin(BOOK, path))) continue; |
| 473 | seen.add(path); q.push(path); |
| 474 | } else if (['svg', 'png', 'jpg', 'jpeg', 'webp'].includes(e) && l.startsWith('/')) { |
| 475 | pics.add(l.slice(1)); |
| 476 | } |
| 477 | } |
| 478 | } |
| 479 | const fontFiles = []; |
| 480 | (function walk(d) { |
| 481 | for (const n of readdirSync(pjoin(BOOK, d))) { |
| 482 | const rel = d + '/' + n; |
| 483 | if (statSync(pjoin(BOOK, rel)).isDirectory()) walk(rel); |
| 484 | else if (/\.(ttf|otf|ttc)$/i.test(n)) fontFiles.push(rel); |
| 485 | } |
| 486 | })('assets/fonts'); |
| 487 | |
| 488 | // The book's sources, VERBATIM -- all 63 files, a glossary, an index, 17 chapters. |
| 489 | // |
| 490 | // Until the packs were vendored this could not be true: `book_template.typ` imports |
| 491 | // `@preview/cetz` and `@preview/cetz-plot`, and chapter two's figures are drawn with |
| 492 | // cetz, so every `@preview/…` line was stripped and the figures replaced by grey |
| 493 | // rectangles -- which meant this check compiled a book nobody wrote. cetz, cetz-plot |
| 494 | // and their closure now travel inside the bundle, so the author's own source is what |
| 495 | // goes in, drawings included. Reinstating a stub or an import filter here would put |
| 496 | // the fiction back; if a `@preview/…` import stops this compile, the answer is to |
| 497 | // vendor the pack (www/assets/typst/refresh.sh), not to edit it out. |
| 498 | const bookSrc = [...seen].map(rel => [rel, readFileSync(pjoin(BOOK, rel), 'utf8')]); |
| 499 | const bookBin = [...pics, ...fontFiles].map(rel => [rel, Array.from(readFileSync(pjoin(BOOK, rel)))]); |
| 500 | console.log(` .. the real book: ${bookSrc.length} sources, ${pics.size} pictures, ${fontFiles.length} fonts`); |
| 501 | |
| 502 | // (a) The folder above the book is marked in -- the arrangement that makes the |
| 503 | // `../style/…` imports resolve -- and the book's own `assets` is what a copy of |
| 504 | // it looks like when the symlink has not been followed: inside the book, one |
| 505 | // level below the root the imports force. This is the shape that lost every |
| 506 | // font, and it is where the author's book actually failed. |
| 507 | const inBook = bookSrc.concat(bookBin.map(([rel, b]) => ['CheapThinking/' + rel, b])); |
| 508 | const real = await compileIn('realbook', inBook, BOOK_MAIN, BROKEN); |
| 509 | check('THE REAL BOOK compiles with the root above it and its assets inside it', |
| 510 | real.error === '' && real.magic === '%PDF-' && real.bytes > 20000, |
| 511 | real.error ? real.error.slice(0, 400) : `${real.bytes} bytes`); |
| 512 | check(' and it is set in the family the book asks for', |
| 513 | real.radley === true, `Radley in the PDF: ${real.radley}`); |
| 514 | |
| 515 | // (b) The same book with the shared tree's assets where the disk really keeps them, |
| 516 | // beside the book rather than inside it. Both must work, because which one the |
| 517 | // user gets depends on whether a symlink was followed by whatever copied it. |
| 518 | const beside = bookSrc.concat(bookBin); |
| 519 | const real2 = await compileIn('realbook2', beside, BOOK_MAIN, BROKEN); |
| 520 | check(' and again with the shared assets beside the book instead of inside it', |
| 521 | real2.error === '' && real2.bytes > 20000, |
| 522 | real2.error ? real2.error.slice(0, 300) : `${real2.bytes} bytes`); |
| 523 | |
| 524 | // (c) The book folder marked in on its own, which is the mistake anybody would make. |
| 525 | // There is no reading above a marked folder -- the mark IS the permission -- so |
| 526 | // the only honest answer is a refusal that names the import that reaches out and |
| 527 | // carries the action to take. |
| 528 | const onlyBook = inBook |
| 529 | .filter(([rel]) => rel.startsWith('CheapThinking/')) |
| 530 | .map(([rel, c]) => [rel.slice('CheapThinking/'.length), c]); |
| 531 | const tooDeep = await compileIn('realbookdeep', onlyBook, 'thinking.typ', BROKEN); |
| 532 | check(' and marking only the book folder is refused by name, with the remedy attached', |
| 533 | tooDeep.bytes === 0 |
| 534 | && tooDeep.error.indexOf('../style/glossary_index.typ') >= 0 |
| 535 | && /REMEDY mark-above/.test(tooDeep.error), |
| 536 | tooDeep.error.slice(0, 300)); |
| 537 | } |
| 538 | |
| 539 | await shot(s, 'typstproject'); |
| 540 | const errs = s.errs.filter(e => !/favicon|404|401|net::ERR/.test(e)); |
| 541 | console.log('\nconsole errors:', errs.slice(0, 4)); |
| 542 | await s.close(); |
| 543 | |
| 544 | console.log(`\n${ok.length} passed, ${bad.length} failed, ${skipped.length} skipped` |
| 545 | + (BROKEN ? ' (TYPST_BROKEN=1)' : '')); |
| 546 | if (bad.length) console.log('FAILED:\n ' + bad.join('\n ')); |
| 547 | process.exit(bad.length ? 1 : 0); |