diff --git a/localinfra/docker-compose.web-build.yml b/localinfra/docker-compose.web-build.yml index 40bed6172..62109e655 100644 --- a/localinfra/docker-compose.web-build.yml +++ b/localinfra/docker-compose.web-build.yml @@ -9,6 +9,8 @@ services: corepack pnpm install --frozen-lockfile --store-dir /src/node_modules/.pnpm-store corepack pnpm exec vite build exec corepack pnpm exec vite preview --host 0.0.0.0 --port 5174 --strictPort + environment: + E2E_TESTIDS: "true" # a changed hash recreates the container, which builds again. e2e/up sets it labels: e2e.web-src: ${E2E_WEB_SRC:-} diff --git a/web/package.json b/web/package.json index f4c1570b8..cd4916ec6 100644 --- a/web/package.json +++ b/web/package.json @@ -9,9 +9,9 @@ "build": "vite build && node scripts/asset-headers.ts", "preview": "vite preview --host 0.0.0.0", "deploy:cloudflare-preview": "./scripts/deploy-cloudflare-preview.sh", - "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "lint": "prettier --check --cache . && eslint . --cache", - "lint:cached": "prettier --check --cache . && eslint . --cache", + "check": "svelte-kit sync && tsc --noEmit -p scripts/tsconfig.json && svelte-check --tsconfig ./tsconfig.json", + "lint": "prettier --check --cache . && eslint . --cache && node scripts/testids-check.ts", + "lint:cached": "prettier --check --cache . && eslint . --cache && node scripts/testids-check.ts", "format": "prettier --write .", "test": "vitest --run --project server", "test:storybook": "vitest --run --project storybook", diff --git a/web/scripts/testids-check.ts b/web/scripts/testids-check.ts new file mode 100644 index 000000000..4c2ecfc6c --- /dev/null +++ b/web/scripts/testids-check.ts @@ -0,0 +1,46 @@ +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { componentName, transform } from "./testids.ts"; + +const web = path.resolve(import.meta.dirname, ".."); +const e2e = path.resolve(web, "../e2e"); + +const walk = (dir: string, keep: (file: string) => boolean): string[] => + readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const file = path.join(dir, entry.name); + if (entry.isDirectory()) return entry.name === "node_modules" ? [] : walk(file, keep); + return keep(file) ? [file] : []; + }); + +const ids = new Set(); +const components = new Set(); +for (const file of walk( + path.join(web, "src"), + (f) => f.endsWith(".svelte") && !f.endsWith(".stories.svelte") +)) { + const name = componentName(file); + const { seen, inserts } = transform(readFileSync(file, "utf8"), { component: name }); + if (inserts.some((insert) => insert.text.includes("data-component="))) components.add(name); + for (const item of seen) if (item.id) ids.add(item.id); +} + +const unknown: string[] = []; +const uses = + /(testid|component)\(\s*"([^"]+)"|data-(testid|component)=\\?"([A-Za-z0-9_:/.[\]()-]+)/g; +for (const file of walk(e2e, (f) => f.endsWith(".ts"))) { + readFileSync(file, "utf8") + .split("\n") + .forEach((line, index) => { + for (const match of line.matchAll(uses)) { + const kind = match[1] ?? match[3]; + const name = match[2] ?? match[4]; + if (kind === "component" ? !components.has(name) : !ids.has(name)) + unknown.push( + `${kind} ${name} ${path.relative(path.dirname(e2e), file)}:${index + 1}` + ); + } + }); +} + +if (unknown.length) console.error(`not in the web:\n ${unknown.join("\n ")}`); +process.exit(unknown.length ? 1 : 0); diff --git a/web/scripts/testids.test.ts b/web/scripts/testids.test.ts new file mode 100644 index 000000000..163b89932 --- /dev/null +++ b/web/scripts/testids.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; +import { apply, transform } from "./testids.ts"; + +const run = (source: string, options?: Parameters[1]) => { + const { inserts, seen } = transform(source, options); + return { out: apply(source, inserts), seen }; +}; + +const idOf = (markup: string) => run(markup).seen[0]?.id ?? null; + +describe("handlers", () => { + it("names a plain handler", () => + expect(idOf("")).toBe("deleteIssue")); + it("names the call inside an arrow", () => { + expect(idOf("")).toBe("archive"); + expect(idOf("")).toBe("copy"); + expect(idOf("")).toBe("upload"); + }); + it("names an action() call after the action, not .run", () => + expect(idOf("")).toBe("save")); + it("reads assignments as what they do", () => { + expect(idOf("")).toBe("close"); + expect(idOf("")).toBe("openDrawer"); + expect(idOf("")).toBe("toggleMenu"); + }); + it("drops the on prefix a prop carries", () => { + expect(idOf("")).toBe("cancel"); + expect(idOf("")).toBe("confirm"); + }); + it("leaves a forwarded onclick for the call site", () => + expect(idOf("")).toBeNull()); + it("gives up on a handler with no single action", () => + expect(idOf("")).toBeNull()); +}); + +describe("links", () => { + it("names a literal href by its last word", () => { + expect(idOf('x')).toBe("keys"); + expect(idOf('x')).toBe("signup"); + }); + it("skips the parts only known at runtime", () => { + expect(idOf("x")).toBe("issues"); + expect(idOf("x")).toBeNull(); + }); + it("names a variable href after the variable", () => + expect(idOf("x")).toBe("newRepoHref")); + it("ignores external links and a forwarded href", () => { + expect(idOf('x')).toBeNull(); + expect(idOf("x")).toBeNull(); + }); +}); + +describe("fields", () => { + it("names an input after what it binds", () => + expect(idOf("")).toBe("formTitle")); + it("names a submit button submit", () => + expect(idOf('')).toBe("submit")); + it("prefers a handler over a name", () => + expect(idOf('')).toBe("go")); +}); + +describe("injection", () => { + it("puts the id on a component call site", () => + expect(run("").out).toBe( + '' + )); + it("leaves a hand-written id alone", () => { + const { out, seen } = run(''); + expect(out).toBe(''); + expect(seen.map((item) => [item.id, item.source])).toEqual([["mine", "hand"]]); + }); + it("does not touch elements that are not interactive", () => + expect(run("

hi

").out).toBe("

hi

")); + it("does not touch a component it has no rule for", () => + expect(run("y").out).toBe( + "y" + )); + it("marks the root elements with the component, through blocks too", () => { + const { out } = run("{#if a}
x
{/if}", { + component: "Panel" + }); + expect(out).toBe( + '{#if a}
x
{/if}' + ); + }); + it("does not mark elements nested in another", () => + expect(run("
x
", { component: "Panel" }).out).toBe( + '
x
' + )); + it("copes with typescript in the script", () => + expect( + run( + '' + ).out + ).toContain("