diff --git a/package-lock.json b/package-lock.json index aa63e98..54790cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,11 +16,13 @@ "write-excel-file": "^4" }, "devDependencies": { + "@axe-core/playwright": "^4.13.0", "@playwright/test": "^1", "@supabase/supabase-js": "^2", "@sveltejs/kit": "^2", "@sveltejs/vite-plugin-svelte": "^7", "@types/uuid": "^10", + "happy-dom": "^20.11.6", "pg": "^8", "prettier": "^3", "prettier-plugin-svelte": "^4", @@ -37,6 +39,19 @@ "node": ">=22" } }, + "node_modules/@axe-core/playwright": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.13.0.tgz", + "integrity": "sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.13.0" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, "node_modules/@ecies/ciphers": { "version": "0.2.6", "resolved": "https://registry.npmjs.org/@ecies/ciphers/-/ciphers-0.2.6.tgz", @@ -1302,6 +1317,16 @@ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "license": "MIT" }, + "node_modules/@types/node": { + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -1315,6 +1340,23 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vercel/nft": { "version": "1.11.0", "resolved": "https://registry.npmjs.org/@vercel/nft/-/nft-1.11.0.tgz", @@ -1528,6 +1570,16 @@ "integrity": "sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==", "license": "MIT" }, + "node_modules/axe-core": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz", + "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -1567,6 +1619,19 @@ "node": "20 || >=22" } }, + "node_modules/buffer-image-size": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -1709,6 +1774,19 @@ "node": ">=16" } }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-module-lexer": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", @@ -1876,6 +1954,25 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/happy-dom": { + "version": "20.11.6", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.11.6.tgz", + "integrity": "sha512-Hldbg8AdAa5a5oDcZpjqnGitp7JB0hqWmfv/8qr+kft4vzSD8BHsbdRfzYvL/0QcbKcURC/yyoygbeDQarPvYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -2978,6 +3075,13 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/uuid": { "version": "14.0.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz", @@ -3197,6 +3301,16 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "license": "BSD-2-Clause" }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -3236,6 +3350,28 @@ "node": ">=18" } }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index e96f0ba..278d196 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "dev": "vite dev", "build": "vite build", "preview": "vite preview", - "test": "npm run test:unit && npm run test:e2e", + "test": "npm run test:unit && npm run test:e2e && npm run test:integration", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "format": "prettier --write .", "test:integration": "playwright test", @@ -15,6 +15,7 @@ "test:e2e": "vitest run --config vitest.e2e.config.ts", "csv": "node csv2org.js", "snapshot": "node restore/snapshot.js", + "audit:markup": "vitest run restore/markup-audit.test.ts", "restore": "node restore/restore.js", "start": "cp .env.local .env; supabase start -x storage-api,imgproxy,logflare,vector,edge-runtime", "stop": "cp .env.prod .env; supabase stop", @@ -27,11 +28,13 @@ "node": ">=22" }, "devDependencies": { + "@axe-core/playwright": "^4.13.0", "@playwright/test": "^1", "@supabase/supabase-js": "^2", "@sveltejs/kit": "^2", "@sveltejs/vite-plugin-svelte": "^7", "@types/uuid": "^10", + "happy-dom": "^20.11.6", "pg": "^8", "prettier": "^3", "prettier-plugin-svelte": "^4", diff --git a/playwright.config.ts b/playwright.config.ts index 1c5d7a1..20fae63 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,12 +1,39 @@ import type { PlaywrightTestConfig } from '@playwright/test'; +/** + * Two servers, because the two kinds of test need different things. + * + * The application is checked against a production build, which is the only way to catch what only + * breaks once it is built. The editor is checked against the dev server, which serves the source + * modules, so that the editor can be mounted on a bare element and driven directly rather than + * reached through a signed in page. + */ const config: PlaywrightTestConfig = { - webServer: { - command: 'npm run build && npm run preview', - port: 4173 - }, + webServer: [ + { + command: 'npm run build && npm run preview', + port: 4173, + reuseExistingServer: !process.env.CI + }, + { + command: 'npm run dev -- --port 5173 --strictPort', + port: 5173, + reuseExistingServer: !process.env.CI + } + ], testDir: 'tests', - testMatch: /(.+\.)?(test|spec)\.[jt]s/ + projects: [ + { + name: 'app', + testMatch: 'test.ts', + use: { baseURL: 'http://localhost:4173' } + }, + { + name: 'editor', + testMatch: 'editor.test.ts', + use: { baseURL: 'http://localhost:5173' } + } + ] }; export default config; diff --git a/restore/README.md b/restore/README.md index 9b045ed..0f3a356 100644 --- a/restore/README.md +++ b/restore/README.md @@ -41,7 +41,7 @@ Take a snapshot of production **before** you change anything. It is your only re pre-restore state, and it makes the whole operation auditable. ```sh -export PROD_DB_URL='postgresql://postgres:...@db.xxxx.supabase.co:5432/postgres' +export PROD_DB_URL='postgresql://postgres.xxxx:...@aws-0-REGION.pooler.supabase.com:5432/postgres' npm run snapshot -- --source-env PROD_DB_URL ``` @@ -50,10 +50,29 @@ No downtime is needed and nothing is locked for long — the tool only reads her Then write down what was lost, roughly when, and who reported it. You will need it in step 4, because the diff cannot tell an accidental deletion from a deliberate one. -Get the connection string from **Dashboard → Connect**. Use the **direct connection** or the -**session pooler**. Not the transaction pooler on port 6543 — it does not hold session state across +Get the connection string from **Dashboard → Connect**, and take the **session pooler**. + +Not the **direct connection**: `db..supabase.co` resolves to IPv6 only unless the project has +the IPv4 add-on, so on a network without IPv6 it fails at name resolution with `ENOTFOUND` before +anything is even attempted. Check with `dig +short A db..supabase.co` — no answer means no. + +Not the **transaction pooler** on port 6543 either: it does not hold session state across statements, and the tool refuses it. +The session pooler's username is `postgres.`, not `postgres`. Getting that wrong +fails as a password error, which sends you looking in the wrong place. + +Supabase signs its own certificates — the pooler presents one from `Supabase Intermediate 2021 CA`, +which no public trust store carries — so every connection outside the local stack also needs +`--ca`. Download it from **Dashboard → Settings → Database**: + +```sh +npm run snapshot -- --source-env PROD_DB_URL --ca ~/Downloads/prod-ca-2021.crt +``` + +Do not reach for a way to skip verification instead. A connection that cannot prove what it is +talking to is not one to send a production password down. + ## 2. Get the backup **Dashboard → Database → Backups**, pick the day, **Download**. The timestamps are UTC. diff --git a/restore/lib/db.js b/restore/lib/db.js index d0ec899..17224d4 100644 --- a/restore/lib/db.js +++ b/restore/lib/db.js @@ -65,6 +65,22 @@ export async function connect(url, { ca, label } = {}) { ); } + /* + * The session pooler's username is postgres., which is easy to paste into the + * database's place at the end of the string instead. Postgres then reports that a database of + * that name does not exist, and the search goes looking for a missing database rather than for + * a username in the wrong half of the connection string. + */ + const database = parsed.pathname.replace(/^\//, ''); + if (/^postgres\.\w/.test(database)) { + throw new Error( + `${label ?? 'connection'} names a database of "${database}", which looks like the session ` + + `pooler's username in the database's place. The username goes before the @ and the ` + + `database after the host:\n` + + ` postgresql://${database}:PASSWORD@${parsed.hostname}:${parsed.port || '5432'}/postgres` + ); + } + const local = isLocal(parsed.hostname); /** @type {import('pg').ClientConfig} */ const config = { connectionString: url }; @@ -88,6 +104,18 @@ export async function connect(url, { ca, label } = {}) { `pass it with --ca . Do not disable certificate verification.` ); } + // A direct connection host resolves to IPv6 only unless the project has the IPv4 add-on, so + // on a network without IPv6 it fails here rather than anywhere informative. Saying so is + // worth more than the raw resolver error, since this is read during an emergency. + if (/ENOTFOUND|EAI_AGAIN/i.test(message) && /^db\./.test(parsed.hostname)) { + throw new Error( + `${label ?? 'connection'} (${describe(url)}): ${message}\n` + + `${parsed.hostname} is a direct connection host, which resolves to IPv6 only unless ` + + `the project has the IPv4 add-on. Use the session pooler instead: Dashboard -> ` + + `Connect -> Session pooler, on port 5432. Note its username is postgres. ` + + `rather than postgres.` + ); + } throw new Error(`${label ?? 'connection'} (${describe(url)}): ${message}`); } return client; diff --git a/restore/markup-audit.test.ts b/restore/markup-audit.test.ts new file mode 100644 index 0000000..4e0c4ab --- /dev/null +++ b/restore/markup-audit.test.ts @@ -0,0 +1,328 @@ +import { test, expect } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { parse, parseWithSpans } from '../src/markup/parser'; +import { serialize, serializeBlock } from '../src/markup/serializer'; +import Markup from '../src/markup/Markup'; +import Paragraph from '../src/markup/Paragraph'; +import Characters from '../src/markup/Text'; +import Heading from '../src/markup/Heading'; +import Bullets from '../src/markup/Bullets'; +import Numbered from '../src/markup/Numbered'; +import Quote from '../src/markup/Quote'; +import Link from '../src/markup/Link'; +import Reference from '../src/markup/Reference'; +import type Block from '../src/markup/Block'; +// @ts-expect-error -- restore/ is plain JavaScript with JSDoc types. +import { connect } from './lib/db.js'; + +/** + * Measure what the editor would do to markup people have already written. + * + * Two different questions get asked here, and only one of them is frightening. + * + * Does anything mean something different afterwards? That is data loss, and it has to be zero. + * Do any of the bytes change? That is normalization -- a bullet marker, some blank lines -- and + * it is untidy rather than dangerous. + * + * The second is also narrower than it looks. Saving rewrites only the blocks somebody edited, so + * what matters is not how many documents would change if reserialized whole, but how likely the + * one block being edited is to be rewritten. All three numbers are reported. + * + * Read-only, and skipped unless pointed at a database: + * + * AUDIT_DB_URL=postgresql://... npm run audit:markup + * + * Supabase presents a certificate from its own authority rather than a public one, so a connection + * to anything but the local stack also needs its CA: + * + * AUDIT_DB_CA=~/Downloads/prod-ca-2021.crt + * + * Download it from Dashboard -> Settings -> Database. Verification is never turned off to get + * around this: a connection that cannot prove who it is talking to is not one to send a password + * down. + * + * A url on the command line shows up in ps output and shell history, so it is read from the + * environment only. Reading .env is deliberately not an option: `npm run stop` copies .env.prod + * over .env, so .env points at production whenever the local stack is down. + * + * Run against production on 22 August 2026, before the rich text editor was turned on, over 3332 + * values and 5183 blocks, of which 42% held anything the round trip could touch: + * + * Words dropped 0 + * Never settles 0 + * Reshaped 0 + * Blocks rewritten 398 of 5183 (7.7%), all of it cosmetic + * + * A later run that moves any of the first three off zero is a regression, whatever else changed. + */ + +/** Every column that holds markup, as of the call sites of MarkupView. */ +const Columns: [table: string, column: string][] = [ + ['comments', 'what'], + ['hows', 'what'], + ['orgs', 'description'], + ['orgs', 'prompt'], + ['roles', 'description'], + ['suggestions', 'description'], + ['suggestions', 'proposal'], + ['suggestions', 'what'], + ['teams', 'description'] +]; + +/** Anything that makes a value more than plain prose, and so a candidate for being rewritten. */ +const Exposed = /[\\*_<@"]|^#|^\s*\d+\./m; + +/** A rough label for what changed, so a long report can be skimmed rather than read. */ +function classify(before: string, after: string): string { + if (before.replace(/\n{2,}/g, '\n\n') === after) return 'blank line spacing'; + if (before.replace(/^[*•]\s/gm, '- ') === after) return 'bullet marker'; + if (before.includes('\\') || after.includes('\\')) return 'escaping'; + if (/[*_]/.test(before)) return 'unterminated or spaced formatting'; + if (/^#{3,}/m.test(before)) return 'heading level'; + // Ordered lists are renumbered from one, so compare with the numbers taken out. + const renumbered = (text: string) => text.replace(/^\s*\d+\./gm, '#.'); + if (renumbered(before) === renumbered(after)) return 'list numbering'; + return 'other'; +} + +function linesOf(block: Block) { + if (block instanceof Heading) return [block.text]; + if (block instanceof Bullets || block instanceof Numbered) return block.items; + if (block instanceof Quote) return block.blocks; + if (block instanceof Paragraph) return [block.segments]; + return []; +} + +/** + * Every character a reader would see, with none of the structure. + * + * This is what must not change. The tree around it may: a space moves out of a bold run, a bullet + * marker becomes a dash, blank lines collapse. None of that is anybody's words going missing, and + * counting it as loss buries the thing worth being frightened of. + */ +function textOf(markup: Markup): string { + return markup.blocks + .map((block) => + linesOf(block) + .map((line) => + line + .map((segment) => + segment instanceof Characters + ? segment.text + : segment instanceof Link + ? `${segment.text}${segment.url}` + : segment instanceof Reference + ? `${segment.text}${segment.target}` + : '' + ) + .join('') + ) + .join('\n') + ) + .join('\n'); +} + +/** What the round trip does to one stored value. */ +function examine(before: string) { + // One parse, so that the spans are keyed by the very blocks being looked up. Parsing twice + // gives two sets of blocks that are equal and not identical, and every lookup misses. + const { markup: tree, spans } = parseWithSpans(before); + const after = serialize(tree); + const once = parse(after); + + // Words going missing. Nothing else counts, and this must be nothing. + const dropped = textOf(once) !== textOf(tree); + + // Settling. The first pass normalizes -- a space leaves a bold run, a bullet becomes a dash -- + // and that is fine as long as it is the end of it. A document that keeps changing on every save + // is being worn away rather than tidied. + const unstable = parse(serialize(once)).toString() !== once.toString(); + + // Same words, different tree: the normalization, which is untidy rather than dangerous. + const reshaped = !dropped && once.toString() !== tree.toString(); + let blocks = 0; + let rewritten = 0; + for (const block of tree.blocks) { + const span = spans.get(block); + if (span === undefined) continue; + blocks++; + if (before.slice(span[0], span[1]) !== serializeBlock(block)) rewritten++; + } + + return { after, dropped, unstable, reshaped, changed: after !== before, blocks, rewritten }; +} + +/** + * Values that must be reported as changing, and values that must not. A report saying nothing + * would change is only worth believing if the comparison behind it is known to work -- and a + * corpus of plain prose produces exactly that report whether the detector works or not. + */ +const SelfCheck: [markup: string, changes: boolean][] = [ + ['C:\\shared', true], + ['I am *bold', true], + ['* a\n* b', true], + ['#### Deep', true], + ['a\n\n\n\nb', true], + ['"unclosed', true], + ['Just some ordinary prose.', false], + ['# A heading', false], + ['- one\n- two', false] +]; + +/** + * How many blocks of a value would be rewritten, and how many it has. This is the number that + * says what one edit costs, so it is worth knowing it counts rather than assuming it. + */ +const BlockCheck: [markup: string, rewritten: number, blocks: number][] = [ + ['* a\n* b', 1, 1], + ['# fine\n\n* rewritten', 1, 2], + ['# fine\n\nalso fine', 0, 2], + ['one\n\n\n\ntwo', 0, 2], + ['#### deep\n\n- fine\n- fine', 1, 2] +]; + +/** + * Shapes real documents turn out to be full of, and what each should be called. + * + * The first pass at this reported all of these as meaning lost, which buried the question worth + * asking under four false alarms. + */ +const MeaningCheck: [markup: string, dropped: boolean, unstable: boolean, reshaped: boolean][] = [ + // A space just inside the end of a formatting run stays exactly where it was written. + ['_Overview _', false, false, false], + ['*Undergraduate Research *', false, false, false], + ['_ Rationale_', false, false, false], + // A stray asterisk opens a run that never closes, which the parser has always read this way. + // Only the closing marker is added, which the parser was already supplying for itself. + ['(PM)* owns the delivery.', false, false, false], + // Ordinary prose is not reshaped at all. + ['Nothing remarkable here.', false, false, false], + ['- one\n- two', false, false, false] +]; + +const url = process.env.AUDIT_DB_URL; + +/** Supabase signs its own certificates, so anything but the local stack needs its authority. */ +const ca = process.env.AUDIT_DB_CA?.replace(/^~(?=\/)/, process.env.HOME ?? '~'); + +test.skipIf(!url)( + 'report what the editor would do to stored markup', + async () => { + // Prove the detectors before trusting anything they say about the corpus. + for (const [markup, changes] of SelfCheck) + expect(examine(markup).changed, `self check: ${JSON.stringify(markup)}`).toBe(changes); + for (const [markup, dropped, unstable, reshaped] of MeaningCheck) { + const result = examine(markup); + expect( + [result.dropped, result.unstable, result.reshaped], + `meaning: ${JSON.stringify(markup)}` + ).toEqual([dropped, unstable, reshaped]); + } + for (const [markup, rewritten, blocks] of BlockCheck) { + const result = examine(markup); + expect([result.rewritten, result.blocks], `blocks: ${JSON.stringify(markup)}`).toEqual([ + rewritten, + blocks + ]); + } + // A trailing space is meaning the parser cannot read back, so the loss detector must see it. + const trailing = serialize(new Markup([new Paragraph([new Characters('', 'a ')])])); + expect(parse(trailing).toString()).not.toBe( + new Markup([new Paragraph([new Characters('', 'a ')])]).toString() + ); + + // connect() checks the file itself; what it does not do is expand a leading ~. + const client = await connect(url, { label: 'audit', ca }); + const counts = new Map(); + const losses: string[] = []; + const unsettled: string[] = []; + let reshaped = 0; + const lines: string[] = []; + let total = 0; + let exposed = 0; + let changed = 0; + let blocks = 0; + let rewritten = 0; + + try { + for (const [table, column] of Columns) { + const { rows } = await client.query( + `select id, ${column} as value from public.${table} where ${column} is not null and ${column} <> ''` + ); + for (const row of rows) { + const before: string = row.value; + const result = examine(before); + total++; + if (Exposed.test(before)) exposed++; + blocks += result.blocks; + rewritten += result.rewritten; + + if (result.reshaped) reshaped++; + if (result.dropped) + losses.push( + `--- ${table}.${column} ${row.id}\nbefore: ${JSON.stringify(before)}\n` + + `after: ${JSON.stringify(result.after)}` + ); + if (result.unstable) + unsettled.push( + `--- ${table}.${column} ${row.id}\nbefore: ${JSON.stringify(before)}\n` + + `after: ${JSON.stringify(result.after)}` + ); + + if (!result.changed) continue; + changed++; + const kind = classify(before, result.after); + counts.set(kind, (counts.get(kind) ?? 0) + 1); + lines.push( + `--- ${table}.${column} ${row.id} [${kind}] ${result.rewritten}/${result.blocks} blocks\n` + + `before: ${JSON.stringify(before)}\n` + + `after: ${JSON.stringify(result.after)}` + ); + } + } + } finally { + await client.end(); + } + + const percent = (part: number, whole: number) => `${((part / (whole || 1)) * 100).toFixed(1)}%`; + + const summary = [ + `Audited ${total} values across ${Columns.length} columns, ${blocks} blocks in all.`, + '', + `Words dropped: ${losses.length} <- has to be zero`, + `Never settles: ${unsettled.length} <- has to be zero`, + `Reshaped: ${reshaped} of ${total} values (${percent(reshaped, total)}); same words, tidier tree`, + `Bytes changed: ${changed} of ${total} values (${percent(changed, total)}) if reserialized whole`, + `Blocks rewritten: ${rewritten} of ${blocks} (${percent(rewritten, blocks)}) <- the odds for any one edit`, + `In scope at all: ${exposed} of ${total} (${percent(exposed, total)}); the rest is plain prose`, + '', + ...[...counts.entries()] + .sort((a, b) => b[1] - a[1]) + .map(([kind, count]) => ` ${String(count).padStart(6)} ${kind}`) + ].join('\n'); + + const out = path.join('backups', 'markup-audit.txt'); + fs.mkdirSync('backups', { recursive: true }); + fs.writeFileSync( + out, + `${summary}\n\n${losses.length > 0 ? `WORDS DROPPED\n\n${losses.join('\n\n')}\n\n` : ''}` + + `${unsettled.length > 0 ? `NEVER SETTLES\n\n${unsettled.join('\n\n')}\n\n` : ''}` + + `NORMALIZED\n\n${lines.join('\n\n')}\n`, + 'utf8' + ); + + // Written straight out rather than through console, which the test runner swallows. + process.stdout.write(`\n${summary}\n\nFull report: ${out}\n\n`); + + // Normalization is expected and is for a person to look at. Words going missing is not, and + // neither is a document that never stops changing. The detail is in the file rather than + // here: five whole documents in a terminal is not a report anybody can read. + expect({ dropped: losses.length, unsettled: unsettled.length, report: out }).toEqual({ + dropped: 0, + unsettled: 0, + report: out + }); + }, + 120000 +); diff --git a/src/app.html b/src/app.html index c240042..c7c7107 100644 --- a/src/app.html +++ b/src/app.html @@ -194,6 +194,11 @@ blockquote { margin-block-start: var(--spacing); margin-block-end: var(--spacing); + margin-inline-start: var(--spacing); + margin-inline-end: var(--spacing); + padding-inline-start: var(--spacing); + border-inline-start: solid var(--thickness) var(--chrome); + font-style: italic; } blockquote:before, diff --git a/src/lib/Announcement.svelte b/src/lib/Announcement.svelte new file mode 100644 index 0000000..1e6cfa5 --- /dev/null +++ b/src/lib/Announcement.svelte @@ -0,0 +1,23 @@ + + + +
{announcement()}
+ + diff --git a/src/lib/Button.svelte b/src/lib/Button.svelte index f62e12e..2090aac 100644 --- a/src/lib/Button.svelte +++ b/src/lib/Button.svelte @@ -14,6 +14,18 @@ chromeless?: boolean; children?: import('svelte').Snippet; onkeydown?: (event: KeyboardEvent) => void; + /** For a button that turns something on and off, such as bold in a toolbar. */ + pressed?: boolean | undefined; + /** A toolbar is one tab stop, so all but one of its buttons are taken out of the order. */ + tabindex?: number | undefined; + /** + * The keystroke for this command, written the way the platform writes it. It goes in the + * tooltip, where it can be seen, and in aria-keyshortcuts, which is where a screen reader + * expects to find it -- rather than in the accessible name, which is read out every time. + */ + shortcut?: string | undefined; + /** The same keystroke in the form aria-keyshortcuts takes, such as "Control+B". */ + keys?: string | undefined; } let { @@ -25,7 +37,11 @@ end = false, chromeless = false, children, - onkeydown + onkeydown, + pressed = undefined, + tabindex = undefined, + shortcut = undefined, + keys = undefined }: Props = $props(); let confirm = $state(false); @@ -47,8 +63,11 @@ class:chromeless type={submit ? 'submit' : 'button'} aria-disabled={!active} - title={tip} + aria-pressed={pressed === undefined ? null : pressed} + {tabindex} + title={shortcut ? `${tip} (${shortcut})` : tip} aria-label={tip} + aria-keyshortcuts={keys} {onkeydown} onclick={(event) => { if (warning) confirm = true; diff --git a/src/lib/Labeled.svelte b/src/lib/Labeled.svelte index c2b923a..8e6c3c6 100644 --- a/src/lib/Labeled.svelte +++ b/src/lib/Labeled.svelte @@ -3,12 +3,26 @@ label: string; message?: string | undefined; children?: import('svelte').Snippet; + /** + * The id of what is being labeled, when that is not something a label can point at. + * Wrapping in a