kaloko 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,162 @@
1
+ // Exportability (#42): a design version leaves Kaloko in open formats only, as a folder or a zip that opens from disk:
2
+ // the live HTML with its assets (the design folder layout), tokens.json (W3C DTCG), scenario.yml (qawalk.scenario.v1),
3
+ // the screenshots, kaloko.json (what is where) and index.html, a start page without scripts.
4
+ import { mkdir, writeFile } from 'node:fs/promises'
5
+ import { createWriteStream } from 'node:fs'
6
+ import { Readable } from 'node:stream'
7
+ import { pipeline } from 'node:stream/promises'
8
+ import { gunzipSync } from 'node:zlib'
9
+ import { join, dirname, relative, basename, isAbsolute } from 'node:path'
10
+ import yaml from 'js-yaml'
11
+ import { zipStream } from './zip.mjs'
12
+ import { UserError } from './util.mjs'
13
+ import { TOKENS_FILE } from './design-core.mjs'
14
+
15
+ export const DESIGN_EXPORT_SCHEMA = 'qawalk.design-export.v1'
16
+ const enc = encodeURIComponent
17
+ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
18
+ const titleOf = (t, locale) => (t && typeof t === 'object' ? t[locale] ?? t.default ?? Object.values(t)[0] : t) || ''
19
+ const flowOrder = (nodes) => nodes.filter((n) => n.kind !== 'decision').slice().sort((a, b) => (a.col ?? 0) - (b.col ?? 0) || (a.lane ?? 0) - (b.lane ?? 0))
20
+
21
+ /** A qawalk.scenario.v1 document from a run manifest: a design version carries the whole flow (steps, criteria, edges). */
22
+ export function scenarioFromManifest (m) {
23
+ const lanes = m.lanes || []
24
+ const nodes = m.nodes.map((n) => {
25
+ const own = (n.criteria || []).filter((c) => !c.pack)
26
+ const packs = [...new Set((n.criteria || []).filter((c) => c.pack).map((c) => c.pack))]
27
+ return {
28
+ id: n.id, kind: n.kind, title: n.title,
29
+ ...(n.path != null ? { path: n.path } : {}), ...(n.purpose ? { purpose: n.purpose } : {}),
30
+ ...(lanes.length && Number.isInteger(n.lane) ? { lane: n.lane } : {}), ...(n.session ? { session: n.session } : {}),
31
+ // run manifests do not carry how a step is reached; the scenario needs it, so the export names the manual way
32
+ ...(['decision', 'stack'].includes(n.kind) ? {} : { execution: n.execution?.strategy && n.execution?.instructions ? { strategy: n.execution.strategy, instructions: n.execution.instructions } : { strategy: 'manual', instructions: `Open ${typeof n.path === 'string' ? n.path : `the ${n.id} step`}.` } }),
33
+ ...(own.length ? { criteria: own.map((c) => ({ id: c.id, text: c.text, check: c.check })) } : {}), ...(packs.length ? { packs } : {})
34
+ }
35
+ })
36
+ return {
37
+ schema: 'qawalk.scenario.v1', id: m.scenario.id, title: m.scenario.title || m.scenario.id, kind: m.scenario.kind === 'flow' ? 'flow' : 'task',
38
+ ...(m.project?.slug ? { project: m.project.slug } : {}),
39
+ locales: m.scope.locales, viewports: m.scope.viewports, ...(lanes.length ? { lanes } : {}), ...(m.sessions ? { sessions: m.sessions } : {}),
40
+ nodes, edges: (m.edges || []).map((e) => ({ from: e.from, to: e.to, ...(e.label ? { label: e.label } : {}), ...(e.kind && e.kind !== 'main' ? { kind: e.kind } : {}) }))
41
+ }
42
+ }
43
+
44
+ /** Start page of an exported design: the steps in flow order with each locale's page and screenshot; plain HTML, no script. */
45
+ export function exportIndexHtml ({ index }) {
46
+ const loc0 = index.locales[0]
47
+ const rows = index.steps.map((s) => `<li><h2>${esc(titleOf(s.title, loc0))} <small>${esc(s.id)}</small></h2>${s.purpose ? `<p>${esc(s.purpose)}</p>` : ''}<p>${index.locales.filter((l) => s.pages[l]).map((l) => `<a href="${esc(s.pages[l].split('/').map(enc).join('/'))}">${esc(l)}: ${esc(titleOf(s.title, l))}</a>`).join(' · ') || '<em>no page</em>'}</p>${s.screenshots.length ? `<p class="shots">${s.screenshots.map((x) => `<a href="${esc(x.file)}"><img src="${esc(x.file)}" alt="${esc(`${s.id} ${x.locale} ${x.viewport}`)}" loading="lazy" width="160"></a>`).join('')}</p>` : ''}</li>`).join('\n')
48
+ return `<!doctype html>
49
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${esc(index.scenario.title)} · ${esc(index.ref)}</title>
50
+ <style>:root{color-scheme:light dark;--ink:#151514;--paper:#fff;--line:#ddd}@media (prefers-color-scheme:dark){:root{--ink:#eee;--paper:#151514;--line:#333}}body{margin:0;font:16px/1.5 system-ui,sans-serif;color:var(--ink);background:var(--paper)}main{max-width:960px;margin:0 auto;padding:32px 16px}ol{padding-left:20px}li{border-top:1px solid var(--line);padding:12px 0}h2{font-size:18px;margin:0}small{font-weight:400;opacity:.6}.shots img{border:1px solid var(--line);border-radius:4px;margin:0 8px 8px 0;height:auto}code{font-size:14px}</style></head>
51
+ <body><main><h1>${esc(index.scenario.title)}</h1>
52
+ <p>${esc(index.ref)}${index.version ? ` · v${esc(index.version)}${index.version_name ? ` “${esc(index.version_name)}”` : ''}` : ''} · exported ${esc(index.exported_at.slice(0, 10))} from Kaloko. Everything here is plain HTML, CSS, JS and images; it opens from disk without Kaloko.</p>
53
+ <p>Files: <a href="scenario.yml">scenario.yml</a> (steps, criteria, flow)${index.tokens ? ` · <a href="${TOKENS_FILE}">${TOKENS_FILE}</a> (W3C DTCG design tokens)` : ''} · <a href="kaloko.json">kaloko.json</a> (what is where)</p>
54
+ <ol>
55
+ ${rows}
56
+ </ol></main></body></html>
57
+ `
58
+ }
59
+
60
+ /**
61
+ * A relative path inside the export, or a UserError: names come from the service's responses (file lists, step ids,
62
+ * locales), and none of them may leave the export folder (no "..", no absolute or drive paths, no backslashes).
63
+ */
64
+ export function safeExportPath (p) {
65
+ const s = String(p ?? '')
66
+ const parts = s.split('/')
67
+ if (!s || s.length > 1024 || /[\\\u0000]/.test(s) || /^[a-zA-Z]:/.test(s) || isAbsolute(s) || parts.some((x) => !x || x === '.' || x === '..')) throw new UserError(`Refusing to write ${JSON.stringify(s.slice(0, 120))}: not a plain relative path inside the export.`)
68
+ return s
69
+ }
70
+
71
+ /** A folder or, when `out` ends with .zip, a zip with one top folder; put(path, bytes) then close(). */
72
+ export function exportSink (out) {
73
+ if (!out.endsWith('.zip')) return { put: async (p, bytes) => { const t = join(out, safeExportPath(p)); await mkdir(dirname(t), { recursive: true }); await writeFile(t, bytes) }, close: async () => {} }
74
+ const top = basename(out, '.zip')
75
+ const entries = []
76
+ return {
77
+ put: async (p, bytes) => { entries.push({ name: `${top}/${safeExportPath(p)}`, body: bytes instanceof Uint8Array ? bytes : new TextEncoder().encode(bytes) }) },
78
+ close: async () => { await mkdir(dirname(out), { recursive: true }); await pipeline(Readable.fromWeb(zipStream(entries)), createWriteStream(out)) }
79
+ }
80
+ }
81
+
82
+ /** Run id behind a reference: design (the design reference, per branch), vN, or a run id. */
83
+ async function runIdOf ({ client, org, scenarioId, ref, branch }) {
84
+ if (ref === 'design') {
85
+ const refs = await client.request('GET', `/orgs/${org}/scenarios/${enc(scenarioId)}/references`)
86
+ const id = refs.design_by_branch?.[branch] || refs.design
87
+ if (!id) throw new UserError(`${scenarioId} has no design reference yet; name a version with --version <n> (kaloko history lists them).`)
88
+ return id
89
+ }
90
+ if (/^v\d+$/.test(ref)) {
91
+ const v = (await client.request('GET', `/orgs/${org}/scenarios/${enc(scenarioId)}/versions`)).find((x) => String(x.version) === ref.slice(1))
92
+ if (!v) throw new UserError(`${scenarioId} has no version ${ref}.`)
93
+ return v.id
94
+ }
95
+ return ref
96
+ }
97
+
98
+ /**
99
+ * Exports a design version (or the draft) of a scenario into `out` (folder or .zip). Returns the index (kaloko.json).
100
+ * `ref`: design | vN | draft | a run id.
101
+ */
102
+ export async function exportDesign ({ config, scenarioId, client, ref = 'design', branch = 'main', out, say = () => {} }) {
103
+ const org = config.org
104
+ const draft = ref === 'draft'
105
+ const runId = draft ? null : await runIdOf({ client, org, scenarioId, ref, branch })
106
+ const manifest = draft
107
+ ? await client.request('GET', `/orgs/${org}/scenarios/${enc(scenarioId)}/draft/manifest${branch !== 'main' ? `?branch=${enc(branch)}` : ''}`)
108
+ : await client.request('GET', `/orgs/${org}/runs/${runId}`)
109
+ if (manifest.source !== 'design' && !manifest.draft) throw new UserError(`${runId} is not a design version; kaloko export --run ${runId} exports any run.`)
110
+ const revs = await client.request('GET', `/orgs/${org}/scenarios/${enc(scenarioId)}/revisions?ref=${enc(draft ? (branch === 'main' ? 'draft' : `draft:${branch}`) : runId)}`)
111
+ const sink = exportSink(out)
112
+ const fileOf = async (path) => {
113
+ const res = draft ? await fetch(manifest.files_base + path) : await client.fetch(`/orgs/${org}/runs/${runId}/files/${path.split('/').map(enc).join('/')}`)
114
+ if (!res.ok) throw new UserError(`${path}: HTTP ${res.status}`)
115
+ return Buffer.from(await res.arrayBuffer())
116
+ }
117
+ // the live files keep the design folder layout: links between steps work from disk exactly as in the folder
118
+ const paths = new Map(), clashes = []
119
+ const pages = {}
120
+ for (const s of revs.steps.filter((x) => x.state === 'default')) {
121
+ ;(pages[s.node] ||= {})[s.locale] = s.entry
122
+ for (const [p, hash] of Object.entries(s.files)) {
123
+ safeExportPath(p) // checked before anything is written
124
+ if (paths.has(p)) { if (paths.get(p).hash !== hash) clashes.push(p); continue }
125
+ paths.set(p, { hash, revision: s.revision })
126
+ }
127
+ }
128
+ for (const [p, { revision }] of paths) {
129
+ const res = await client.fetch(`/orgs/${org}/revisions/${revision}/files/${p.split('/').map(enc).join('/')}`)
130
+ let buf = Buffer.from(await res.arrayBuffer())
131
+ if (res.headers.get('X-QAwalk-Encoding') === 'gzip') buf = gunzipSync(buf)
132
+ await sink.put(p, buf)
133
+ }
134
+ const steps = []
135
+ let shots = 0
136
+ for (const n of flowOrder(manifest.nodes)) {
137
+ const screenshots = []
138
+ for (const [locale, vps] of Object.entries(n.captures || {})) for (const [viewport, c] of Object.entries(vps || {})) {
139
+ const src = c?.image || c?.png
140
+ if (!src) continue
141
+ const file = `screenshots/${n.id}/${locale}/${viewport}.${src.split('.').pop()}`
142
+ await sink.put(file, await fileOf(src))
143
+ screenshots.push({ locale, viewport, file, size: c.size || null })
144
+ shots++
145
+ }
146
+ steps.push({ id: n.id, title: n.title, purpose: n.purpose || '', kind: n.kind, pages: pages[n.id] || {}, screenshots })
147
+ }
148
+ const index = {
149
+ schema: DESIGN_EXPORT_SCHEMA, scenario: { id: scenarioId, title: manifest.scenario?.title || scenarioId }, ref, run_id: runId,
150
+ version: manifest.version ?? null, version_name: manifest.version_name ?? null, branch, exported_at: new Date().toISOString(),
151
+ locales: manifest.scope.locales, viewports: manifest.scope.viewports, tokens: paths.has(TOKENS_FILE) ? TOKENS_FILE : null,
152
+ formats: { pages: 'HTML, CSS, JS and assets in the design folder layout (<step>/index.html, <step>/<locale>/index.html, _shared/)', tokens: 'W3C Design Tokens (DTCG) JSON', scenario: 'qawalk.scenario.v1 YAML', screenshots: 'WebP or PNG per step, locale and viewport' },
153
+ steps
154
+ }
155
+ await sink.put('scenario.yml', `# Exported from Kaloko (${ref}); validate with: kaloko validate scenario.yml\n` + yaml.dump(scenarioFromManifest(manifest), { lineWidth: 140, noRefs: true }))
156
+ await sink.put('kaloko.json', JSON.stringify(index, null, 2) + '\n')
157
+ await sink.put('index.html', exportIndexHtml({ index }))
158
+ await sink.close()
159
+ if (clashes.length) say(`! ${clashes.length} file(s) differ between steps (${clashes.slice(0, 3).join(', ')}); the export keeps the first step's copy.`)
160
+ say(`Design ${scenarioId} at ${ref}${index.version ? ` (v${index.version})` : ''} → ${relative(process.cwd(), out) || '.'}\n ${paths.size} live file(s), ${shots} screenshot(s), ${steps.length} step(s); open index.html from disk.`)
161
+ return index
162
+ }
@@ -0,0 +1,189 @@
1
+ // Portable interactive HTML (#42): checks a design folder against what works on the live origin
2
+ // (<revision>.<USERCONTENT_HOST>, sandboxed, CSP default-src 'self'), from disk (file://) and from a zip.
3
+ // Errors are things that certainly break there; warnings make prototypes fragile or captures unstable.
4
+ // The rules are explained in skills/kaloko/reference/portable-html.md. lintFiles is pure (tests, other tools).
5
+ import { readFile, readdir, stat } from 'node:fs/promises'
6
+ import { join, relative, sep } from 'node:path'
7
+ import { BUNDLE_LIMITS, SHARED_DIR, TOKENS_FILE, TEXT_TYPES, contentTypeOf, extOf, resolveRef } from './design-core.mjs'
8
+
9
+ /** Rule id → level and a short reason (the fix is in each finding). */
10
+ export const LINT_RULES = {
11
+ 'external-url': ['error', 'loads a file from another site; the live origin allows only files of the bundle'],
12
+ 'root-relative': ['error', 'a path starting with / points outside the design folder on the live origin and from disk'],
13
+ 'outside-folder': ['error', 'a relative path that climbs out of the design folder'],
14
+ 'missing-file': ['error', 'a referenced file that is not in the folder'],
15
+ 'file-type': ['error', 'a referenced file type the bundle does not allow'],
16
+ 'eval': ['error', "eval and new Function are blocked (no 'unsafe-eval')"],
17
+ 'service-worker': ['error', 'service workers are refused on live steps'],
18
+ 'embedded-frame': ['error', 'iframe, object and embed are blocked (frame-src and object-src none)'],
19
+ 'websocket': ['error', 'network connections are blocked (connect-src self)'],
20
+ 'too-large': ['error', 'over the bundle limits'],
21
+ 'tokens-json': ['error', 'tokens.json is not valid JSON'],
22
+ 'storage': ['warn', 'storage and cookies throw in the sandboxed frame and are not shared between viewers'],
23
+ 'nondeterministic': ['warn', 'random values and the current time change every capture'],
24
+ 'directory-link': ['warn', 'a link to a folder does not open its index.html from disk or a zip'],
25
+ 'broken-link': ['warn', 'a link to a page that is not in the folder'],
26
+ 'new-window': ['warn', 'popups are blocked in the sandbox'],
27
+ 'top-navigation': ['warn', 'the frame may not navigate the canvas'],
28
+ 'modal': ['warn', 'alert, confirm and prompt are blocked in the sandbox'],
29
+ 'form-action': ['warn', 'form submission is blocked (form-action none)'],
30
+ 'doctype': ['warn', 'without <!doctype html> the page renders in quirks mode'],
31
+ 'charset': ['warn', 'without <meta charset> text may decode wrongly from disk'],
32
+ 'viewport-meta': ['warn', 'without the viewport meta, mobile viewports render a zoomed-out desktop page'],
33
+ 'lang': ['warn', '<html lang> tells screen readers and the language checks what language the page is in'],
34
+ 'title': ['warn', 'the page title names the step in tabs, history and screen readers'],
35
+ 'img-alt': ['warn', 'images need an alt text (empty for decoration)'],
36
+ 'reduced-motion': ['warn', 'animations should respect prefers-reduced-motion'],
37
+ 'large-file': ['warn', 'over the size budget'],
38
+ 'tokens-unused': ['warn', 'the token set is there but no CSS custom property uses it'],
39
+ 'missing-step': ['warn', 'a scenario step without a design'],
40
+ 'external-link': ['info', 'a link that leaves the prototype'],
41
+ 'stray-folder': ['info', 'a folder that is not a step of the scenario'],
42
+ 'no-tokens': ['info', 'no token set: the inspector cannot name values']
43
+ }
44
+ export const BUDGET = { file_bytes: 1024 * 1024, step_bytes: 5 * 1024 * 1024 }
45
+
46
+ const lineAt = (text, i) => { let n = 1; for (let k = 0; k < i && k < text.length; k++) if (text.charCodeAt(k) === 10) n++; return n }
47
+ const SCHEME = /^[a-z][a-z0-9+.-]*:/i
48
+ const stripQ = (u) => u.split('#')[0].split('?')[0]
49
+ const kb = (b) => `${Math.round(b / 1024)} kB`
50
+
51
+ /**
52
+ * Lints the files of a design folder. `files`: Map path → { bytes, text? } (text for text types), `steps`: step ids of
53
+ * the scenario (null = every top-level folder but _shared). Returns findings { rule, level, file, line, message, fix }.
54
+ */
55
+ export function lintFiles (files, { steps = null } = {}) {
56
+ const out = []
57
+ const add = (rule, file, line, message, fix = '') => out.push({ rule, level: LINT_RULES[rule][0], file, line: line || null, message, fix })
58
+ const has = (p) => files.has(p)
59
+ const isDir = (p) => [...files.keys()].some((f) => f.startsWith(p.replace(/\/?$/, '/')))
60
+ let anyMotion = null, reducedMotion = false, usesVars = false
61
+ const resource = (file, text, i, raw, { script = false, data = false } = {}) => {
62
+ const u = raw.trim()
63
+ if (!u || u.startsWith('#') || /^(about:blank|blob:)/i.test(u)) return
64
+ const line = lineAt(text, i)
65
+ if (/^data:/i.test(u)) { if (script) add('external-url', file, line, 'a script from a data: URL is blocked (script-src self)', 'Put the script into a .js file of the bundle.'); return }
66
+ if (/^javascript:/i.test(u)) return
67
+ if (/^(https?:)?\/\//i.test(u) || SCHEME.test(u)) return add('external-url', file, line, `loads ${u.slice(0, 120)} from another site; it will not load on the live step (CSP default-src 'self')`, data ? 'Live steps have no network: put the data into a JSON fixture of the bundle and fetch(\'./fixtures/….json\').' : 'Download the file into _shared/ (fonts as woff2) and reference it with a relative path.')
68
+ if (u.startsWith('/')) return add('root-relative', file, line, `${u.slice(0, 120)} starts with /, which is the origin root on the live step and the disk root from file://`, `Use a path relative to ${file} (for example ../${SHARED_DIR}/…).`)
69
+ const p = resolveRef(file, stripQ(u))
70
+ if (!p) return add('outside-folder', file, line, `${u.slice(0, 120)} points outside the design folder`, 'Keep every file the page needs inside the design folder.')
71
+ if (!contentTypeOf(p)) return add('file-type', file, line, `${p}: .${extOf(p) || '?'} files are not part of a bundle`, 'Convert it (fonts to woff2, images to webp/png/svg) or drop it.')
72
+ if (!has(p)) add('missing-file', file, line, `${u.slice(0, 120)} → ${p} is not in the folder`, 'Add the file or fix the path (paths are case-sensitive on the service).')
73
+ }
74
+ const link = (file, text, i, raw, attrs) => {
75
+ const u = raw.trim(); const line = lineAt(text, i)
76
+ const tg = attrs.match(/\starget\s*=\s*["']?_(blank|top|parent)/i)
77
+ if (tg) add('new-window', file, line, `a link with target=_${tg[1]} does nothing in the sandboxed live step`, 'Drop the target attribute; links between steps open in the same frame.')
78
+ if (!u || u.startsWith('#') || /^(mailto|tel|javascript):/i.test(u)) return
79
+ if (/^(https?:)?\/\//i.test(u)) return add('external-link', file, line, `${u.slice(0, 120)} leaves the prototype`, '')
80
+ if (u.startsWith('/')) return add('root-relative', file, line, `link ${u.slice(0, 120)} starts with /: it 404s on the live step and from disk`, 'Link relatively, e.g. ../payment/index.html.')
81
+ const bare = stripQ(u)
82
+ if (!bare) return
83
+ const p = resolveRef(file, bare)
84
+ if (!p) return add('outside-folder', file, line, `link ${u.slice(0, 120)} points outside the design folder`, '')
85
+ if (bare.endsWith('/') || (!extOf(p) && isDir(p))) return add('directory-link', file, line, `link ${u.slice(0, 120)} points at a folder; from disk or a zip the browser shows a listing or nothing`, `Link the page itself: ${bare.replace(/\/?$/, '/')}index.html`)
86
+ if (!has(p)) add('broken-link', file, line, `link ${u.slice(0, 120)} → ${p} is not in the folder`, 'Point it at a step page (../<step>/index.html) or add the page.')
87
+ }
88
+ const css = (file, text, base = 0, whole = text) => {
89
+ for (const m of text.matchAll(/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi)) resource(file, whole, base + m.index, m[2])
90
+ for (const m of text.matchAll(/@import\s+(['"])([^'"]+)\1/gi)) resource(file, whole, base + m.index, m[2])
91
+ if (/var\(--/.test(text)) usesVars = true
92
+ if (/prefers-reduced-motion/.test(text)) reducedMotion = true
93
+ if (!anyMotion) { const m = text.match(/(?:^|[;{\s])(animation(?:-name)?|transition)\s*:\s*(?!none\b)[^;}]*[1-9]/m); if (m) anyMotion = { file, line: lineAt(whole, base + m.index) } }
94
+ }
95
+ const js = (file, text, base = 0, whole = text) => {
96
+ const at = (re, fn) => { for (const m of text.matchAll(re)) fn(m, lineAt(whole, base + m.index)) }
97
+ at(/(?:^|[^.\w$])(?:import|export)\s[^'"]*?from\s*(['"])([^'"]+)\1|import\s*\(\s*(['"])([^'"]+)\3\s*\)/g, (m) => { const u = m[2] || m[4]; if (/^(\.|\/|https?:|\/\/)/.test(u)) resource(file, whole, base + m.index, u, { script: true }) })
98
+ at(/\bfetch\(\s*(['"`])([^'"`$]+)\1/g, (m) => resource(file, whole, base + m.index, m[2], { data: true }))
99
+ at(/\bnew\s+(?:XMLHttpRequest|EventSource|WebSocket)\b/g, (m, l) => add('websocket', file, l, `${m[0].replace(/^new\s+/, '')} needs a network the live step does not have (connect-src self)`, 'Read fixtures from JSON files in the bundle with fetch(\'./fixtures/…json\').'))
100
+ at(/(?:^|[^.\w$])(?:eval\s*\(|new\s+Function\s*\(|set(?:Timeout|Interval)\s*\(\s*['"`])/g, (m, l) => add('eval', file, l, 'code from a string is blocked on the live step (no \'unsafe-eval\')', 'Pass a function instead of a string; do not use eval or new Function.'))
101
+ at(/navigator\.serviceWorker/g, (m, l) => add('service-worker', file, l, 'service workers are refused on live steps and outlive the page', 'Remove the registration; prototypes need no offline cache.'))
102
+ at(/\b(localStorage|sessionStorage|indexedDB|document\.cookie)\b/g, (m, l) => add('storage', file, l, `${m[1]} throws in the sandboxed live step (opaque origin) and is empty for every other viewer`, 'Keep prototype state in the URL (location.hash or ?query) or in memory; wrap any storage access in try/catch.'))
103
+ at(/\b(Date\.now\(\)|new Date\(\s*\)|Math\.random\(\)|crypto\.(?:randomUUID|getRandomValues)\()/g, (m, l) => add('nondeterministic', file, l, `${m[1].replace(/\($/, '()')} makes every capture different, so unchanged steps look changed`, 'Use fixed dates (new Date(\'2026-01-15T10:00\')) and a seeded generator for fake data.'))
104
+ at(/(?:^|[^.\w$]|window\.)(alert|confirm|prompt)\s*\(/g, (m, l) => add('modal', file, l, `${m[1]}() is blocked in the sandbox (no allow-modals)`, 'Show an in-page <dialog> instead.'))
105
+ at(/(?:^|[^.\w$])window\.open\s*\(/g, (m, l) => add('new-window', file, l, 'window.open is blocked in the sandbox (no allow-popups)', 'Navigate in the same frame (location.href = \'../next/index.html\').'))
106
+ at(/\b(?:top|parent)\.location\b/g, (m, l) => add('top-navigation', file, l, `${m[0]} tries to navigate the canvas`, 'Navigate the frame itself, or use data-kaloko-go="<step>".'))
107
+ }
108
+ for (const [file, f] of files) {
109
+ if (f.bytes > BUNDLE_LIMITS.max_file_bytes) add('too-large', file, null, `${kb(f.bytes)}: over the ${Math.round(BUNDLE_LIMITS.max_file_bytes / 1048576)} MB limit of one file`, 'Compress it or split it.')
110
+ else if (f.bytes > BUDGET.file_bytes) add('large-file', file, null, `${kb(f.bytes)}: over the ${kb(BUDGET.file_bytes)} budget of one file`, /\.(png|jpe?g|gif)$/i.test(file) ? 'Use webp or avif at the size it is shown.' : /\.(woff2?)$/i.test(file) ? 'Subset the font to the characters you use.' : 'Keep prototypes light: every viewer loads it.')
111
+ if (f.text == null) continue
112
+ const t = f.text, ext = extOf(file)
113
+ if (ext === 'css') css(file, t)
114
+ else if (ext === 'js' || ext === 'mjs') js(file, t)
115
+ else if (ext === 'html' || ext === 'svg') {
116
+ for (const m of t.matchAll(/\s(src|poster|data-src)\s*=\s*(['"])([^'"]*)\2/gi)) resource(file, t, m.index, m[3], { script: /<script\b[^>]*$/i.test(t.slice(Math.max(0, m.index - 200), m.index)) })
117
+ for (const m of t.matchAll(/<link\b([^>]*)>/gi)) { const h = m[1].match(/\shref\s*=\s*(['"])([^'"]*)\1/i); const rel = (m[1].match(/\srel\s*=\s*(['"]?)([^'"\s>]+)\1/i)?.[2] || '').toLowerCase(); if (h && !/^(canonical|alternate|author|help|license|next|prev|search|dns-prefetch|preconnect)$/.test(rel)) resource(file, t, m.index, h[2]) }
118
+ for (const m of t.matchAll(/<(?:use|image|feImage)\b[^>]*\s(?:xlink:)?href\s*=\s*(['"])([^'"]*)\1/gi)) resource(file, t, m.index, m[2])
119
+ for (const m of t.matchAll(/\ssrcset\s*=\s*(['"])([^'"]*)\1/gi)) for (const part of m[2].split(',')) resource(file, t, m.index, part.trim().split(/\s+/)[0])
120
+ for (const m of t.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi)) css(file, m[1], m.index + m[0].indexOf(m[1]), t)
121
+ for (const m of t.matchAll(/\sstyle\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)) css(file, m[1] ?? m[2], m.index, t)
122
+ for (const m of t.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)) if (!/\ssrc\s*=/.test(m[1]) && !/type\s*=\s*["']?(application\/(ld\+)?json|importmap|text\/template)/i.test(m[1])) js(file, m[2], m.index + m[0].indexOf(m[2]), t)
123
+ if (ext === 'svg') continue
124
+ for (const m of t.matchAll(/<(a|area)\b([^>]*)>/gi)) { const h = m[2].match(/\shref\s*=\s*(['"])([^'"]*)\1/i); if (h) link(file, t, m.index, h[2], m[2]) }
125
+ for (const m of t.matchAll(/<(iframe|object|embed|frame)\b/gi)) add('embedded-frame', file, lineAt(t, m.index), `<${m[1].toLowerCase()}> is blocked on the live step`, 'Draw the embedded content into the page (an image or markup of it).')
126
+ for (const m of t.matchAll(/<form\b[^>]*\saction\s*=\s*(['"])([^'"]*)\1/gi)) if (m[2] && m[2] !== '#') add('form-action', file, lineAt(t, m.index), `submitting to ${m[2].slice(0, 80)} is blocked on the live step`, 'Handle submit in a script (event.preventDefault()) and move on with a link or data-kaloko-go.')
127
+ for (const m of t.matchAll(/<img\b([^>]*)>/gi)) if (!/\salt\s*=/i.test(m[1])) add('img-alt', file, lineAt(t, m.index), 'an <img> without alt', 'Add alt="…" (alt="" for decoration).')
128
+ if (!/^\s*(<!--[\s\S]*?-->\s*)*<!doctype html/i.test(t)) add('doctype', file, 1, 'no <!doctype html>', 'Start the page with <!doctype html>.')
129
+ if (!/<meta\s+charset\s*=/i.test(t) && !/<meta[^>]+content\s*=\s*["'][^"']*charset=/i.test(t)) add('charset', file, null, 'no <meta charset>', 'Add <meta charset="utf-8"> first in <head>.')
130
+ if (!/<meta[^>]+name\s*=\s*["']?viewport/i.test(t)) add('viewport-meta', file, null, 'no viewport meta', 'Add <meta name="viewport" content="width=device-width, initial-scale=1">.')
131
+ if (!/<html\b[^>]*\slang\s*=\s*["']?[a-z]/i.test(t)) add('lang', file, null, '<html> has no lang', 'Add <html lang="en"> (the locale of the page).')
132
+ if (!/<title>\s*[^<\s]/i.test(t)) add('title', file, null, 'no <title>', 'Name the step in <title>.')
133
+ }
134
+ }
135
+ if (anyMotion && !reducedMotion) add('reduced-motion', anyMotion.file, anyMotion.line, 'animations or transitions without a prefers-reduced-motion rule anywhere in the folder', 'Add @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important } } to the shared CSS.')
136
+ if (files.has(TOKENS_FILE)) {
137
+ try { JSON.parse(files.get(TOKENS_FILE).text) } catch (e) { add('tokens-json', TOKENS_FILE, null, e.message, 'Fix the JSON (kaloko tokens validate).') }
138
+ if (!usesVars) add('tokens-unused', TOKENS_FILE, null, 'no var(--…) in any stylesheet', 'Write token values as CSS custom properties (--color-accent) and use var(--color-accent), so the inspector and kaloko tokens check see them.')
139
+ } else add('no-tokens', '.', null, 'no tokens.json in the folder', 'Add a W3C DTCG token set (kaloko tokens pull, or write tokens.json).')
140
+ // per step: everything under its folder plus the shared files (an upper bound of what a revision carries)
141
+ const tops = [...new Set([...files.keys()].filter((p) => p.includes('/')).map((p) => p.split('/')[0]))].filter((d) => d !== SHARED_DIR)
142
+ const shared = [...files].filter(([p]) => p.startsWith(`${SHARED_DIR}/`)).reduce((s, [, f]) => s + f.bytes, 0)
143
+ for (const d of tops) {
144
+ const own = [...files].filter(([p]) => p.startsWith(`${d}/`)).reduce((s, [, f]) => s + f.bytes, 0)
145
+ if (own + shared > BUNDLE_LIMITS.max_revision_bytes) add('too-large', `${d}/`, null, `${kb(own + shared)} with the shared files: over the ${Math.round(BUNDLE_LIMITS.max_revision_bytes / 1048576)} MB limit of a step`, 'Move big media out or compress it.')
146
+ else if (own + shared > BUDGET.step_bytes) add('large-file', `${d}/`, null, `${kb(own + shared)} with the shared files: over the ${kb(BUDGET.step_bytes)} budget of a step`, 'Compress images and fonts; drop unused files from _shared/.')
147
+ if (steps && !steps.includes(d)) add('stray-folder', `${d}/`, null, `${d}/ is not a step of the scenario`, 'Rename it to a step id, or move shared files into _shared/.')
148
+ }
149
+ if (steps) for (const s of steps) if (!has(`${s}/index.html`) && ![...files.keys()].some((p) => p.startsWith(`${s}/`) && p.endsWith('/index.html'))) add('missing-step', `${s}/`, null, `step ${s} has no ${s}/index.html`, `Add ${s}/index.html.`)
150
+ const order = { error: 0, warn: 1, info: 2 }
151
+ return out.sort((a, b) => order[a.level] - order[b.level] || a.file.localeCompare(b.file) || (a.line || 0) - (b.line || 0))
152
+ }
153
+
154
+ /** Reads a design folder (hidden files and .qawalk/ left out) into the shape lintFiles takes. */
155
+ export async function readDesignFolder (dir) {
156
+ const files = new Map()
157
+ const walk = async (d) => {
158
+ let entries = []
159
+ try { entries = await readdir(d, { withFileTypes: true }) } catch { return }
160
+ for (const e of entries) {
161
+ if (e.name.startsWith('.') || e.name === 'node_modules') continue
162
+ const full = join(d, e.name)
163
+ if (e.isDirectory()) { await walk(full); continue }
164
+ const path = relative(dir, full).split(sep).join('/')
165
+ const bytes = (await stat(full)).size
166
+ files.set(path, { bytes, ...(TEXT_TYPES.has(extOf(path)) && bytes <= BUNDLE_LIMITS.max_file_bytes ? { text: await readFile(full, 'utf8') } : {}) })
167
+ }
168
+ }
169
+ await walk(dir)
170
+ return files
171
+ }
172
+
173
+ /** Lints a design folder; `scenario` (optional) names the steps. */
174
+ export async function lintDesign ({ dir, scenario = null }) {
175
+ const files = await readDesignFolder(dir)
176
+ const steps = scenario ? scenario.nodes.filter((n) => n.kind !== 'decision' && n.kind !== 'external').map((n) => n.id) : null
177
+ const findings = lintFiles(files, { steps })
178
+ return { dir, files: files.size, findings, errors: findings.filter((f) => f.level === 'error').length, warnings: findings.filter((f) => f.level === 'warn').length }
179
+ }
180
+
181
+ /** Human report: errors and warnings (info with `all`), grouped like a compiler, at most `max` lines. */
182
+ export function formatLint (report, { all = false, max = 40, cwd = process.cwd() } = {}) {
183
+ const shown = report.findings.filter((f) => all || f.level !== 'info')
184
+ const where = relative(cwd, report.dir) || '.'
185
+ if (!shown.length) return `✓ ${where}: ${report.files} file(s) follow the portable HTML rules.`
186
+ const mark = { error: '✕', warn: '!', info: '·' }
187
+ const lines = shown.slice(0, max).map((f) => ` ${mark[f.level]} ${f.file}${f.line ? `:${f.line}` : ''} ${f.message} [${f.rule}]${f.fix ? `\n fix: ${f.fix}` : ''}`)
188
+ return `${where}: ${report.errors} error(s), ${report.warnings} warning(s)\n${lines.join('\n')}${shown.length > max ? `\n … ${shown.length - max} more (kaloko design lint --json lists them all)` : ''}`
189
+ }
@@ -115,6 +115,13 @@ export async function copyLive (runDir, dir, rev) {
115
115
  await mkdir(dirname(to), { recursive: true })
116
116
  await copyFile(join(dir, path), to)
117
117
  }
118
+ // and one merged tree of the run (live/site/<path>, the design folder layout): plain links between steps work from
119
+ // disk there, so the local preview and an offline export present the prototype like the live origin does
120
+ for (const path of Object.keys(rev.files)) {
121
+ const to = join(runDir, 'live', 'site', path)
122
+ await mkdir(dirname(to), { recursive: true })
123
+ await copyFile(join(dir, path), to)
124
+ }
118
125
  }
119
126
 
120
127
  // ---- render cache: a capture is reused when the same revision was rendered in the same viewport ----
@@ -23,7 +23,7 @@ export function combine ({ node, capture, jev, thresholds }) {
23
23
  if (c.check.type === 'deterministic') {
24
24
  const r = capture.checks?.[c.id]
25
25
  const verdict = r == null || r.ok == null ? 'not_evaluated' : r.ok ? 'pass' : 'fail'
26
- const live = c.check.assert === 'no_console_errors' || c.check.assert === 'no_overflow' || c.check.assert === 'http_status' || /^perf_/.test(c.check.assert || '')
26
+ const live = c.check.assert === 'no_console_errors' || c.check.assert === 'no_overflow' || c.check.assert === 'http_status' || c.check.assert === 'links_ok' || c.check.assert === 'hreflang_pairs' || /^perf_/.test(c.check.assert || '')
27
27
  return { id: c.id, type: 'deterministic', verdict, evidence: r?.evidence ?? null, ...(capture.rechecked_at && !live ? { rechecked: true } : {}) }
28
28
  }
29
29
  if (c.check.type === 'semantic' && c.check.about === 'console' && !(capture.console_errors || []).length && capture.checks) {
@@ -0,0 +1,149 @@
1
+ // Checks that need the network, evaluated next to the capture (not inside the page):
2
+ // links_ok every same-origin link on the page answers below 400 (after redirects)
3
+ // hreflang_pairs the page's hreflang alternates name the page itself for its own language, and every other
4
+ // language version answers and links back (reciprocal pairs, as search engines require)
5
+ // Requests are GETs (pages often do not implement HEAD) from a separate request context that starts with a copy of
6
+ // the page's cookies and the environment's gate, and never writes back: following a link (or fetching /?lang=en for
7
+ // hreflang) must not change the language, session or anything else the walk sees next. Links that look like they act
8
+ // (log out, unsubscribe, delete, tokens, switching organization) are never followed, and links_ok follows links only
9
+ // on readonly environments or scenarios unless the check opts in (follow: true). Results are cached per environment,
10
+ // locale and sign-in state for the walk, so shared navigation is fetched once.
11
+
12
+ const DEFAULT_MAX_LINKS = 100
13
+ /** Links that may act when followed with a session: never fetched by the checks. */
14
+ export const SKIP_LINK = /(?:^|[/?&=_.-])(?:token|unsubscribe|delete|remove|log-?out|sign-?out|switch)/i
15
+ const actionLink = (u) => SKIP_LINK.test(u.pathname + u.search)
16
+
17
+ /** Comparable form of a URL: origin, path without a trailing slash (except the root), sorted query, no hash. */
18
+ export function normUrl (href, base) {
19
+ try {
20
+ const u = new URL(href, base)
21
+ u.hash = ''
22
+ const params = [...u.searchParams.entries()].sort(([a, x], [b, y]) => (a === b ? (x < y ? -1 : 1) : a < b ? -1 : 1))
23
+ const path = u.pathname.length > 1 ? u.pathname.replace(/\/+$/, '') : '/'
24
+ return `${u.origin}${path}${params.length ? '?' + new URLSearchParams(params).toString() : ''}`
25
+ } catch { return null }
26
+ }
27
+
28
+ /** `<link rel="alternate" hreflang href>` entries of an HTML document (attributes in any order). */
29
+ export function parseAlternates (html, base) {
30
+ const out = []
31
+ for (const tag of String(html || '').match(/<link\b[^>]*>/gi) || []) {
32
+ const attr = (name) => tag.match(new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i'))?.slice(1).find((v) => v != null)
33
+ if (!/(^|\s)alternate(\s|$)/i.test(attr('rel') || '')) continue
34
+ const hreflang = attr('hreflang'); const href = attr('href')
35
+ if (hreflang && href) out.push({ hreflang: hreflang.toLowerCase(), href: new URL(href.replace(/&amp;/g, '&'), base).href })
36
+ }
37
+ return out
38
+ }
39
+
40
+ const short = (u, origin) => { try { const x = new URL(u); return x.origin === origin ? x.pathname + x.search : x.href } catch { return u } }
41
+
42
+ /**
43
+ * links_ok: { links: [href], pageUrl, get(url) → { status, url, error? }, max?, exclude? } → { ok, evidence }.
44
+ * Only same-origin http(s) links count; fragments and the page itself are skipped.
45
+ */
46
+ export async function checkLinks ({ links, pageUrl, get, max = DEFAULT_MAX_LINKS, exclude = null }) {
47
+ const origin = new URL(pageUrl).origin
48
+ const self = normUrl(pageUrl)
49
+ const skip = exclude ? new RegExp(exclude) : null
50
+ const targets = [...new Set(links.map((h) => { try { const u = new URL(h, pageUrl); u.hash = ''; return u.href } catch { return null } }).filter((h) => h && /^https?:/.test(h)))]
51
+ .filter((h) => new URL(h).origin === origin && normUrl(h) !== self && !actionLink(new URL(h)) && !(skip && skip.test(short(h, origin))))
52
+ const checked = targets.slice(0, max)
53
+ const broken = []; const redirected = []
54
+ for (const url of checked) {
55
+ const r = await get(url)
56
+ if (r.error || !(r.status < 400)) broken.push(`${short(url, origin)} → ${r.error || `HTTP ${r.status}`}`)
57
+ else if (r.url && normUrl(r.url) !== normUrl(url)) redirected.push(short(url, origin))
58
+ }
59
+ if (!checked.length) return { ok: true, evidence: 'no internal links on the page' }
60
+ const more = targets.length > checked.length ? ` (first ${checked.length} of ${targets.length})` : ''
61
+ const note = redirected.length ? `; ${redirected.length} redirect: ${redirected.slice(0, 3).join(', ')}` : ''
62
+ return broken.length
63
+ ? { ok: false, evidence: `${broken.length} of ${checked.length} internal links broken${more}: ${broken.slice(0, 5).join('; ')}${note}` }
64
+ : { ok: true, evidence: `${checked.length} internal links answer below 400${more}${note}` }
65
+ }
66
+
67
+ /**
68
+ * hreflang_pairs: { alternates: [{ hreflang, href }], pageUrl, lang, get(url) → { status, url, html?, error? }, min? }.
69
+ * Fails when there are fewer than `min` languages, when the page's own language does not point to the page, or
70
+ * when another language version does not answer 200 or does not link back to the page.
71
+ */
72
+ export async function checkHreflang ({ alternates, pageUrl, lang, get, min = 2 }) {
73
+ const problems = []
74
+ const langs = alternates.filter((a) => a.hreflang !== 'x-default')
75
+ if (new Set(langs.map((a) => a.hreflang)).size < min) problems.push(`${new Set(langs.map((a) => a.hreflang)).size} hreflang language(s), expected ${min}`)
76
+ const self = normUrl(pageUrl)
77
+ const primary = String(lang || '').toLowerCase().split('-')[0]
78
+ const own = langs.find((a) => a.hreflang === String(lang || '').toLowerCase()) || langs.find((a) => a.hreflang.split('-')[0] === primary)
79
+ if (!primary) problems.push('html lang is not set, so the page\'s own hreflang cannot be identified')
80
+ else if (!own) problems.push(`no hreflang for the page's own language "${lang}"`)
81
+ else if (normUrl(own.href) !== self) problems.push(`hreflang ${own.hreflang} points to ${own.href}, not to this page (${pageUrl})`)
82
+ for (const alt of langs) {
83
+ if (alt === own) continue
84
+ if (actionLink(new URL(alt.href))) { problems.push(`${alt.hreflang} version ${alt.href} looks like an action link; not fetched`); continue }
85
+ const r = await get(alt.href, { html: true })
86
+ if (r.error || r.status !== 200) { problems.push(`${alt.hreflang} version ${alt.href} → ${r.error || `HTTP ${r.status}`}`); continue }
87
+ if (r.url && normUrl(r.url) !== normUrl(alt.href)) problems.push(`${alt.hreflang} version ${alt.href} redirects to ${r.url}`)
88
+ const back = parseAlternates(r.html, r.url || alt.href)
89
+ const mine = own ? own.hreflang : primary
90
+ const ret = back.find((b) => b.hreflang === mine) || back.find((b) => b.hreflang.split('-')[0] === primary)
91
+ if (!ret) problems.push(`${alt.hreflang} version does not link back with hreflang ${mine}`)
92
+ else if (normUrl(ret.href) !== self) problems.push(`${alt.hreflang} version links back to ${ret.href}, not to this page`)
93
+ }
94
+ return problems.length
95
+ ? { ok: false, evidence: problems.slice(0, 5).join('; ') }
96
+ : { ok: true, evidence: `${langs.map((a) => a.hreflang).join(', ')}: reciprocal, the own language points to this page` }
97
+ }
98
+
99
+ /**
100
+ * The environment's gate for the checks' own requests (set once per walk by the CLI): headers for the gate's origins
101
+ * only (third parties never see them), HTTP Basic credentials and client certificates.
102
+ */
103
+ const GATE = { headers: {}, toGate: () => false, httpCredentials: null, clientCertificates: [] }
104
+ export function configureGate ({ headers = {}, toGate = () => false, httpCredentials = null, clientCertificates = [] } = {}) {
105
+ Object.assign(GATE, { headers, toGate, httpCredentials, clientCertificates })
106
+ }
107
+
108
+ /**
109
+ * A request context of its own for the checks: a copy of the browser context's cookies and storage (so pages behind
110
+ * a sign-in answer as they do for the walker) that never writes back to the browser context.
111
+ * `factory` is Playwright's `request` (injectable for tests).
112
+ */
113
+ export async function isolatedRequest (context, { factory = null } = {}) {
114
+ const storageState = await context.storageState()
115
+ const pw = factory || (await import('playwright')).request
116
+ return pw.newContext({
117
+ storageState,
118
+ ...(GATE.httpCredentials ? { httpCredentials: GATE.httpCredentials } : {}),
119
+ ...(GATE.clientCertificates?.length ? { clientCertificates: GATE.clientCertificates } : {})
120
+ })
121
+ }
122
+
123
+ /** Cache scope of a page: environment, locale and sign-in state (a digest of the cookies, never the cookies). */
124
+ export function cacheScope ({ environment = '', locale = '', cookies = [] } = {}) {
125
+ const jar = cookies.map((c) => `${c.domain}|${c.path}|${c.name}=${c.value}`).sort().join(';')
126
+ let h = 0
127
+ for (let i = 0; i < jar.length; i++) h = (Math.imul(h, 31) + jar.charCodeAt(i)) | 0
128
+ return `${environment}|${locale}|${jar ? (h >>> 0).toString(36) : 'anonymous'}`
129
+ }
130
+
131
+ /** Cached GETs through a Playwright APIRequestContext (an isolated one, see isolatedRequest). */
132
+ export function requester (request, cache = new Map(), scope = '') {
133
+ return async (url, { html = false } = {}) => {
134
+ const key = `${scope} ${html ? 'html' : 'status'} ${url}`
135
+ if (cache.has(key)) return cache.get(key)
136
+ const job = (async () => {
137
+ try {
138
+ const res = await request.get(url, { maxRedirects: 5, timeout: 15000, failOnStatusCode: false, ...(GATE.toGate(url) ? { headers: GATE.headers } : {}) })
139
+ return { status: res.status(), url: res.url(), ...(html ? { html: (await res.text()).slice(0, 500000) } : {}) }
140
+ } catch (error) { return { status: 0, error: String(error.message || error).split('\n')[0].slice(0, 120) } }
141
+ })()
142
+ cache.set(key, job)
143
+ return job
144
+ }
145
+ }
146
+
147
+ /** One cache per walk process: navigation links shared by hundreds of stack pages are fetched once. */
148
+ export const walkCache = new Map()
149
+ export const NETWORK_ASSERTS = new Set(['links_ok', 'hreflang_pairs'])
@@ -13,7 +13,7 @@ import { contextOptions, prepareContext } from './access.mjs'
13
13
  import { launchEphemeral } from './browser.mjs'
14
14
  import { treeChecks } from './apps/tree.mjs'
15
15
 
16
- export const LIVE_ONLY = (assert) => assert === 'no_console_errors' || assert === 'no_overflow' || assert === 'http_status' || /^perf_/.test(assert)
16
+ export const LIVE_ONLY = (assert) => assert === 'no_console_errors' || assert === 'no_overflow' || assert === 'http_status' || assert === 'links_ok' || assert === 'hreflang_pairs' || /^perf_/.test(assert)
17
17
 
18
18
  const withBase = (html, url) => {
19
19
  if (!url || /<base\s/i.test(html)) return html
package/src/lib/run.mjs CHANGED
@@ -36,7 +36,7 @@ export async function createRun ({ output, scenario, scenarioFile, env, scope, g
36
36
  id,
37
37
  short,
38
38
  scenario_file: relative(root, scenarioFile),
39
- scenario: { id: scenario.id, title: scenario.title, kind: scenario.kind || 'task', version: scenario.version ?? null, source: scenario.source ?? null, ...(scenario.archived ? { archived: true } : {}), ...(scenario.platform && scenario.platform !== 'web' ? { platform: scenario.platform } : {}) },
39
+ scenario: { id: scenario.id, title: scenario.title, kind: scenario.kind || 'task', version: scenario.version ?? null, source: scenario.source ?? null, ...(scenario.archived ? { archived: true } : {}), ...(scenario.readonly ? { readonly: true } : {}), ...(scenario.platform && scenario.platform !== 'web' ? { platform: scenario.platform } : {}) },
40
40
  environment: { name: env.name, base_url: env.base_url, readonly: env.readonly, ...(env.kind === 'design' ? { kind: 'design' } : {}) },
41
41
  scope,
42
42
  git,
@@ -0,0 +1,58 @@
1
+ // Streaming ZIP writer (method "store", data descriptors) for run and design exports, shared by the service and the CLI.
2
+ // Files are appended as they are read from R2; nothing is buffered beyond one chunk.
3
+
4
+ const CRC_TABLE = (() => { const t = new Uint32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0 } return t })()
5
+ export function crc32 (crc, bytes) { crc = ~crc; for (let i = 0; i < bytes.length; i++) crc = CRC_TABLE[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8); return ~crc >>> 0 }
6
+
7
+ const enc = new TextEncoder()
8
+ const u16 = (v, out, o) => { out[o] = v & 0xff; out[o + 1] = (v >>> 8) & 0xff }
9
+ const u32 = (v, out, o) => { out[o] = v & 0xff; out[o + 1] = (v >>> 8) & 0xff; out[o + 2] = (v >>> 16) & 0xff; out[o + 3] = (v >>> 24) & 0xff }
10
+ const dosTime = (d) => ({ time: (d.getUTCHours() << 11) | (d.getUTCMinutes() << 5) | (d.getUTCSeconds() >> 1), date: ((d.getUTCFullYear() - 1980) << 9) | ((d.getUTCMonth() + 1) << 5) | d.getUTCDate() })
11
+
12
+ /**
13
+ * @param {Array<{ name: string, body: ReadableStream|Uint8Array|string, modified?: Date }>} entries lazily opened: `body` may be a function returning a promise of the stream
14
+ * @returns {ReadableStream<Uint8Array>}
15
+ */
16
+ export function zipStream (entries) {
17
+ const central = []
18
+ let offset = 0
19
+ let i = 0
20
+ const now = new Date()
21
+ return new ReadableStream({
22
+ async pull (controller) {
23
+ const push = (bytes) => { controller.enqueue(bytes); offset += bytes.length }
24
+ if (i < entries.length) {
25
+ const e = entries[i++]
26
+ const name = enc.encode(e.name)
27
+ const { time, date } = dosTime(e.modified || now)
28
+ const local = new Uint8Array(30 + name.length)
29
+ u32(0x04034b50, local, 0); u16(20, local, 4); u16(0x0808, local, 6) /* bit 3: sizes in the descriptor, bit 11: UTF-8 */; u16(0, local, 8); u16(time, local, 10); u16(date, local, 12)
30
+ u32(0, local, 14); u32(0, local, 18); u32(0, local, 22); u16(name.length, local, 26); u16(0, local, 28); local.set(name, 30)
31
+ const start = offset
32
+ push(local)
33
+ let body = typeof e.body === 'function' ? await e.body() : e.body
34
+ let crc = 0, size = 0
35
+ if (typeof body === 'string') body = enc.encode(body)
36
+ if (body instanceof Uint8Array) { crc = crc32(crc, body); size = body.length; push(body) } else if (body) {
37
+ const reader = body.getReader()
38
+ for (;;) { const { done, value } = await reader.read(); if (done) break; crc = crc32(crc, value); size += value.length; push(value) }
39
+ }
40
+ const desc = new Uint8Array(16); u32(0x08074b50, desc, 0); u32(crc, desc, 4); u32(size, desc, 8); u32(size, desc, 12); push(desc)
41
+ central.push({ name, crc, size, time, date, start })
42
+ return
43
+ }
44
+ // central directory
45
+ const cdStart = offset
46
+ for (const c of central) {
47
+ const h = new Uint8Array(46 + c.name.length)
48
+ u32(0x02014b50, h, 0); u16(20, h, 4); u16(20, h, 6); u16(0x0808, h, 8); u16(0, h, 10); u16(c.time, h, 12); u16(c.date, h, 14)
49
+ u32(c.crc, h, 16); u32(c.size, h, 20); u32(c.size, h, 24); u16(c.name.length, h, 28); u16(0, h, 30); u16(0, h, 32); u16(0, h, 34); u16(0, h, 36); u32(0, h, 38); u32(c.start, h, 42); h.set(c.name, 46)
50
+ push(h)
51
+ }
52
+ const end = new Uint8Array(22)
53
+ u32(0x06054b50, end, 0); u16(0, end, 4); u16(0, end, 6); u16(central.length, end, 8); u16(central.length, end, 10); u32(offset - cdStart, end, 12); u32(cdStart, end, 16); u16(0, end, 20)
54
+ push(end)
55
+ controller.close()
56
+ }
57
+ })
58
+ }