diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 47cb4c5..0557f1a 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -25,12 +25,12 @@ body: placeholder: Firefox 156 on Windows 11, Safari on iOS 18.7, Chrome 139 on Android 15 validations: required: true - - type: textarea - id: selftest + - type: input + id: version attributes: - label: Self-test - description: Open https://sqzer.dev/selftest/ in the same browser and paste what it prints. It names the package version and what the browser can decode. - render: text + label: Version of `sqzer` + description: The number in the page's footer, after "`sqzer` on npm". + placeholder: 0.3.0 validations: required: true - type: textarea diff --git a/.github/check.mjs b/.github/check.mjs deleted file mode 100644 index 4912472..0000000 --- a/.github/check.mjs +++ /dev/null @@ -1,91 +0,0 @@ -// The page in a headless browser, against the version of `sqzer` that -// `site/package.json` names. Run by `.github/workflows/check.yml` on every -// pull request, the Dependabot bumps included, with `site/` served at `URL`. -// -// npm install --no-save --no-package-lock playwright@1.63.0 -// npx playwright install --with-deps chromium -// python3 -m http.server -d site 8000 & -// node .github/check.mjs -import { chromium } from "playwright"; - -const url = process.env.URL ?? "http://localhost:8000/"; -const problems = []; - -// The server was started a moment ago and may not be listening yet. -for (let attempt = 1; ; attempt++) { - try { - const response = await fetch(url); - if (response.ok) break; - throw new Error(`HTTP ${response.status}`); - } catch (e) { - if (attempt === 20) throw new Error(`${url} is not serving: ${e.message}`); - await new Promise((done) => setTimeout(done, 500)); - } -} - -const browser = await chromium.launch(); -const page = await browser.newPage(); -page.on("pageerror", (e) => problems.push(`page error: ${e.message}`)); -page.on("console", (m) => { - if (m.type() === "error") problems.push(`console: ${m.text()}`); -}); -const status = () => page.textContent("#status"); - -try { - // 1. the self-test: `decodeAny` on the page and in a worker - await page.goto(`${url}selftest/`); - await page.waitForFunction(() => document.getElementById("out").textContent.endsWith("done\n"), null, { timeout: 120_000 }); - const out = await page.textContent("#out"); - console.log(out); - problems.push(...out.split("\n").filter((line) => line.startsWith("FAIL"))); - - // 2. the page: a JPEG in, an image out, with the defaults - const settled = (done) => - page.waitForFunction((done) => { - const s = document.getElementById("status"); - return done.test(s.textContent) || s.classList.contains("failed"); - }, done, { timeout: 120_000 }); - await page.goto(url); - await settled(/^Ready\./); - if (await status() === "Ready.") { - await page.setInputFiles("#file", new URL("../site/selftest/fixtures/pattern-rgb.jpg", import.meta.url).pathname); - await settled(/^Done/); - } - const summary = await page.textContent("#summary"); - console.log(`${await status()}\n${summary}`); - if (!/^Done/.test(await status())) { - problems.push(`the page: ${await status()}`); - } else { - // the download is a picture of the fixture's size, not just a link. The - // page's CSP lets an load a blob and nothing else read one, so the - // byte count comes from the result block, as the command line prints it - const made = await page.evaluate(async () => { - const a = document.getElementById("download"); - if (a.hidden || !a.href) return "no download"; - const img = new Image(); - img.src = a.href; - try { - await img.decode(); - return { name: a.download, width: img.naturalWidth, height: img.naturalHeight }; - } catch (e) { - return `${a.download} is no image this browser decodes: ${e.message}`; - } - }); - const bytes = summary.match(/-> (\d+(?:\.\d+)? [KM]?B) /)?.[1]; - console.log({ ...(typeof made === "string" ? { made } : made), bytes }); - if (typeof made === "string") problems.push(`the page: ${made}`); - else if (made.width !== 48 || made.height !== 32) problems.push(`the page: the output is ${made.width}x${made.height}, the fixture is 48x32`); - if (!bytes || bytes === "0 B") problems.push(`the page: the output has ${bytes ?? "no"} bytes`); - } -} catch (e) { - // what the browser said before the wait ran out is the diagnosis - problems.push(`the check did not finish: ${e.message.split("\n")[0]}`); -} finally { - await browser.close(); -} - -if (problems.length) { - console.error(`\n${problems.join("\n")}`); - process.exit(1); -} -console.log("\nok"); diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d2eaaef..468be1c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,8 +1,12 @@ version: 2 updates: - # The version of `sqzer` the page imports, which `site/worker.js` reads - # from `site/package.json`. Merging the pull request is the whole bump. + # `sqzer` comes alone: a release of the package is one pull request with + # nothing else in it, and merging it is the whole bump. The build and test + # tooling comes grouped. - package-ecosystem: npm - directory: /site + directory: / schedule: interval: daily + groups: + tooling: + dependency-type: development diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index ba6439d..8d0422b 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -6,15 +6,15 @@ ## Browsers tried - + ## New dependencies - + ## Checklist -- [ ] `.github/check.mjs` passes locally, or CI shows it green -- [ ] a path that depends on the browser got a line in `/selftest/`, if one changed +- [ ] `pnpm check` passes locally, or CI shows it green +- [ ] a path that depends on the browser got a Vitest test next to it, if one changed - [ ] a new ADR has its row in `docs/adr/README.md` and its entry in `.greptile/files.json`, if there is one - [ ] the `Content-Security-Policy` is unchanged, or the PR says why it had to change diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 01a87fd..44ce6bd 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -1,7 +1,7 @@ name: Check -# The page in headless Chromium against the version of `sqzer` that -# `site/package.json` names, so a Dependabot bump is tested before it merges. +# The checks of ADR-0002 D6, against the version of `sqzer` that `package.json` +# names, so a Dependabot bump is tested before it merges. on: pull_request: push: @@ -17,7 +17,19 @@ jobs: timeout-minutes: 15 steps: - uses: actions/checkout@v7 - - run: npm install --no-save --no-package-lock playwright@1.63.0 - - run: npx playwright install --with-deps chromium - - run: python3 -m http.server -d site 8000 & - - run: node .github/check.mjs + # the version of pnpm is the `packageManager` of `package.json` + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm typecheck + - run: pnpm lint + - run: pnpm format:check + - run: pnpm fsd + - run: pnpm exec playwright install --with-deps chromium firefox + # units, components and `decodeAny`, in Chromium and Firefox + - run: pnpm test + # the built page, served by `vite preview`, in Chromium and Firefox + - run: pnpm test:e2e diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 02ad25e..1ba8ed0 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -1,7 +1,7 @@ name: Pages # Every merge to `main` is a deploy: the page never waits for a release of -# the crates. `site/` is published as it is, there is no build step. +# the crates. The page is built by Vite and `dist/` is published. on: push: branches: [main] @@ -26,8 +26,16 @@ jobs: url: ${{ steps.deploy.outputs.page_url }} steps: - uses: actions/checkout@v7 + # the version of pnpm is the `packageManager` of `package.json` + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm build - uses: actions/upload-pages-artifact@v5 with: - path: site + path: dist - id: deploy uses: actions/deploy-pages@v5 diff --git a/.gitignore b/.gitignore index 646ac51..123892c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ .DS_Store node_modules/ +dist/ +test-results/ +playwright-report/ diff --git a/.greptile/config.json b/.greptile/config.json index 33b2ab3..35c629c 100644 --- a/.greptile/config.json +++ b/.greptile/config.json @@ -1,78 +1,46 @@ { "strictness": 2, - "commentTypes": [ - "logic", - "syntax" - ], - "autoReview": [ - "open", - "push" - ], + "commentTypes": ["logic", "syntax"], + "autoReview": ["open", "push"], "statusCheck": true, - "excludeAuthors": [ - "dependabot[bot]" - ], - "ignorePatterns": "site/selftest/fixtures/*\nLICENSE-*\nCODE_OF_CONDUCT.md", - "instructions": "sqzer.dev is the page that runs the `sqzer` npm package in the browser. It moves from plain HTML to React 19, TypeScript and Vite under ADR-0002; until that port lands, `site/` is the page. CLAUDE.md is the rule book and docs/adr holds the decisions; where they disagree during the port, the accepted ADR wins. Review against them. The page depends on the published package by exact version and sends nothing anywhere. The page never writes a decoder, encoder, resampler or metric; what the package lacks is a change to sqzer-dev/sqzer, not to this repository.", + "excludeAuthors": ["dependabot[bot]"], + "ignorePatterns": "tests/fixtures/*\nLICENSE-*\nCODE_OF_CONDUCT.md", + "instructions": "sqzer.dev is the page that runs the `sqzer` npm package in the browser. It is a React 19 and TypeScript application built by Vite (ADR-0002), with the search as an XState machine whose worker outlives a search (ADR-0004). CLAUDE.md is the rule book and docs/adr holds the decisions; where they disagree, the accepted ADR wins. Review against them. The page depends on the published package by exact version and sends nothing anywhere. The page never writes a decoder, encoder, resampler or metric; what the package lacks is a change to sqzer-dev/sqzer, not to this repository.", "rules": [ { "id": "no-third-party", "rule": "The page talks to its own origin and nothing else: no analytics, no error reporting, no font, script, stylesheet, image or wasm from another host, no `data:` URI. The `Content-Security-Policy` is written once in `vite.config.ts` and injected into the built `index.html`; it is not widened for convenience. Flag any new origin, any inline script in `index.html`, any `style` attribute or `