diff --git a/AGENTS.md b/AGENTS.md index 54bc5df57f..d762a0d79c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,13 +107,36 @@ npm i x # Testing - Never write unit tests in the same session. Spawn a new session, and write the test after the feature is working. Tests are for regression, not for development. -- UI component tests (`*.test.tsx`) should test DOM output, click events, and dialogs. Never test CSS classes or form UI (`AutoForm`, form fields). -- Do not test an existing feature applied somewhere new. Building the widgets system earns tests; adding widget zones or widgets to another page or plugin does not, because the mechanism is already covered and only the wiring is new. -- Do not test database models or schema definitions (Drizzle tables, column types, indexes, foreign keys). -- Do not test static config, constants, color presets, or plugin configuration (`config.api`, content type definitions). -- Do not test third-party libraries or trivial helper/wrapper code. +- UI component tests (`*.test.tsx`) should test DOM output, click events, and dialogs. Never test CSS classes, inline styles that only decorate, or form UI (`AutoForm`, form fields). - Always run "pnpm lint:fix" to fix formatting and linting issues before committing. Never commit a file with lint errors. +### What not to test + +- An existing feature applied somewhere new. Building the widgets system earns tests; adding widget zones or widgets to another page or plugin does not, because the mechanism is already covered and only the wiring is new. The same goes for a route that only calls a tested helper, or a boot step that only calls a tested validator. +- Database models or schema definitions (Drizzle tables, column types, indexes, foreign keys, RLS), or the SQL text Drizzle generates. +- Static config, constants, color presets, plugin configuration (`config.api`, content type definitions), query-key literals, namespace lists, TanStack Query option values (`retry`, `staleTime`) and route `staticData`. +- Third-party libraries or trivial helper/wrapper code. A shadcn or Base UI wrapper does not earn a test for what Base UI already does (stepping a `NumberField`, a `Tooltip` delay, a `Progress` aria value). +- Source text. No `readFileSync` + `toContain`/`toMatch` on a module, no "this file still exists" or "this identifier is gone" checks - test behaviour. Import-graph checks that keep server or editor code out of a bundle are fine. +- Translated copy or the contents of a locale JSON - `use-intl` renders keys in tests, so assert on the key. +- The test's own fixtures. + +### One behaviour, one test + +- Test a rule once, in the layer that owns it: a pure function in its own file, a service in the service test, HTTP status, permissions and events in the route test. Don't re-assert the same rule through a wrapper, a route or a rendered screen. +- Before adding a case, search for the behaviour - if another file already asserts it, extend that file instead of copying the scenario. +- Prefer a pure-function test to a render. A DOM test costs about 15× a node test; render only to check what the user sees or clicks. +- Collapse near-identical cases into one `it.each` table instead of a run of copy-pasted `it`s. +- Content Engine route permissions go in `content/server/permission-matrix.test.ts`, not as allowed/403 pairs in every route file. +- Security checks stay even when they look redundant: cookie scoping, auth ceremonies (origin, RP ID, replayed challenges), timing-safe paths, open-redirect guards and per-route `403`s. + +### Speed + +- The file name picks the environment: `*.test.tsx` runs in happy-dom (project `dom`), `*.test.ts` runs in node (project `unit`). Never add a `// @vitest-environment` pragma. +- `unit` shares one module graph per worker (`isolate: false`). A test that asserts process-wide state ("nothing registered yet") goes in `ISOLATED` in `packages/vitnode/vitest.config.ts`. +- Real builds, watchers and child processes go in `INTEGRATION` there. `pnpm test` runs everything; `pnpm test:unit` skips them. +- Never wait out a real delay - use `vi.useFakeTimers()` and advance the clock. +- Build a Hono app or a fixture once per file when the tests only read from it. + ### Mocking - Avoid `vi.mock`. It swaps a whole module for the file, so the test stops exercising the real code and breaks silently when the module is renamed or reshaped. Reach for a real seam first: diff --git a/packages/create-vitnode-app/src/plugin/create/route-templates.test.ts b/packages/create-vitnode-app/src/plugin/create/route-templates.test.ts index 51c2bd997d..12905f48f4 100644 --- a/packages/create-vitnode-app/src/plugin/create/route-templates.test.ts +++ b/packages/create-vitnode-app/src/plugin/create/route-templates.test.ts @@ -116,14 +116,6 @@ describe("the generated route tree", () => { it("declares no route id, which VitNode derives", () => { expect(pluginRoutesTemplate("blog")).not.toMatch(/^\s*id:/m); }); - - it("never writes a framework's path spelling", () => { - const routes = pluginRoutesTemplate("blog"); - const declared = /page\("([^"]+)"/.exec(routes)?.[1]; - - expect(declared).toBe("/blog"); - expect(declared).not.toMatch(/[[\]$]/); - }); }); describe("the generated route module", () => { @@ -472,10 +464,6 @@ describe("the scaffold as a whole", () => { expect(templateTsconfigInclude()).toContain("types"); }); - it("is a pure function of the plugin name", () => { - expect(pluginRouteScaffold("blog")).toEqual(pluginRouteScaffold("blog")); - }); - it("writes the same files whatever the name", () => { expect(Object.keys(pluginRouteScaffold("@acme/blog"))).toEqual( Object.keys(pluginRouteScaffold("other")), diff --git a/packages/create-vitnode-app/src/plugin/validation.test.ts b/packages/create-vitnode-app/src/plugin/validation.test.ts deleted file mode 100644 index 2241c68a0d..0000000000 --- a/packages/create-vitnode-app/src/plugin/validation.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { reservedPluginNameProblem } from "./validation.js"; - -describe("reservedPluginNameProblem", () => { - it.each(["admin", "api", "login", "users"])( - "refuses %s, a route core already serves", - name => { - expect(reservedPluginNameProblem(name)).toContain(`/${name}`); - }, - ); - - it.each(["blog", "forum", "admin-tools"])("accepts %s", name => { - expect(reservedPluginNameProblem(name)).toBeNull(); - }); -}); diff --git a/packages/vitnode/package.json b/packages/vitnode/package.json index 3b9ee5f49a..597470d687 100644 --- a/packages/vitnode/package.json +++ b/packages/vitnode/package.json @@ -66,6 +66,7 @@ "@vitnode/config": "workspace:*", "drizzle-kit": "1.0.0-rc.4", "drizzle-orm": "1.0.0-rc.4", + "happy-dom": "^20.14.6", "hono": "^4.13.13", "jiti": "^2.7.0", "jsdom": "^30.1.2", @@ -219,10 +220,11 @@ "build:plugins": "node dist/scripts/scripts.js build", "dev": "node dist/scripts/scripts.js dev", "dev:email": "email dev --dir src/emails", - "test": "vitest run", - "test:watch": "vitest", - "test:coverage": "vitest run --coverage", - "test:types": "vitest run --typecheck.only", + "test": "vitest run --project unit --project isolated --project dom --project integration", + "test:unit": "vitest run --project unit --project isolated --project dom", + "test:watch": "vitest --project unit --project isolated --project dom", + "test:coverage": "vitest run --project unit --project isolated --project dom --project integration --coverage", + "test:types": "vitest run --project types", "lint": "oxlint --type-aware", "lint:fix": "oxlint --type-aware --fix" }, diff --git a/packages/vitnode/scripts/cli/builder/analysis.test.ts b/packages/vitnode/scripts/cli/builder/analysis.test.ts index f02d293901..5539e252b3 100644 --- a/packages/vitnode/scripts/cli/builder/analysis.test.ts +++ b/packages/vitnode/scripts/cli/builder/analysis.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; diff --git a/packages/vitnode/scripts/cli/builder/measure.test.ts b/packages/vitnode/scripts/cli/builder/measure.test.ts index 4d7b9bb1b8..624d8d7795 100644 --- a/packages/vitnode/scripts/cli/builder/measure.test.ts +++ b/packages/vitnode/scripts/cli/builder/measure.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -6,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { BuildOutputFile, FileCategory } from "./output-files"; -import { brotliSize, gzipSize, measureFiles } from "./measure"; +import { measureFiles } from "./measure"; let dir: string; @@ -99,12 +98,3 @@ describe("measureFiles", () => { ).toBeNull(); }); }); - -describe("compression helpers", () => { - it("compress repetitive content well", async () => { - const content = Buffer.from("vitnode ".repeat(10_000)); - - expect(await gzipSize(content)).toBeLessThan(content.length / 10); - expect(await brotliSize(content)).toBeLessThan(content.length / 10); - }); -}); diff --git a/packages/vitnode/scripts/cli/builder/output-files.test.ts b/packages/vitnode/scripts/cli/builder/output-files.test.ts index 9809ca5d96..f5e2070e3b 100644 --- a/packages/vitnode/scripts/cli/builder/output-files.test.ts +++ b/packages/vitnode/scripts/cli/builder/output-files.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import { describe, expect, it } from "vitest"; import { diff --git a/packages/vitnode/scripts/cli/builder/package-build.test.ts b/packages/vitnode/scripts/cli/builder/package-build.test.ts index 552eb00a9d..c372c0ace2 100644 --- a/packages/vitnode/scripts/cli/builder/package-build.test.ts +++ b/packages/vitnode/scripts/cli/builder/package-build.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import { execFileSync } from "node:child_process"; import { cpSync, @@ -14,7 +13,15 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, relative } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, +} from "vitest"; import { runBuildCommand } from "../commands/build"; import { RuntimeError } from "../errors"; @@ -117,7 +124,7 @@ const fixture = () => { write("src/__fixtures__/sample.json", "{}\n"); }; -beforeEach(() => { +const createPackage = () => { root = mkdtempSync(join(tmpdir(), "vitnode-package-build-")); // The package's own node_modules, as an install would leave it: tsdown, // TypeScript and React's types resolve from there. The sources themselves @@ -129,11 +136,11 @@ beforeEach(() => { "junction", ); fixture(); -}); +}; -afterEach(() => { +const removePackage = () => { rmSync(root, { force: true, recursive: true }); -}); +}; const build = async () => { const { context, runtime } = createTestContext({ cwd: root }); @@ -173,8 +180,18 @@ const importWithNode = (expression: string) => ).trim(); describe("vitnode build in a plugin package", () => { - it("compiles every module under src into dist/src with tsdown and checks types", async () => { - const { code, output } = await build(); + // One build serves every assertion that only reads its output. + let result: Awaited>; + + beforeAll(async () => { + createPackage(); + result = await build(); + }); + + afterAll(removePackage); + + it("compiles every module under src into dist/src with tsdown and checks types", () => { + const { code, output } = result; expect(code).toBe(0); expect(output).toContain("✓ Compiling sources"); @@ -193,11 +210,9 @@ describe("vitnode build in a plugin package", () => { // Every relative import - JavaScript and declarations - names a file // that was written, as Node's ESM resolution requires. expect(unresolvedRelativeImports(join(root, "dist", "src"))).toEqual([]); - }, 60_000); - - it("emits ESM that Node runs, with aliases, JSX, dynamic imports and side effects intact", async () => { - await build(); + }); + it("emits ESM that Node runs, with aliases, JSX, dynamic imports and side effects intact", () => { expect(read("dist/src/components/greeting.js")).toContain( "react/jsx-runtime", ); @@ -221,11 +236,9 @@ describe("vitnode build in a plugin package", () => { 'const { quadruple } = await import("./dist/src/uses-alias.js"); const { loadPage } = await import("./dist/src/lazy.js"); await import("./dist/src/register.js"); const { helloModule } = await import("./dist/src/api/hello.module.js"); console.log(quadruple(2), (await loadPage()).default, globalThis.fixtureLoaded, helloModule.routes[0]);', ), ).toBe("8 page true /hello"); - }, 60_000); - - it("copies JSON next to the module that imports it, with the import attribute kept", async () => { - await build(); + }); + it("copies JSON next to the module that imports it, with the import attribute kept", () => { expect(read("dist/src/locales/en.json")).toBe(read("src/locales/en.json")); expect(read("dist/src/locales/index.js")).toMatch( /import\(\s*["'`]\.\/en\.json["'`]\s*,\s*\{\s*with:\s*\{\s*type:\s*["'`]json["'`]/, @@ -235,11 +248,9 @@ describe("vitnode build in a plugin package", () => { 'const { default: messages } = await import("./dist/src/locales/index.js"); console.log((await messages.en()).default.fixture.hello);', ), ).toBe("Hi"); - }, 60_000); - - it("writes declarations with relative imports, declaration maps and the ambient types the package declares", async () => { - await build(); + }); + it("writes declarations with relative imports, declaration maps and the ambient types the package declares", () => { const declaration = read("dist/src/uses-alias.d.ts"); expect(declaration).toMatch(/from\s*["']\.\/lib\/math\.js["']/); expect(declaration).not.toContain("@/"); @@ -255,14 +266,18 @@ describe("vitnode build in a plugin package", () => { expect(read("dist/src/registry.d.ts")).toMatch( /readGreeting: \(\) => "hello"/, ); - }, 60_000); - - it("ships no JavaScript source maps from a production build", async () => { - await build(); + }); + it("ships no JavaScript source maps from a production build", () => { expect(exists("dist/src/standalone.js.map")).toBe(false); expect(read("dist/src/standalone.js")).not.toContain("sourceMappingURL"); - }, 60_000); + }); +}); + +describe("vitnode build on a package that changes", () => { + beforeEach(createPackage); + + afterEach(removePackage); it("fails on a type error, as tsc always made it", async () => { write("src/broken.ts", 'export const value: number = "text";\n'); diff --git a/packages/vitnode/scripts/cli/builder/scripts-build.test.ts b/packages/vitnode/scripts/cli/builder/scripts-build.test.ts index a5b5bc0f2e..d8c84fe3a6 100644 --- a/packages/vitnode/scripts/cli/builder/scripts-build.test.ts +++ b/packages/vitnode/scripts/cli/builder/scripts-build.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import { execFileSync, spawnSync } from "node:child_process"; import { existsSync, diff --git a/packages/vitnode/scripts/cli/builder/size-severity.test.ts b/packages/vitnode/scripts/cli/builder/size-severity.test.ts index 6b8bd26224..93cafa7852 100644 --- a/packages/vitnode/scripts/cli/builder/size-severity.test.ts +++ b/packages/vitnode/scripts/cli/builder/size-severity.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import { describe, expect, it } from "vitest"; import { classifySize, needsWarning } from "./size-severity"; diff --git a/packages/vitnode/scripts/cli/builder/snapshot.test.ts b/packages/vitnode/scripts/cli/builder/snapshot.test.ts index c666faaf34..9ed8c24e94 100644 --- a/packages/vitnode/scripts/cli/builder/snapshot.test.ts +++ b/packages/vitnode/scripts/cli/builder/snapshot.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; diff --git a/packages/vitnode/scripts/cli/commands/build.test.ts b/packages/vitnode/scripts/cli/commands/build.test.ts index 84d2685c2b..446f198106 100644 --- a/packages/vitnode/scripts/cli/commands/build.test.ts +++ b/packages/vitnode/scripts/cli/commands/build.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import type { InlineConfig, Plugin } from "vite"; import { @@ -295,33 +294,6 @@ describe("vitnode build for an app", () => { expect(runtime.output()).not.toContain("Warnings"); }); - it("prints stable [OK] lines without color in plain mode", async () => { - const { runtime } = await build( - fakeVite(appBuild({ admin: 612_400, index: 1000 })), - { - interactive: true, - plain: true, - }, - ); - - expect(runtime.output()).toContain("[OK] Building client"); - expect(runtime.output()).toContain("[WARN] admin-AAAAAAA1.js is larger"); - expect(runtime.raw()).not.toContain("\x1b["); - expect(runtime.raw()).not.toContain("\r"); - }); - - it("never animates in CI, even on a TTY", async () => { - const { runtime } = await build( - fakeVite(appBuild({ admin: 1000, index: 1000 })), - { - env: { CI: "true" }, - interactive: true, - }, - ); - - expect(runtime.raw()).not.toContain("\r"); - }); - it("passes the bundler's own log level through in verbose mode", async () => { const vite = fakeVite(appBuild({ admin: 1000, index: 1000 })); await build(vite, { verbose: true }); @@ -456,6 +428,7 @@ describe("a failed build", () => { fakeVite(appBuild({ admin: 1000, index: 1000 }), { fail: new Error("Something odd"), }), + { captureOutput: captureProcessOutput }, ).catch((thrown: unknown) => thrown)) as RuntimeError; expect(failure.details).toEqual(["Something odd"]); diff --git a/packages/vitnode/scripts/cli/commands/db.test.ts b/packages/vitnode/scripts/cli/commands/db.test.ts index 9ef32c2e47..246fb57760 100644 --- a/packages/vitnode/scripts/cli/commands/db.test.ts +++ b/packages/vitnode/scripts/cli/commands/db.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; diff --git a/packages/vitnode/scripts/cli/commands/dev.test.ts b/packages/vitnode/scripts/cli/commands/dev.test.ts index e295565120..04446a08da 100644 --- a/packages/vitnode/scripts/cli/commands/dev.test.ts +++ b/packages/vitnode/scripts/cli/commands/dev.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import type { ViteDevServer } from "vite"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; @@ -454,12 +453,4 @@ describe("the dev request log", () => { expect(lines.join("")).toBe("HMR src/components/post.tsx\n"); }); - - it("is only active while serving", () => { - const { ui } = plainUi(); - - expect(createDevReporter(ui, { defaultPort: null, root: "/" }).apply).toBe( - "serve", - ); - }); }); diff --git a/packages/vitnode/scripts/cli/commands/i18n.test.ts b/packages/vitnode/scripts/cli/commands/i18n.test.ts index 26a5e52c9e..fe8a7a81e2 100644 --- a/packages/vitnode/scripts/cli/commands/i18n.test.ts +++ b/packages/vitnode/scripts/cli/commands/i18n.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import { existsSync, mkdirSync, @@ -85,6 +84,38 @@ describe("vitnode i18n check", () => { expect(errors).toMatch(/✖ \d+ issue\(s\), 1 untranslated key\(s\)\./); }); + it("only warns about missing keys without --ci", async () => { + const { code, output } = await run(["i18n", "check"]); + + expect(code).toBe(0); + expect(output).toContain("site.cta"); + expect(output).toMatch(/! \d+ issue\(s\), 1 untranslated key\(s\)\./); + }); + + it("reports a complete translation", async () => { + write( + "src/locales/site/pl.json", + JSON.stringify({ site: { cta: "Odkryj", title: "Tytuł" } }), + ); + + const { code, output } = await run(["i18n", "check"]); + + expect(code).toBe(0); + expect(output).toContain("site · pl: complete"); + expect(output).not.toContain("never loaded"); + }); + + it("errors when the namespace has no default-locale file", async () => { + rmSync(join(root, "src/locales/site/en.json")); + + const { code, output } = await run(["i18n", "check"]); + + expect(code).toBe(1); + expect(output).toContain( + 'site: no "en" messages - create src/locales/site/en.json', + ); + }); + it("keeps working under its older name", async () => { expect((await run(["i18n:check", "--ci"])).code).toBe(1); expect((await run(["i18n:check", "--cii"])).code).toBe(2); diff --git a/packages/vitnode/scripts/cli/commands/plugin.test.ts b/packages/vitnode/scripts/cli/commands/plugin.test.ts index ac607b3c30..109f186363 100644 --- a/packages/vitnode/scripts/cli/commands/plugin.test.ts +++ b/packages/vitnode/scripts/cli/commands/plugin.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import { createJiti } from "jiti"; import { cpSync, diff --git a/packages/vitnode/scripts/cli/commands/start.test.ts b/packages/vitnode/scripts/cli/commands/start.test.ts index f005289b28..c461fb29b2 100644 --- a/packages/vitnode/scripts/cli/commands/start.test.ts +++ b/packages/vitnode/scripts/cli/commands/start.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { createServer } from "node:net"; import { tmpdir } from "node:os"; @@ -128,7 +127,7 @@ describe("start options", () => { const serverScript = ` import { createServer } from "node:http"; const server = createServer((_, res) => res.end("ok")); -server.listen(Number(process.env.PORT), () => console.log("listening " + process.env.NODE_ENV)); +server.listen(Number(process.env.PORT)); `; describe("vitnode start", () => { diff --git a/packages/vitnode/scripts/cli/db/db.test.ts b/packages/vitnode/scripts/cli/db/db.test.ts index 27af970793..a6270a7dbd 100644 --- a/packages/vitnode/scripts/cli/db/db.test.ts +++ b/packages/vitnode/scripts/cli/db/db.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import { describe, expect, it } from "vitest"; import type { AppliedMigration, LocalMigration } from "./migration-state"; diff --git a/packages/vitnode/scripts/cli/dev/package-watch.test.ts b/packages/vitnode/scripts/cli/dev/package-watch.test.ts index c32de411f5..192b968686 100644 --- a/packages/vitnode/scripts/cli/dev/package-watch.test.ts +++ b/packages/vitnode/scripts/cli/dev/package-watch.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import type { ChildProcess } from "node:child_process"; import { @@ -82,7 +81,7 @@ class RecordingGroup extends ProcessGroup { readonly pids: (number | undefined)[] = []; override spawn(options: RunProcessOptions): ChildProcess { - const child = super.spawn(options); + const child = super.spawn({ ...options, capture: true }); this.pids.push(child.pid); return child; diff --git a/packages/vitnode/scripts/cli/index.test.ts b/packages/vitnode/scripts/cli/index.test.ts index fae03945f6..8e33b15c12 100644 --- a/packages/vitnode/scripts/cli/index.test.ts +++ b/packages/vitnode/scripts/cli/index.test.ts @@ -1,7 +1,5 @@ -// @vitest-environment node import { describe, expect, it } from "vitest"; -import { PUBLIC_COMMANDS } from "./help"; import { runCli } from "./index"; import { createFakeRuntime } from "./testing"; @@ -66,32 +64,6 @@ describe("vitnode", () => { expect(code).toBe(0); expect(output.trim()).toBe("1.2.3-test"); }); - - it("lists only the public commands - legacy names keep working but stay out of help", async () => { - const { output } = await run([]); - - expect(PUBLIC_COMMANDS.every(({ name }) => output.includes(name))).toBe( - true, - ); - expect(output).not.toContain("db:prepare"); - expect(output).not.toContain("i18n:"); - }); - - it("never mentions out-of-scope commands", async () => { - const { output } = await run([]); - - for (const command of [ - "doctor", - "info", - "seed", - "cache", - "update", - "enable", - "disable", - ]) { - expect(output).not.toMatch(new RegExp(`\\b${command}\\b`)); - } - }); }); describe("usage errors", () => { diff --git a/packages/vitnode/scripts/cli/plugins/workspace.test.ts b/packages/vitnode/scripts/cli/plugins/workspace.test.ts index b6f2ce5a84..db9936da48 100644 --- a/packages/vitnode/scripts/cli/plugins/workspace.test.ts +++ b/packages/vitnode/scripts/cli/plugins/workspace.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; diff --git a/packages/vitnode/scripts/cli/project/package-manager.test.ts b/packages/vitnode/scripts/cli/project/package-manager.test.ts index 0712a7fd6e..5170327f4c 100644 --- a/packages/vitnode/scripts/cli/project/package-manager.test.ts +++ b/packages/vitnode/scripts/cli/project/package-manager.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; diff --git a/packages/vitnode/scripts/cli/project/processes.ts b/packages/vitnode/scripts/cli/project/processes.ts index 1f777e91bd..eb31aeb104 100644 --- a/packages/vitnode/scripts/cli/project/processes.ts +++ b/packages/vitnode/scripts/cli/project/processes.ts @@ -37,8 +37,9 @@ export const waitForShutdownSignal = async ( export interface RunProcessOptions { args: readonly string[]; /** - * `true` collects stdout and stderr into `output` instead of printing them, - * for steps whose output only matters when they fail. + * `true` keeps stdout and stderr off the terminal: `runProcess` collects them + * into `output`, for steps whose output only matters when they fail, and a + * `ProcessGroup` discards them. */ capture?: boolean; command: string; @@ -129,7 +130,11 @@ export class ProcessGroup { } spawn(options: RunProcessOptions): ChildProcess { - const child = spawnChild(options, "inherit"); + const child = spawnChild(options, options.capture ? "pipe" : "inherit"); + // Nothing reads a long-running child's output, so drain it - a full pipe + // would stall the child. + child.stdout?.resume(); + child.stderr?.resume(); this.children.add(child); this.exits.push( new Promise(resolve => { diff --git a/packages/vitnode/scripts/cli/project/runtime.test.ts b/packages/vitnode/scripts/cli/project/runtime.test.ts index dd1e2ded06..3c50ec4c1c 100644 --- a/packages/vitnode/scripts/cli/project/runtime.test.ts +++ b/packages/vitnode/scripts/cli/project/runtime.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; diff --git a/packages/vitnode/scripts/cli/ui/format.test.ts b/packages/vitnode/scripts/cli/ui/format.test.ts index 4fc841890f..2b170e22f3 100644 --- a/packages/vitnode/scripts/cli/ui/format.test.ts +++ b/packages/vitnode/scripts/cli/ui/format.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import { describe, expect, it } from "vitest"; import { diff --git a/packages/vitnode/scripts/cli/ui/terminal.test.ts b/packages/vitnode/scripts/cli/ui/terminal.test.ts index 8b7108a0fb..55b079c70a 100644 --- a/packages/vitnode/scripts/cli/ui/terminal.test.ts +++ b/packages/vitnode/scripts/cli/ui/terminal.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import { describe, expect, it } from "vitest"; import { detectTerminal, isCi, isUnicodeSupported } from "./terminal"; diff --git a/packages/vitnode/scripts/cli/ui/ui.test.ts b/packages/vitnode/scripts/cli/ui/ui.test.ts index 6c3f1164a1..dcb77835df 100644 --- a/packages/vitnode/scripts/cli/ui/ui.test.ts +++ b/packages/vitnode/scripts/cli/ui/ui.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import { describe, expect, it, vi } from "vitest"; import { createFakeRuntime } from "../testing"; diff --git a/packages/vitnode/scripts/database-bootstrap.test.ts b/packages/vitnode/scripts/database-bootstrap.test.ts index 25248fc43d..9ff8370bcf 100644 --- a/packages/vitnode/scripts/database-bootstrap.test.ts +++ b/packages/vitnode/scripts/database-bootstrap.test.ts @@ -1,11 +1,4 @@ -// @vitest-environment node -import { - existsSync, - mkdtempSync, - readFileSync, - rmSync, - writeFileSync, -} from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; @@ -23,18 +16,6 @@ import { withConfigFlag, } from "./prepare-database.js"; -const scriptsRoot = import.meta.dirname; - -/** A script's code, with comments removed - prose may name what code may not do. */ -const codeOf = (file: string): string => - readFileSync(join(scriptsRoot, file), "utf8") - .replace(/\/\*[\s\S]*?\*\//g, "") - .replace(/\/\/.*$/gm, ""); - -const cli = codeOf("cli/commands/legacy.ts"); -const program = codeOf("cli/program.ts"); -const bootstrap = codeOf("prepare-database.ts"); - describe("the steps of a database bootstrap", () => { it("generates, then applies, then seeds", () => { expect( @@ -84,26 +65,6 @@ describe("the steps of a database bootstrap", () => { }); describe("the `db:prepare` command", () => { - it("exists", () => { - expect(program).toContain('"db:prepare"'); - }); - - /** - * A `dev` script chains on this with `&&`, so a failed bootstrap has to be a - * non-zero exit. The command awaits the bootstrap and does not catch: the - * CLI's error boundary turns the throw into exit code 1. - */ - it("awaits the bootstrap and lets a failure reach the error boundary", () => { - const branch = cli.slice( - cli.indexOf("runDbPrepareCommand"), - cli.indexOf("runMigrateCommand"), - ); - - expect(branch).toMatch(/await databaseBootstrap\(/); - expect(branch).not.toMatch(/catch/); - expect(branch).not.toMatch(/\bvoid\s+databaseBootstrap/); - }); - it("exits non-zero when the bootstrap cannot run", async () => { const cwd = mkdtempSync(join(tmpdir(), "vitnode-db-prepare-")); const runtime = createFakeRuntime({ cwd }); @@ -117,37 +78,9 @@ describe("the `db:prepare` command", () => { rmSync(cwd, { force: true, recursive: true }); } }); - - it("shares one implementation with `migrate`", () => { - const branch = cli.slice( - cli.indexOf("runMigrateCommand"), - cli.indexOf("runI18nCheckCommand"), - ); - - expect(branch).toMatch(/await databaseBootstrap\(/); - // The one thing `migrate --generate` does that the bootstrap does not. - expect(branch).toMatch(/await generateDatabaseMigrations\(\)/); - expect(cli.match(/await runMigrations\(\)/g)).toBeNull(); - expect(cli.match(/await initialDataForDatabase\(\)/g)).toBeNull(); - }); - - it("no longer offers `init` or a `--web` no-op", () => { - expect(program).not.toContain('"init"'); - expect(cli).not.toContain("prepareDatabase"); - expect(bootstrap).not.toContain('"--web"'); - expect(bootstrap).not.toContain("prepareDatabase"); - }); }); describe("what decides whether work is pending", () => { - it("keeps no first-run marker of its own", () => { - for (const source of [cli, bootstrap]) { - expect(source).not.toMatch(/\.init-created|\.migrations-done|\.migrated/); - expect(source).not.toMatch(/VITNODE_(?:INITIALIZED|MIGRATED|DB_READY)/); - expect(source).not.toMatch(/writeFile[^)]*(?:marker|sentinel|stamp)/i); - } - }); - it("reads the migrations folder - and journal table - from drizzle.config.ts", async () => { const root = mkdtempSync(join(tmpdir(), "vitnode-drizzle-config-")); @@ -174,10 +107,6 @@ describe("what decides whether work is pending", () => { } finally { rmSync(root, { force: true, recursive: true }); } - - expect(bootstrap).toMatch( - /migrate\(config\.dbProvider, \{\s+migrationsFolder,\s+migrationsSchema,\s+migrationsTable,/, - ); }); it("reads whichever drizzle.config extension the project uses, and names it to drizzle-kit", async () => { @@ -208,57 +137,9 @@ describe("what decides whether work is pending", () => { rmSync(root, { force: true, recursive: true }); } }); - - it("ensures text-search configs before applying migrations", () => { - const applyStep = bootstrap.slice( - bootstrap.indexOf("export const runMigrations"), - ); - - expect(applyStep.indexOf("ensureSearchTextConfigs")).toBeGreaterThan(-1); - expect(applyStep.indexOf("ensureSearchTextConfigs")).toBeLessThan( - applyStep.indexOf("await migrate("), - ); - expect(bootstrap).toContain("COPY = simple"); - }); - - /** A fresh database needs more than tables. */ - it.each([ - ["languages", "core_languages"], - ["roles", "core_roles"], - ["role names", "core_languages_words"], - ["moderator permissions", "core_moderators_permissions"], - ["admin permissions", "core_admin_permissions"], - ])("seeds %s", (_label, table) => { - const seed = bootstrap.slice( - bootstrap.indexOf("export const initialDataForDatabase"), - bootstrap.indexOf("export interface DatabaseBootstrapStep"), - ); - - expect(seed).toContain(table); - }); - - /** Idempotent, which is what makes running it every time safe. */ - it("seeds idempotently", () => { - expect(bootstrap).toContain("onConflictDoNothing"); - expect(bootstrap).toMatch(/roleCount\.count === 0/); - }); }); describe("the languages a fresh database is seeded with", () => { - it("reads no frontend config", () => { - const seed = bootstrap.slice( - bootstrap.indexOf("export const initialDataForDatabase"), - bootstrap.indexOf("export interface DatabaseBootstrapStep"), - ); - - expect(seed).not.toContain('type: "config"'); - expect(seed).not.toContain("webConfig"); - expect(seed).toContain("languagesFromApiConfig(config.i18n)"); - // And nothing anywhere reaches for a sibling application. - expect(bootstrap).not.toMatch(/\.\.\/(?:web|api)\b/); - expect(bootstrap).not.toContain("vitnode.config.ts"); - }); - it("falls back to English when the API declares no locales", () => { for (const i18n of [undefined, {}, { locales: [] }]) { expect(languagesFromApiConfig(i18n)).toEqual([ @@ -401,90 +282,6 @@ describe("the languages a fresh database is seeded with", () => { }); expect(after.slice(0, 1)).toEqual(before); - expect(bootstrap).toMatch( - /onConflictDoNothing\(\{ target: core_languages\.code \}\)/, - ); - }); - - it("seeds role names under the default language, not under `en`", () => { - const seed = bootstrap.slice( - bootstrap.indexOf("export const initialDataForDatabase"), - bootstrap.indexOf("export interface DatabaseBootstrapStep"), - ); - - expect(seed).toContain("languageCode: defaultLanguageCode"); - expect(seed).not.toMatch(/languageCode: "en"/); - for (const role of ["Guest", "Member", "Moderator", "Administrator"]) { - expect(seed).toContain(`value: "${role}"`); - } - }); -}); - -describe("concurrent bootstraps are serialised", () => { - it("takes a Postgres advisory lock around the whole bootstrap", () => { - expect(bootstrap).toContain("withMigrationLock"); - expect(bootstrap).toContain("pg_try_advisory_lock"); - expect(bootstrap).toContain("pg_advisory_unlock"); - expect(bootstrap).toMatch(/MIGRATION_LOCK_KEY = [\d_]+;/); - }); - - it("wraps every step, not just the apply", () => { - const fn = bootstrap.slice( - bootstrap.indexOf("export const databaseBootstrap"), - ); - const lock = fn.indexOf("withMigrationLock("); - const action = fn.indexOf("step.action()"); - - // Both must be *found*. `indexOf` returns -1 for absent, and -1 is less than - // any real offset - so a bare `toBeLessThan` passes when the lock has been - // deleted outright, which is the mutation this test exists to catch. - expect( - lock, - "databaseBootstrap does not call withMigrationLock", - ).toBeGreaterThanOrEqual(0); - expect(action).toBeGreaterThanOrEqual(0); - expect(lock).toBeLessThan(action); - }); - - it("opens a dedicated connection for the lock", () => { - const open = bootstrap.slice( - bootstrap.indexOf("const openMigrationLock"), - bootstrap.indexOf("export const runWithMigrationLock"), - ); - - expect(open).toContain("postgres({"); - expect(open).toContain("max: 1"); - expect(open).toContain("client.end("); - }); - - /** - * And never out of the application's. `reserve()` pins a pooled connection, - * which is exactly the connection the migration then needs. - */ - it("reserves nothing from the application pool", () => { - expect(bootstrap).not.toContain(".reserve("); - expect(bootstrap).not.toContain("session.release()"); - }); - - it("waits with a deadline and says what it is waiting for", () => { - expect(bootstrap).not.toContain("pg_advisory_lock("); - expect(bootstrap).toMatch(/MIGRATION_LOCK_WAIT_MS = [\d_]+;/); - expect(bootstrap).toMatch(/Timed out after/); - expect(bootstrap).toMatch(/waiting for it to finish/); - }); - - it("degrades to unlocked rather than failing on an unknown driver", () => { - expect(bootstrap).toMatch(/!\("shared" in options\)/); - expect(bootstrap).toMatch(/if \(lock === null\) \{\s*await run\(\)/); - }); - - it("tolerates both spellings of “already created”, down the cause chain", () => { - expect(bootstrap).toContain("isAlreadyCreatedError"); - expect(bootstrap).toContain('"23505"'); - expect(bootstrap).toContain('"42710"'); - expect(bootstrap).toMatch( - /current = \(current as \{ cause\?: unknown \}\)\.cause/, - ); }); }); @@ -653,25 +450,3 @@ describe("holding the lock costs the application pool nothing", () => { expect(ran).toEqual(["migrated"]); }); }); - -describe("the bootstrap and the plugin runtime are separate", () => { - it("prepares no plugin and writes no route file", () => { - for (const source of [cli, bootstrap]) { - expect(source).not.toContain("preparePluginsFiles"); - expect(source).not.toContain("prepare-plugins"); - expect(source).not.toContain("copyDirectoryRecursive"); - expect(source).not.toMatch(/src\/routes|src\/app|\[locale\]|@breadcrumb/); - expect(source).not.toMatch(/\.gen\.tsx?\b/); - } - }); - - it("has no route-copying module left beside it", () => { - for (const deleted of [ - "prepare-plugins-files.ts", - "plugin.ts", - "legacy-route-overlap.ts", - ]) { - expect(existsSync(join(scriptsRoot, deleted))).toBe(false); - } - }); -}); diff --git a/packages/vitnode/scripts/i18n-check.test.ts b/packages/vitnode/scripts/i18n-check.test.ts deleted file mode 100644 index e4efdfc344..0000000000 --- a/packages/vitnode/scripts/i18n-check.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -// @vitest-environment node -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; - -import { ValidationError } from "./cli/errors"; -import { createTestContext } from "./cli/testing"; -import { i18nCheck } from "./i18n-check"; - -const writeApp = ( - root: string, - files: Record>, -) => { - mkdirSync(join(root, "src"), { recursive: true }); - writeFileSync(join(root, "package.json"), "{}"); - writeFileSync( - join(root, "src", "vitnode.config.ts"), - `export const vitNodeConfig = { i18n: { defaultLocale: "en", locales: [{ code: "en" }, { code: "pl" }] }, plugins: [] }`, - ); - mkdirSync(join(root, "src", "locales", "site"), { recursive: true }); - writeFileSync( - join(root, "src", "locales", "app.ts"), - `export const appMessages = { en: { site: () => null }, pl: { site: () => null } }`, - ); - writeFileSync( - join(root, "src", "vitnode.server.config.ts"), - `import { appMessages } from "@/locales/app"\nexport const vitNodeServerConfig = { messages: appMessages }`, - ); - - for (const [locale, tree] of Object.entries(files)) { - writeFileSync( - join(root, "src", "locales", "site", `${locale}.json`), - JSON.stringify(tree), - ); - } -}; - -describe("i18nCheck with an app-owned namespace", () => { - let root: string; - - beforeEach(() => { - root = mkdtempSync(join(tmpdir(), "vitnode-i18n-check-")); - }); - - afterEach(() => { - rmSync(root, { force: true, recursive: true }); - }); - - const check = async (ci = false) => { - const { context, runtime } = createTestContext({ cwd: root }); - const result = await i18nCheck({ ci, cwd: root, ui: context.ui }).catch( - (error: unknown) => error, - ); - - return { report: runtime.output(), result }; - }; - - it("lists keys the app's Polish file is missing and fails under --ci", async () => { - writeApp(root, { - en: { site: { hero: { title: "Title", cta: "Explore" } } }, - pl: { site: { hero: { title: "Tytuł" } } }, - }); - - const { report, result } = await check(true); - - expect(report).toContain("site · pl: 1 key(s) missing"); - expect(report).toContain("site.hero.cta"); - expect(result).toBeInstanceOf(ValidationError); - }); - - it("only warns about missing keys without --ci", async () => { - writeApp(root, { - en: { site: { hero: { title: "Title", cta: "Explore" } } }, - pl: { site: { hero: { title: "Tytuł" } } }, - }); - - const { report, result } = await check(); - - expect(result).toBe(0); - expect(report).toContain("! 2 issue(s), 1 untranslated key(s)."); - }); - - it("reports a complete translation", async () => { - writeApp(root, { - en: { site: { hero: { title: "Title" } } }, - pl: { site: { hero: { title: "Tytuł" } } }, - }); - - const { report, result } = await check(); - - expect(result).toBe(0); - expect(report).toContain("site · pl: complete"); - expect(report).not.toContain("never loaded"); - }); - - it("errors when the namespace has no default-locale file", async () => { - writeApp(root, { pl: { site: { hero: { title: "Tytuł" } } } }); - - const { report, result } = await check(); - - expect(report).toContain( - 'site: no "en" messages - create src/locales/site/en.json', - ); - expect(result).toBeInstanceOf(ValidationError); - }); -}); diff --git a/packages/vitnode/scripts/i18n-update-ai.test.ts b/packages/vitnode/scripts/i18n-update-ai.test.ts index c3a563788d..958ca8e818 100644 --- a/packages/vitnode/scripts/i18n-update-ai.test.ts +++ b/packages/vitnode/scripts/i18n-update-ai.test.ts @@ -2,11 +2,9 @@ import { describe, expect, it } from "vitest"; import { applyTranslations, - chunk, collectUntranslated, isTranslatable, mapPool, - uniqueSources, } from "./i18n-update-ai"; const delay = async (ms: number) => @@ -109,38 +107,6 @@ describe("applyTranslations", () => { }); }); -describe("chunk", () => { - it("splits into consecutive slices of at most `size`", () => { - expect(chunk([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]); - }); - - it("returns an empty array for no items", () => { - expect(chunk([], 3)).toEqual([]); - }); - - it("keeps a single slice when everything fits", () => { - expect(chunk([1, 2], 10)).toEqual([[1, 2]]); - }); -}); - -describe("uniqueSources", () => { - it("dedups sources, keeping first-seen order", () => { - const leaves = [ - { path: ["a"], source: "Save" }, - { path: ["b"], source: "Cancel" }, - { path: ["c"], source: "Save" }, - { path: ["d", "e"], source: "Cancel" }, - { path: ["f"], source: "Name" }, - ]; - - expect(uniqueSources(leaves)).toEqual(["Save", "Cancel", "Name"]); - }); - - it("returns an empty array for no leaves", () => { - expect(uniqueSources([])).toEqual([]); - }); -}); - describe("mapPool", () => { it("returns results in input order regardless of finish order", async () => { // The first item is the slowest, yet its result stays at index 0. diff --git a/packages/vitnode/scripts/no-route-copier.test.ts b/packages/vitnode/scripts/no-route-copier.test.ts deleted file mode 100644 index 7d5dd182b8..0000000000 --- a/packages/vitnode/scripts/no-route-copier.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -// @vitest-environment node -import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; -import { join, relative, resolve } from "node:path"; -import { describe, expect, it } from "vitest"; - -const scriptsRoot = resolve(import.meta.dirname); -const packageRoot = resolve(scriptsRoot, ".."); - -const SKIP = new Set([".git", "dist", "node_modules"]); - -const filesUnder = (directory: string): string[] => { - if (!existsSync(directory)) return []; - - const walk = (current: string): string[] => - readdirSync(current).flatMap(name => { - if (SKIP.has(name)) return []; - - const path = join(current, name); - - return statSync(path).isDirectory() - ? walk(path) - : [relative(packageRoot, path).replaceAll("\\", "/")]; - }); - - return walk(directory).sort(); -}; - -const codeOf = (file: string): string => - readFileSync(join(packageRoot, file), "utf8") - .replace(/\/\*[\s\S]*?\*\//g, "") - .replace(/\/\/.*$/gm, ""); - -const scriptFiles = filesUnder(scriptsRoot).filter( - file => file.endsWith(".ts") && !file.endsWith(".test.ts"), -); - -describe("the plugin route copier", () => { - it("has no module left in scripts/", () => { - for (const deleted of [ - "scripts/plugin.ts", - "scripts/prepare-plugins-files.ts", - "scripts/legacy-route-overlap.ts", - ]) { - expect(existsSync(join(packageRoot, deleted))).toBe(false); - } - }); - - it("has no CLI command", () => { - const cli = codeOf("scripts/scripts.ts"); - - expect(cli).not.toContain("prepare-plugins"); - expect(cli).not.toContain('case "plugin"'); - expect(cli).not.toContain("processPlugin"); - expect(cli).not.toContain("preparePluginsFiles"); - }); - - /** - * `vitnode dev` is the other way in - it started the watcher alongside the - * three compilers - and `vitnode init` is where a fresh project ran the copy. - */ - it("is not started by `vitnode dev` or `vitnode init`", () => { - expect(codeOf("scripts/cli/commands/dev.ts")).not.toContain( - "processPlugin", - ); - expect(codeOf("scripts/cli/dev/watchers.ts")).not.toContain( - "processPlugin", - ); - expect(codeOf("scripts/prepare-database.ts")).not.toContain( - "preparePluginsFiles", - ); - }); - - it("leaves no file-copying machinery behind", () => { - const utils = codeOf("scripts/shared/file-utils.ts"); - - for (const gone of [ - "copyDirectoryRecursive", - "cleanupDeletedFiles", - "transformFileImports", - "buildInitialRouteMap", - "findLocaleRoot", - "SourceConfig", - ]) { - expect(utils).not.toContain(gone); - } - - expect(utils).toContain("findRepoRoot"); - expect(utils).toContain("findPackagePath"); - }); - - it("recognises no legacy route directory", () => { - const offenders = scriptFiles.filter(file => { - const code = codeOf(file); - - return [ - '"routes", "main"', - '"routes", "admin"', - '"routes", "blank"', - '"routes", "breadcrumb"', - "LEGACY_ROUTE_DIRECTORIES", - ].some(token => code.includes(token)); - }); - - expect(offenders).toEqual([]); - }); -}); - -describe("the build-time strangler", () => { - it("keeps no list of routes another application owns", () => { - expect( - existsSync( - join(packageRoot, "src/framework/plugin-routes/legacy-routes.ts"), - ), - ).toBe(false); - - const offenders = filesUnder(join(packageRoot, "src")) - .filter(file => /\.tsx?$/.test(file) && !/\.test(-d)?\.tsx?$/.test(file)) - .filter(file => { - const code = codeOf(file); - - return ( - code.includes("assertNoLegacyRouteCollision") || - code.includes("legacyAdminRoutePathsFromFiles") || - code.includes("LegacyRoutePath") || - code.includes("legacyRoutes") - ); - }); - - expect(offenders).toEqual([]); - }); - - it("still refuses a plugin route that shadows the host's own page", () => { - const compiler = codeOf("src/framework/plugin-routes/compile.ts"); - - expect(compiler).toContain("assertNoHostRouteCollision"); - }); -}); diff --git a/packages/vitnode/scripts/write-plugin-api-registry.test.ts b/packages/vitnode/scripts/write-plugin-api-registry.test.ts index 6be160b09c..43262da39f 100644 --- a/packages/vitnode/scripts/write-plugin-api-registry.test.ts +++ b/packages/vitnode/scripts/write-plugin-api-registry.test.ts @@ -1,12 +1,25 @@ -// @vitest-environment node -import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { PLUGIN_API_REGISTRY_PATH } from "../src/framework/api-registry/generate.js"; import { writePluginApiRegistry } from "./write-plugin-api-registry.js"; +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { force: true, recursive: true }); + } +}); + const pluginAt = ({ name, withApi = true, @@ -15,6 +28,7 @@ const pluginAt = ({ withApi?: boolean; }): string => { const root = mkdtempSync(join(tmpdir(), "vitnode-plugin-")); + roots.push(root); if (name !== undefined) { writeFileSync(join(root, "package.json"), JSON.stringify({ name })); @@ -39,12 +53,6 @@ describe("writePluginApiRegistry", () => { expect(generated(root)).toContain("'@acme/blog': VitNodeApiPlugin"); }); - it("writes a declaration file, which the plugin's build never emits", () => { - // A `.ts` here would compile into `dist` and register the plugin in the - // registry of every project that installed it, configured or not. - expect(PLUGIN_API_REGISTRY_PATH.endsWith(".d.ts")).toBe(true); - }); - it("skips a package that serves no API", () => { const root = pluginAt({ name: "@acme/blog", withApi: false }); diff --git a/packages/vitnode/src/api/adapters/sso/profile.test.ts b/packages/vitnode/src/api/adapters/sso/profile.test.ts index 690c8892ea..73e24b580d 100644 --- a/packages/vitnode/src/api/adapters/sso/profile.test.ts +++ b/packages/vitnode/src/api/adapters/sso/profile.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import { afterEach, describe, expect, it, vi } from "vitest"; import { normalizeSsoProfile } from "@/api/lib/sso-profile"; @@ -20,8 +19,6 @@ const answer = (body: unknown) => { return fetch; }; -const scopeOf = (url: string) => new URL(url).searchParams.get("scope"); - afterEach(() => { vi.unstubAllGlobals(); }); @@ -64,11 +61,6 @@ describe("the Google adapter", () => { lastName: null, }); }); - - it("asks for no more than it did before", () => { - expect(scopeOf(google.getUrl({ state: "s" }))).toBe("openid profile email"); - expect(google.profileFields).toEqual(["avatar", "firstName", "lastName"]); - }); }); describe("the Discord adapter", () => { @@ -106,18 +98,6 @@ describe("the Discord adapter", () => { expect((await discord.fetchUser(TOKEN)).avatarUrl).toBeNull(); } }); - - it("never offers a first or last name", () => { - expect(discord.profileFields).toEqual(["avatar"]); - expect( - normalizeSsoProfile(discord, { - avatarUrl: null, - firstName: "Alice", - lastName: "Smith", - }), - ).toEqual({ avatarUrl: null, firstName: null, lastName: null }); - expect(scopeOf(discord.getUrl({ state: "s" }))).toBe("identify email"); - }); }); describe("the Facebook adapter", () => { @@ -168,12 +148,6 @@ describe("the Facebook adapter", () => { lastName: null, }); }); - - it("asks for public_profile and email only", () => { - expect(scopeOf(facebook.getUrl({ state: "s" }))).toBe( - "public_profile,email", - ); - }); }); describe("normalizing what an adapter returns", () => { diff --git a/packages/vitnode/src/api/admin-staff-permission.test.ts b/packages/vitnode/src/api/admin-staff-permission.test.ts index 1de1fd4df3..7c0bcb04f1 100644 --- a/packages/vitnode/src/api/admin-staff-permission.test.ts +++ b/packages/vitnode/src/api/admin-staff-permission.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import { OpenAPIHono, z } from "@hono/zod-openapi"; import { describe, expect, it } from "vitest"; diff --git a/packages/vitnode/src/api/lib/ai/accounting.test.ts b/packages/vitnode/src/api/lib/ai/accounting.test.ts index 357b3d0878..3bd1690bfc 100644 --- a/packages/vitnode/src/api/lib/ai/accounting.test.ts +++ b/packages/vitnode/src/api/lib/ai/accounting.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import type { LanguageModelUsage } from "ai"; import { describe, expect, it } from "vitest"; diff --git a/packages/vitnode/src/api/lib/ai/registry.test.ts b/packages/vitnode/src/api/lib/ai/registry.test.ts index 8d31365634..2361769f2b 100644 --- a/packages/vitnode/src/api/lib/ai/registry.test.ts +++ b/packages/vitnode/src/api/lib/ai/registry.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import { describe, expect, it } from "vitest"; import { z } from "zod"; diff --git a/packages/vitnode/src/api/lib/ai/runner.test.ts b/packages/vitnode/src/api/lib/ai/runner.test.ts index 7fa309b8fc..1e9332a3a2 100644 --- a/packages/vitnode/src/api/lib/ai/runner.test.ts +++ b/packages/vitnode/src/api/lib/ai/runner.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import type { Context } from "hono"; import { APICallError } from "ai"; diff --git a/packages/vitnode/src/api/lib/auth-cookie.test.ts b/packages/vitnode/src/api/lib/auth-cookie.test.ts index fe60720f3d..718e629dfb 100644 --- a/packages/vitnode/src/api/lib/auth-cookie.test.ts +++ b/packages/vitnode/src/api/lib/auth-cookie.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import type { Context } from "hono"; import { Hono } from "hono"; diff --git a/packages/vitnode/src/api/lib/check-staff-permission.test.ts b/packages/vitnode/src/api/lib/check-staff-permission.test.ts index 6c9de3a2b6..89f4b04901 100644 --- a/packages/vitnode/src/api/lib/check-staff-permission.test.ts +++ b/packages/vitnode/src/api/lib/check-staff-permission.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import type { Context } from "hono"; import { describe, expect, it, vi } from "vitest"; @@ -145,43 +144,6 @@ describe("isStaff", () => { }); describe("getStaffFlags", () => { - it("is neither for a member with no staff entry", async () => { - expect(await getStaffFlags(contextWith({}), USER)).toEqual({ - isAdmin: false, - isModerator: false, - }); - }); - - it("keeps the two staff types apart", async () => { - const c = contextWith({ moderator: [staffEntry({ userId: USER.id })] }); - - expect(await getStaffFlags(c, USER)).toEqual({ - isAdmin: false, - isModerator: true, - }); - }); - - it("finds staff entries held through a secondary role", async () => { - const c = contextWith({ - admin: [staffEntry({ roleId: STAFF_ROLE })], - secondaryRoleIds: [STAFF_ROLE], - }); - - expect(await getStaffFlags(c, USER)).toEqual({ - isAdmin: true, - isModerator: false, - }); - }); - - it("makes a secondary root role staff of both types", async () => { - const c = contextWith({ secondaryRoleIds: [ROOT_ROLE] }); - - expect(await getStaffFlags(c, USER)).toEqual({ - isAdmin: true, - isModerator: true, - }); - }); - it("agrees with isStaff", async () => { const c = contextWith({ moderator: [staffEntry({ roleId: STAFF_ROLE })], diff --git a/packages/vitnode/src/api/lib/content-modules.integration.test.ts b/packages/vitnode/src/api/lib/content-modules.integration.test.ts index 22ee4d3b82..89546eeae1 100644 --- a/packages/vitnode/src/api/lib/content-modules.integration.test.ts +++ b/packages/vitnode/src/api/lib/content-modules.integration.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; diff --git a/packages/vitnode/src/api/lib/error-details.test.ts b/packages/vitnode/src/api/lib/error-details.test.ts index 6b50129e17..cbbb52bbbd 100644 --- a/packages/vitnode/src/api/lib/error-details.test.ts +++ b/packages/vitnode/src/api/lib/error-details.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import { DrizzleQueryError } from "drizzle-orm"; import { describe, expect, it } from "vitest"; diff --git a/packages/vitnode/src/api/lib/file-download.test.ts b/packages/vitnode/src/api/lib/file-download.test.ts index 442b03f844..a4851aec37 100644 --- a/packages/vitnode/src/api/lib/file-download.test.ts +++ b/packages/vitnode/src/api/lib/file-download.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import type { Context } from "hono"; import { beforeEach, describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/api/lib/image-signature.test.ts b/packages/vitnode/src/api/lib/image-signature.test.ts index f79bb3390e..dc45d840e1 100644 --- a/packages/vitnode/src/api/lib/image-signature.test.ts +++ b/packages/vitnode/src/api/lib/image-signature.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import sharp from "sharp"; import { describe, expect, it } from "vitest"; diff --git a/packages/vitnode/src/api/lib/navigation-presets.test.ts b/packages/vitnode/src/api/lib/navigation-presets.test.ts index ffe16f77ff..2c48ab4c30 100644 --- a/packages/vitnode/src/api/lib/navigation-presets.test.ts +++ b/packages/vitnode/src/api/lib/navigation-presets.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import { describe, expect, it } from "vitest"; import { diff --git a/packages/vitnode/src/api/lib/openapi-tags.test.ts b/packages/vitnode/src/api/lib/openapi-tags.test.ts index 9a157491ad..9afab95366 100644 --- a/packages/vitnode/src/api/lib/openapi-tags.test.ts +++ b/packages/vitnode/src/api/lib/openapi-tags.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import { OpenAPIHono, z } from "@hono/zod-openapi"; import { describe, expect, it } from "vitest"; diff --git a/packages/vitnode/src/api/lib/pagination-cursor.test.ts b/packages/vitnode/src/api/lib/pagination-cursor.test.ts index c58a098aee..289661f2c8 100644 --- a/packages/vitnode/src/api/lib/pagination-cursor.test.ts +++ b/packages/vitnode/src/api/lib/pagination-cursor.test.ts @@ -7,7 +7,6 @@ import { timestamp, uuid, } from "drizzle-orm/pg-core"; -// @vitest-environment node import { HTTPException } from "hono/http-exception"; import { describe, expect, it } from "vitest"; @@ -286,12 +285,6 @@ describe("column values", () => { expect(cursorValueForColumn(core_users.createdAt, null)).toBeNull(); }); - it("refuses an unparseable date rather than letting Postgres fail the cast", () => { - expect(() => - cursorValueForColumn(core_users.createdAt, "not-a-date"), - ).toThrow(HTTPException); - }); - it("accepts the sortable column kinds", () => { expect(isCursorSortableColumn(core_users.id)).toBe(true); expect(isCursorSortableColumn(core_users.name)).toBe(true); @@ -361,10 +354,6 @@ describe("a tampered cursor value is refused, never coerced", () => { }); describe("bigint", () => { - it("refuses a value that would make BigInt() throw", () => { - refuses(probes.big, "not-a-bigint"); - }); - it.each([ ["a fractional string", "1.5"], ["an empty string", ""], @@ -458,12 +447,6 @@ describe("a tampered cursor value is refused, never coerced", () => { ])("accepts %s", (_why, value) => { expect(cursorValueForColumn(core_users.createdAt, value)).toBe(value); }); - - it("preserves microseconds through validation, digit for digit", () => { - const value = "2026-08-09 10:00:00.000001"; - - expect(cursorValueForColumn(core_users.createdAt, value)).toBe(value); - }); }); }); @@ -534,12 +517,6 @@ describe("a tampered cursor value is refused, never coerced", () => { }); describe("minting keeps the database's own representation", () => { - it("keeps a Postgres timestamp string exactly as it was read", () => { - expect( - cursorValueOf(core_users.createdAt, "2026-08-09 10:00:00.123456"), - ).toBe("2026-08-09 10:00:00.123456"); - }); - it("rewrites a Date into the form the column would have been read in", () => { expect( cursorValueOf(core_users.createdAt, new Date("2026-08-09T10:00:00.123Z")), diff --git a/packages/vitnode/src/api/lib/plugin.test.ts b/packages/vitnode/src/api/lib/plugin.test.ts index 0b1e591924..379de31d82 100644 --- a/packages/vitnode/src/api/lib/plugin.test.ts +++ b/packages/vitnode/src/api/lib/plugin.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import { describe, expect, it } from "vitest"; import { createContentModel } from "@/content/server/model"; diff --git a/packages/vitnode/src/api/lib/remote-image.test.ts b/packages/vitnode/src/api/lib/remote-image.test.ts index f62e8dc344..92be69a9d2 100644 --- a/packages/vitnode/src/api/lib/remote-image.test.ts +++ b/packages/vitnode/src/api/lib/remote-image.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import { describe, expect, it, vi } from "vitest"; import type { RemoteImageResponse, RemoteImageTransport } from "./remote-image"; diff --git a/packages/vitnode/src/api/lib/response-cache-headers.test.ts b/packages/vitnode/src/api/lib/response-cache-headers.test.ts index d72e19cbc7..1a771b7a99 100644 --- a/packages/vitnode/src/api/lib/response-cache-headers.test.ts +++ b/packages/vitnode/src/api/lib/response-cache-headers.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { dirname, join, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; diff --git a/packages/vitnode/src/api/lib/route.test.ts b/packages/vitnode/src/api/lib/route.test.ts index 5deb9f204e..ccd2f63287 100644 --- a/packages/vitnode/src/api/lib/route.test.ts +++ b/packages/vitnode/src/api/lib/route.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import type { MiddlewareHandler } from "hono"; import { OpenAPIHono, z } from "@hono/zod-openapi"; @@ -70,19 +69,4 @@ describe("buildRoute", () => { expect(middleware).toHaveLength(3); expect(middleware.at(-1)).toBe(custom); }); - - it("prepends the plugin tag and keeps route tags", () => { - const { route } = buildRoute({ - pluginId: "@vitnode/test_plugin", - route: { - method: "get", - path: "/", - tags: ["Articles"], - responses: okResponse, - }, - handler: c => c.json({ plugin: c.get("plugin").id }, 200), - }); - - expect(route.tags).toEqual(["Test Plugin", "Articles"]); - }); }); diff --git a/packages/vitnode/src/api/lib/session-token.test.ts b/packages/vitnode/src/api/lib/session-token.test.ts index f7c03dda10..8620cb026d 100644 --- a/packages/vitnode/src/api/lib/session-token.test.ts +++ b/packages/vitnode/src/api/lib/session-token.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import { createHash } from "node:crypto"; import { describe, expect, it } from "vitest"; @@ -15,20 +14,6 @@ describe("hashSessionToken", () => { ); }); - it("matches the known digest of the empty string", async () => { - // Pinned rather than derived: a change of algorithm would otherwise move - // both sides of the comparison above together. - await expect(hashSessionToken("")).resolves.toBe( - "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - ); - }); - - it("answers in lowercase hexadecimal, and nothing else", async () => { - await expect(hashSessionToken("a-session-token")).resolves.toMatch( - /^[0-9a-f]{64}$/, - ); - }); - it("pads a byte below 0x10 rather than dropping its zero", async () => { const hashed = await hashSessionToken("1"); @@ -38,13 +23,6 @@ describe("hashSessionToken", () => { expect(hashed).toHaveLength(64); }); - it.each(["", "1", "a-session-token", " ", "a".repeat(128)])( - "agrees with SHA-256 over %o", - async token => { - await expect(hashSessionToken(token)).resolves.toBe(reference(token)); - }, - ); - it("encodes as UTF-8, not as UTF-16 code units", async () => { // A multi-byte character is where the two encodings part ways, and every // stored token would stop matching if this changed. @@ -54,19 +32,4 @@ describe("hashSessionToken", () => { createHash("sha256").update(Buffer.from(key, "utf8")).digest("hex"), ); }); - - it("is stable, so a look-up finds the row a write made", async () => { - const [first, second] = await Promise.all([ - hashSessionToken("a-session-token"), - hashSessionToken("a-session-token"), - ]); - - expect(first).toBe(second); - }); - - it("separates two tokens that differ by one character", async () => { - expect(await hashSessionToken("token-a")).not.toBe( - await hashSessionToken("token-b"), - ); - }); }); diff --git a/packages/vitnode/src/api/lib/sso-avatar-import.test.ts b/packages/vitnode/src/api/lib/sso-avatar-import.test.ts index f067289402..72fa6523f9 100644 --- a/packages/vitnode/src/api/lib/sso-avatar-import.test.ts +++ b/packages/vitnode/src/api/lib/sso-avatar-import.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import type { Context } from "hono"; import { describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/api/lib/staff-permission-cache.test.ts b/packages/vitnode/src/api/lib/staff-permission-cache.test.ts index f83ca2d761..816789aed4 100644 --- a/packages/vitnode/src/api/lib/staff-permission-cache.test.ts +++ b/packages/vitnode/src/api/lib/staff-permission-cache.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import type { Context } from "hono"; import { beforeEach, describe, expect, it } from "vitest"; diff --git a/packages/vitnode/src/api/lib/user-images.test.ts b/packages/vitnode/src/api/lib/user-images.test.ts index 79b579090e..232064f6a5 100644 --- a/packages/vitnode/src/api/lib/user-images.test.ts +++ b/packages/vitnode/src/api/lib/user-images.test.ts @@ -13,9 +13,7 @@ import { notAttachedAsUserImage, type RoleImageLimits, setUserImage, - USER_IMAGE_FOLDERS, userImageHoldersOf, - zodUserImageKind, } from "./user-images"; const role = (overrides: Partial = {}): RoleImageLimits => ({ @@ -91,14 +89,6 @@ describe("effectiveUserImagePolicy", () => { }); }); -describe("zodUserImageKind", () => { - it("accepts only the two kinds a user has", () => { - expect(zodUserImageKind.safeParse("avatar").success).toBe(true); - expect(zodUserImageKind.safeParse("cover").success).toBe(true); - expect(zodUserImageKind.safeParse("banner").success).toBe(false); - }); -}); - describe("userImageHoldersOf", () => { it("names the kind each user held the file as", () => { expect( @@ -131,13 +121,6 @@ describe("userImageHoldersOf", () => { }); }); -describe("USER_IMAGE_FOLDERS", () => { - it("has one storage folder per kind, and they differ", () => { - expect(Object.keys(USER_IMAGE_FOLDERS).sort()).toEqual(["avatar", "cover"]); - expect(USER_IMAGE_FOLDERS.avatar).not.toBe(USER_IMAGE_FOLDERS.cover); - }); -}); - describe("notAttachedAsUserImage", () => { it("asks whether a profile still points at the file, not what folder it sits in", () => { const { sql } = new PgDialect().sqlToQuery( diff --git a/packages/vitnode/src/api/lib/with-pagination.test.ts b/packages/vitnode/src/api/lib/with-pagination.test.ts index 5daed76776..3267258014 100644 --- a/packages/vitnode/src/api/lib/with-pagination.test.ts +++ b/packages/vitnode/src/api/lib/with-pagination.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import type { SQL } from "drizzle-orm"; import type { Context } from "hono"; diff --git a/packages/vitnode/src/api/middlewares/global.middleware.test.ts b/packages/vitnode/src/api/middlewares/global.middleware.test.ts deleted file mode 100644 index 688ac0759d..0000000000 --- a/packages/vitnode/src/api/middlewares/global.middleware.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import type { PermissionStaffModulesInput } from "@/api/lib/permission-staff"; -import type { VitNodeApiConfig } from "@/vitnode.config"; - -import { buildApiPlugin } from "@/api/lib/plugin"; -import { defineEditablePage } from "@/content/editor/define"; - -import { globalMiddleware } from "./global.middleware"; - -const settingsPage = defineEditablePage({ - id: "example:settings", - permission: { module: "widgets", permission: "can_edit" }, - zones: { sidebar: { allowed: ["core:text"] } }, -}); - -const boot = (moderator?: PermissionStaffModulesInput) => - globalMiddleware({ - cacheClient: null, - dbProvider: {} as VitNodeApiConfig["dbProvider"], - metadata: { title: "VitNode" }, - plugins: [ - buildApiPlugin({ - pluginId: "@vitnode/example", - editablePages: [settingsPage], - permissionStaff: moderator === undefined ? undefined : { moderator }, - }), - ], - }); - -describe("an editable page's permission", () => { - it("boots when the plugin declares it as a moderator permission", () => { - expect(() => boot({ widgets: ["can_edit"] })).not.toThrow(); - }); - - it("fails boot when the plugin declares no permissions at all", () => { - expect(() => boot()).toThrow(/permission nobody holds/); - }); - - it("fails boot when the module is declared without that permission", () => { - expect(() => boot({ widgets: ["can_view"] })).toThrow(/never declares/); - }); -}); diff --git a/packages/vitnode/src/api/models/device.test.ts b/packages/vitnode/src/api/models/device.test.ts index b7b2e111e3..69e623a8a3 100644 --- a/packages/vitnode/src/api/models/device.test.ts +++ b/packages/vitnode/src/api/models/device.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import type { Context } from "hono"; import { Hono } from "hono"; diff --git a/packages/vitnode/src/api/models/events.test.ts b/packages/vitnode/src/api/models/events.test.ts index 95cbee53e5..a62efd55e3 100644 --- a/packages/vitnode/src/api/models/events.test.ts +++ b/packages/vitnode/src/api/models/events.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import type { Context } from "hono"; import { describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/api/models/queue.test.ts b/packages/vitnode/src/api/models/queue.test.ts index dcc2fb1fa7..c20c317728 100644 --- a/packages/vitnode/src/api/models/queue.test.ts +++ b/packages/vitnode/src/api/models/queue.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import type { Context } from "hono"; import { describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/api/models/search.test.ts b/packages/vitnode/src/api/models/search.test.ts index e152f08d9c..d02e558bb7 100644 --- a/packages/vitnode/src/api/models/search.test.ts +++ b/packages/vitnode/src/api/models/search.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import type { Context } from "hono"; import { describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/api/models/session-admin.test.ts b/packages/vitnode/src/api/models/session-admin.test.ts index 234d0bea1e..4a1b8a3bbf 100644 --- a/packages/vitnode/src/api/models/session-admin.test.ts +++ b/packages/vitnode/src/api/models/session-admin.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import type { Table } from "drizzle-orm"; import { Hono } from "hono"; diff --git a/packages/vitnode/src/api/models/session-cache.test.ts b/packages/vitnode/src/api/models/session-cache.test.ts index eacb81298b..b1a69c468b 100644 --- a/packages/vitnode/src/api/models/session-cache.test.ts +++ b/packages/vitnode/src/api/models/session-cache.test.ts @@ -1,7 +1,3 @@ -// @vitest-environment node -import { readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import type { SessionUser } from "./session-cache"; @@ -15,8 +11,6 @@ import { sessionCacheTtl, } from "./session-cache"; -const here = dirname(fileURLToPath(import.meta.url)); - const TOKEN_A = "a".repeat(64); const TOKEN_B = "b".repeat(64); @@ -107,16 +101,6 @@ describe("a cached user never outlives the session", () => { }); }); -describe("both session models refuse to write a non-positive TTL", () => { - it.each(["session.ts", "session-admin.ts"])("%s guards the write", file => { - const source = readFileSync(join(here, file), "utf8"); - - expect(source).toMatch( - /const ttl = sessionCacheTtl\([^)]*\);\s*if \(ttl > 0\) await cache\.setSystem\(/, - ); - }); -}); - describe("a user read back from the cache is the user that was written", () => { const user = { avatarColor: "#123456", diff --git a/packages/vitnode/src/api/models/session-cookies.test.ts b/packages/vitnode/src/api/models/session-cookies.test.ts index 0a6e0e0bbe..eb6316b7e3 100644 --- a/packages/vitnode/src/api/models/session-cookies.test.ts +++ b/packages/vitnode/src/api/models/session-cookies.test.ts @@ -1,10 +1,6 @@ -// @vitest-environment node import type { Context } from "hono"; import { Hono } from "hono"; -import { readFileSync } from "node:fs"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import type { EnvVariablesVitNode } from "@/api/middlewares/global.middleware"; @@ -290,22 +286,3 @@ describe("device cookie", () => { expect(cookie.options.domain).toBe(".example.com"); }); }); - -describe("every auth cookie goes through the shared helper", () => { - // The invariant the rest of this file rests on. A raw `setCookie` reintroduces - // the `domain` argument these tests exist to keep out, and a raw - // `deleteCookie` reintroduces the create/delete mismatch that leaves a cookie - // in the browser - neither of which changes a response's shape enough to fail - // an assertion elsewhere. - const here = dirname(fileURLToPath(import.meta.url)); - - it.each(["session.ts", "session-admin.ts", "device.ts", "sso.ts"])( - "%s writes no cookie of its own", - file => { - const source = readFileSync(resolve(here, file), "utf8"); - - expect(source).not.toMatch(/\bsetCookie\(/); - expect(source).not.toMatch(/\bdeleteCookie\(/); - }, - ); -}); diff --git a/packages/vitnode/src/api/models/session-revoke.test.ts b/packages/vitnode/src/api/models/session-revoke.test.ts index 06a1d3a8f8..87e207df87 100644 --- a/packages/vitnode/src/api/models/session-revoke.test.ts +++ b/packages/vitnode/src/api/models/session-revoke.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import { describe, expect, it } from "vitest"; import { ForgotPasswordTokenModel } from "@/api/models/password"; diff --git a/packages/vitnode/src/api/models/sso-link-token.test.ts b/packages/vitnode/src/api/models/sso-link-token.test.ts index f5a4f8356e..71ffed8a3c 100644 --- a/packages/vitnode/src/api/models/sso-link-token.test.ts +++ b/packages/vitnode/src/api/models/sso-link-token.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import { describe, expect, it } from "vitest"; import { diff --git a/packages/vitnode/src/api/models/storage-image.test.ts b/packages/vitnode/src/api/models/storage-image.test.ts index 29539a5a05..c78d9fa2fc 100644 --- a/packages/vitnode/src/api/models/storage-image.test.ts +++ b/packages/vitnode/src/api/models/storage-image.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node // Runs in the Node environment so sharp and real image buffers behave as they do // on the server. import type { Context } from "hono"; diff --git a/packages/vitnode/src/api/modules/admin/debug/routes/search-collections.route.test.ts b/packages/vitnode/src/api/modules/admin/debug/routes/search-collections.route.test.ts index 9a4e4ff3d7..8eb4d36547 100644 --- a/packages/vitnode/src/api/modules/admin/debug/routes/search-collections.route.test.ts +++ b/packages/vitnode/src/api/modules/admin/debug/routes/search-collections.route.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import { HTTPException } from "hono/http-exception"; import { describe, expect, it, vi } from "vitest"; @@ -221,22 +220,6 @@ describe("POST /search/clear", () => { } }); - it("does not reach the console when the audit log succeeds", async () => { - const consoleWarn = vi - .spyOn(console, "warn") - .mockImplementation(() => undefined); - - try { - const { c } = harness({ body: { itemType: "live.only" } }); - - await clearSearchDebugAdminRoute.handler(c); - - expect(consoleWarn).not.toHaveBeenCalled(); - } finally { - consoleWarn.mockRestore(); - } - }); - it("propagates a failed clear and writes no success audit", async () => { const { c, warnings } = harness({ body: { itemType: "live.only" }, @@ -248,27 +231,4 @@ describe("POST /search/clear", () => { ); expect(warnings).toEqual([]); }); - - it("rejects an empty item type at the schema", () => { - expect( - zodBody(clearSearchDebugAdminRoute).safeParse({ itemType: "" }).success, - ).toBe(false); - expect(zodBody(clearSearchDebugAdminRoute).safeParse({}).success).toBe( - false, - ); - expect( - zodBody(clearSearchDebugAdminRoute).safeParse({ itemType: "a.b" }) - .success, - ).toBe(true); - }); }); - -/** Reaches the body schema the route declared, so the test asserts on the real one. */ -function zodBody(route: typeof clearSearchDebugAdminRoute) { - const body = route.route.request?.body; - if (!body || !("content" in body)) throw new Error("No body schema."); - - return body.content["application/json"].schema as { - safeParse: (value: unknown) => { success: boolean }; - }; -} diff --git a/packages/vitnode/src/api/modules/admin/debug/routes/search-permissions.route.test.ts b/packages/vitnode/src/api/modules/admin/debug/routes/search-permissions.route.test.ts index b27bea39e6..bda9b8ebf6 100644 --- a/packages/vitnode/src/api/modules/admin/debug/routes/search-permissions.route.test.ts +++ b/packages/vitnode/src/api/modules/admin/debug/routes/search-permissions.route.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import type { Context } from "hono"; import { OpenAPIHono } from "@hono/zod-openapi"; diff --git a/packages/vitnode/src/api/modules/admin/debug/routes/search-status.route.test.ts b/packages/vitnode/src/api/modules/admin/debug/routes/search-status.route.test.ts index 58b85e9aef..1fd5b0e1ca 100644 --- a/packages/vitnode/src/api/modules/admin/debug/routes/search-status.route.test.ts +++ b/packages/vitnode/src/api/modules/admin/debug/routes/search-status.route.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import { describe, expect, it } from "vitest"; import type { SearchIndexerConfig } from "@/api/models/search"; diff --git a/packages/vitnode/src/api/modules/admin/files/routes/download-access.test.ts b/packages/vitnode/src/api/modules/admin/files/routes/download-access.test.ts deleted file mode 100644 index 2c94dbe9ca..0000000000 --- a/packages/vitnode/src/api/modules/admin/files/routes/download-access.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -// @vitest-environment node -import { readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; - -import { downloadUserFileRoute } from "../../../users/files/routes/download.route"; -import { downloadFileAdminRoute } from "./download.route"; - -const here = dirname(fileURLToPath(import.meta.url)); - -const USER_ROUTE = join(here, "../../../users/files/routes/download.route.ts"); -const ADMIN_ROUTE = join(here, "download.route.ts"); - -const sourceOf = (path: string): string => readFileSync(path, "utf8"); - -/** - * Who may download which file is the one thing the shared streaming helper is - * not allowed to have absorbed. Each route still asks its own question. - */ -describe("the download routes' access semantics", () => { - it("scopes the user's own download to the rows they own", () => { - expect(sourceOf(USER_ROUTE)).toContain("eq(core_files.userId, user.id)"); - }); - - it("refuses the user's download without a session", () => { - expect(sourceOf(USER_ROUTE)).toContain("new HTTPException(401"); - expect(downloadUserFileRoute.route.responses).toHaveProperty("401"); - }); - - it("lets an admin reach any row, behind a staff permission", () => { - const source = sourceOf(ADMIN_ROUTE); - - expect(source).toContain("eq(core_files.id, fileId)"); - expect(source).not.toContain("core_files.userId"); - expect(source).toContain( - 'adminStaffPermission: { module: "files", permission: "can_download" }', - ); - }); - - it("keeps the admin route out of the anonymous case entirely", () => { - // No `401` to declare: the staff-permission middleware answers first. - expect(downloadFileAdminRoute.route.responses).not.toHaveProperty("401"); - }); -}); - -describe("the 404 both routes answer", () => { - it.each([ - ["the user's own", downloadUserFileRoute], - ["the admin's", downloadFileAdminRoute], - ])("is still %s `{ error }` body", (_name, built) => { - const notFound = built.route.responses[404]; - - expect(notFound.description).toBe("File not found"); - expect( - notFound.content["application/json"].schema.parse({ - error: "File not found", - }), - ).toEqual({ error: "File not found" }); - }); - - it.each([ - ["the user's own", USER_ROUTE], - ["the admin's", ADMIN_ROUTE], - ])("is what %s route falls back to when nothing streamed", (_name, path) => { - const source = sourceOf(path); - - expect(source).toContain("(await attachStoredFile(c, file)) ??"); - expect(source).toContain('c.json({ error: "File not found" }, 404)'); - }); -}); - -describe("the streaming itself", () => { - it.each([ - ["the user's own", USER_ROUTE], - ["the admin's", ADMIN_ROUTE], - ])("is the shared helper in %s route, not a copy", (_name, path) => { - const source = sourceOf(path); - - expect(source).toContain( - 'import { attachStoredFile } from "@/api/lib/file-download";', - ); - // The duplication this removed: the fetch, the two headers and the - // re-streamed body, written out twice. - expect(source).not.toContain("Content-Disposition"); - expect(source).not.toContain("content-length"); - expect(source).not.toContain("c.body("); - }); -}); diff --git a/packages/vitnode/src/api/modules/admin/navigation/lib/read-navigation.test.ts b/packages/vitnode/src/api/modules/admin/navigation/lib/read-navigation.test.ts index cfb0f7f77e..b81f979626 100644 --- a/packages/vitnode/src/api/modules/admin/navigation/lib/read-navigation.test.ts +++ b/packages/vitnode/src/api/modules/admin/navigation/lib/read-navigation.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import { describe, expect, it } from "vitest"; import type { NavigationPreset } from "@/lib/navigation"; diff --git a/packages/vitnode/src/api/modules/admin/navigation/lib/reorder.test.ts b/packages/vitnode/src/api/modules/admin/navigation/lib/reorder.test.ts index 60ab3b6b29..56e59cb921 100644 --- a/packages/vitnode/src/api/modules/admin/navigation/lib/reorder.test.ts +++ b/packages/vitnode/src/api/modules/admin/navigation/lib/reorder.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import { describe, expect, it } from "vitest"; import { reorderNavigationTree } from "./reorder"; diff --git a/packages/vitnode/src/api/modules/admin/roles/routes/delete.route.test.ts b/packages/vitnode/src/api/modules/admin/roles/routes/delete.route.test.ts index 3d6b97d117..6b790accb1 100644 --- a/packages/vitnode/src/api/modules/admin/roles/routes/delete.route.test.ts +++ b/packages/vitnode/src/api/modules/admin/roles/routes/delete.route.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import type { Context } from "hono"; import { OpenAPIHono } from "@hono/zod-openapi"; diff --git a/packages/vitnode/src/api/modules/admin/roles/routes/list-mapping.test.ts b/packages/vitnode/src/api/modules/admin/roles/routes/list-mapping.test.ts index 2ca10e2126..0185fa2213 100644 --- a/packages/vitnode/src/api/modules/admin/roles/routes/list-mapping.test.ts +++ b/packages/vitnode/src/api/modules/admin/roles/routes/list-mapping.test.ts @@ -1,43 +1,9 @@ -// @vitest-environment node -import { readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import type { RoleNameWord, RoleUsersCount } from "./list-mapping"; import { withRolesAdminListFields } from "./list-mapping"; -const here = dirname(fileURLToPath(import.meta.url)); - -const source = readFileSync(join(here, "list.route.ts"), "utf8"); - -/** - * The mapping the route used to do, scanned rather than indexed. - * - * Kept as the thing the indexed version has to keep agreeing with: the same - * names in the same order, the same counts, the same fallbacks. - */ -const scanned = ({ - adminRoleIds, - names, - roles, - userCounts, -}: { - adminRoleIds: ReadonlySet; - names: RoleNameWord[]; - roles: { id: number }[]; - userCounts: RoleUsersCount[]; -}) => - roles.map(role => ({ - ...role, - name: names - .filter(word => word.itemId === role.id) - .map(word => ({ name: word.value, languageCode: word.languageCode })), - usersCount: userCounts.find(item => item.roleId === role.id)?.total ?? 0, - grantsAdmin: adminRoleIds.has(role.id), - })); - const ROLES = [ { color: null, id: 3 }, { color: "#123456", id: 1 }, @@ -61,27 +27,14 @@ const COUNTS: RoleUsersCount[] = [ const ADMIN_ROLE_IDS = new Set([3, null]); -const page = ( - overrides: Partial[0]> = {}, -): Parameters[0] => ({ +const page = () => ({ adminRoleIds: ADMIN_ROLE_IDS, names: NAMES, roles: ROLES, userCounts: COUNTS, - ...overrides, }); describe("the role list's derived fields", () => { - it("answer exactly what the scan answered", () => { - expect(withRolesAdminListFields(page())).toEqual(scanned(page())); - }); - - it("serialize to exactly the same JSON, keys and order included", () => { - expect(JSON.stringify(withRolesAdminListFields(page()))).toBe( - JSON.stringify(scanned(page())), - ); - }); - it("keep the page's own role order", () => { expect(withRolesAdminListFields(page()).map(role => role.id)).toEqual([ 3, 1, 9, 4, @@ -119,23 +72,4 @@ describe("the role list's derived fields", () => { withRolesAdminListFields(page()).map(role => role.grantsAdmin), ).toEqual([true, false, false, false]); }); - - it.each([ - ["an empty page", { roles: [] }], - ["nothing loaded at all", { names: [], userCounts: [] }], - ["no admin role", { adminRoleIds: new Set() }], - ["one role translated many times", { roles: [{ id: 1 }, { id: 1 }] }], - ])("agrees with the scan on %s", (_name, overrides) => { - expect(withRolesAdminListFields(page(overrides))).toEqual( - scanned(page(overrides)), - ); - }); -}); - -describe("the route itself", () => { - it("maps through the indexed helper rather than scanning per role", () => { - expect(source).toContain("withRolesAdminListFields({"); - expect(source).not.toContain("names.filter("); - expect(source).not.toContain("userCounts.find("); - }); }); diff --git a/packages/vitnode/src/api/modules/admin/roles/routes/list.route.test.ts b/packages/vitnode/src/api/modules/admin/roles/routes/list.route.test.ts index 700d40933d..4665d5cc5f 100644 --- a/packages/vitnode/src/api/modules/admin/roles/routes/list.route.test.ts +++ b/packages/vitnode/src/api/modules/admin/roles/routes/list.route.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import type { Context } from "hono"; import { OpenAPIHono } from "@hono/zod-openapi"; diff --git a/packages/vitnode/src/api/modules/admin/routes/session.route.test.ts b/packages/vitnode/src/api/modules/admin/routes/session.route.test.ts index 0b40bca61e..d557339468 100644 --- a/packages/vitnode/src/api/modules/admin/routes/session.route.test.ts +++ b/packages/vitnode/src/api/modules/admin/routes/session.route.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import type { Context } from "hono"; import { OpenAPIHono } from "@hono/zod-openapi"; diff --git a/packages/vitnode/src/api/modules/admin/staff/routes/staff-entry-routes.test.ts b/packages/vitnode/src/api/modules/admin/staff/routes/staff-entry-routes.test.ts index 5a3734d613..fb14187bac 100644 --- a/packages/vitnode/src/api/modules/admin/staff/routes/staff-entry-routes.test.ts +++ b/packages/vitnode/src/api/modules/admin/staff/routes/staff-entry-routes.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import type { Context } from "hono"; import { OpenAPIHono } from "@hono/zod-openapi"; diff --git a/packages/vitnode/src/api/modules/admin/users/lib/assert-edit-user-permission.test.ts b/packages/vitnode/src/api/modules/admin/users/lib/assert-edit-user-permission.test.ts index 6f74677af3..00c1d10bec 100644 --- a/packages/vitnode/src/api/modules/admin/users/lib/assert-edit-user-permission.test.ts +++ b/packages/vitnode/src/api/modules/admin/users/lib/assert-edit-user-permission.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import type { Context } from "hono"; import { HTTPException } from "hono/http-exception"; diff --git a/packages/vitnode/src/api/modules/admin/users/routes/devices.route.test.ts b/packages/vitnode/src/api/modules/admin/users/routes/devices.route.test.ts index cd86e606c4..b0f9e04c39 100644 --- a/packages/vitnode/src/api/modules/admin/users/routes/devices.route.test.ts +++ b/packages/vitnode/src/api/modules/admin/users/routes/devices.route.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import type { Context } from "hono"; import { OpenAPIHono } from "@hono/zod-openapi"; diff --git a/packages/vitnode/src/api/modules/admin/users/routes/notification-preferences.route.test.ts b/packages/vitnode/src/api/modules/admin/users/routes/notification-preferences.route.test.ts index ce8c0fde48..27b04e1e9d 100644 --- a/packages/vitnode/src/api/modules/admin/users/routes/notification-preferences.route.test.ts +++ b/packages/vitnode/src/api/modules/admin/users/routes/notification-preferences.route.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import type { Context } from "hono"; import { OpenAPIHono } from "@hono/zod-openapi"; diff --git a/packages/vitnode/src/api/modules/admin/users/routes/passkeys.route.test.ts b/packages/vitnode/src/api/modules/admin/users/routes/passkeys.route.test.ts index 97c7ba0ca6..6d6b1d25ec 100644 --- a/packages/vitnode/src/api/modules/admin/users/routes/passkeys.route.test.ts +++ b/packages/vitnode/src/api/modules/admin/users/routes/passkeys.route.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import type { Context } from "hono"; import { OpenAPIHono } from "@hono/zod-openapi"; diff --git a/packages/vitnode/src/api/modules/admin/users/routes/password.route.test.ts b/packages/vitnode/src/api/modules/admin/users/routes/password.route.test.ts index 124211a1d7..b6d0b07bcb 100644 --- a/packages/vitnode/src/api/modules/admin/users/routes/password.route.test.ts +++ b/packages/vitnode/src/api/modules/admin/users/routes/password.route.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import type { Context } from "hono"; import { OpenAPIHono } from "@hono/zod-openapi"; diff --git a/packages/vitnode/src/api/modules/admin/users/routes/show.route.test.ts b/packages/vitnode/src/api/modules/admin/users/routes/show.route.test.ts deleted file mode 100644 index 3d687ad8ad..0000000000 --- a/packages/vitnode/src/api/modules/admin/users/routes/show.route.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -// @vitest-environment node -import type { Context } from "hono"; - -import { OpenAPIHono } from "@hono/zod-openapi"; -import { describe, expect, it } from "vitest"; - -import type { PermissionsStaffArgs } from "@/api/lib/permission-staff"; - -import { core_admin_permissions } from "@/database/admins"; -import { core_moderators_permissions } from "@/database/moderators"; -import { core_roles } from "@/database/roles"; -import { core_users, core_users_secondary_roles } from "@/database/users"; -import { createTestCache } from "@/tests/cache"; -import { createMemoryDb } from "@/tests/memory-db"; -import { grantStaffPermissions } from "@/tests/staff-permissions"; - -import { showUserAdminRoute } from "./show.route"; - -const ROOT_ROLE = 1; -const VIEWER_ROLE = 2; -const MEMBER_ROLE = 3; - -const VIEWER = { email: "viewer@example.com", id: 1, roleId: VIEWER_ROLE }; -const TARGET_ID = 7; - -const CAN_VIEW: PermissionsStaffArgs = { - module: "users", - permission: "can_view", - plugin: "@vitnode/core", -}; - -type TargetShape = - | "admin" - | "member" - | "moderator" - | "root via a secondary role"; - -const entryRow = (entry: Record) => ({ - permissions: [], - roleId: null, - unrestricted: false, - userId: null, - ...entry, -}); - -const showTarget = async (target: TargetShape) => { - const cache = createTestCache(); - await grantStaffPermissions(cache, { - permissions: [CAN_VIEW], - userId: VIEWER.id, - }); - - const memory = createMemoryDb([ - [ - core_users, - [ - VIEWER, - { - email: "target@example.com", - id: TARGET_ID, - name: "Target", - nameCode: "target", - roleId: MEMBER_ROLE, - }, - ], - ], - [ - core_users_secondary_roles, - target === "root via a secondary role" - ? [{ roleId: ROOT_ROLE, userId: TARGET_ID }] - : [], - ], - [ - core_roles, - [ - { guest: false, id: ROOT_ROLE, root: true }, - { guest: false, id: VIEWER_ROLE, root: false }, - { guest: false, id: MEMBER_ROLE, root: false }, - ], - ], - [ - core_admin_permissions, - [ - entryRow({ permissions: [CAN_VIEW], roleId: VIEWER_ROLE }), - ...(target === "admin" ? [entryRow({ userId: TARGET_ID })] : []), - ], - ], - [ - core_moderators_permissions, - target === "moderator" ? [entryRow({ userId: TARGET_ID })] : [], - ], - ]); - - const app = new OpenAPIHono(); - app.use("*", async (c, next) => { - c.set("admin", { user: VIEWER } as unknown as Context["var"]["admin"]); - c.set("cache", cache); - c.set("db", memory.db as unknown as Context["var"]["db"]); - await next(); - }); - app.openapi(showUserAdminRoute.route, showUserAdminRoute.handler); - - return await app.request(`/${TARGET_ID}`); -}; - -describe("GET /admin/users/{id} - staff flag", () => { - it.each(["moderator", "root via a secondary role", "admin"])( - "reports a target who is %s as staff", - async target => { - const response = await showTarget(target); - - expect(response.status).toBe(200); - expect(await response.json()).toMatchObject({ - id: TARGET_ID, - isStaff: true, - }); - }, - ); - - it("reports a plain member as not staff", async () => { - const response = await showTarget("member"); - - expect(response.status).toBe(200); - expect(await response.json()).toMatchObject({ - id: TARGET_ID, - isStaff: false, - }); - }); -}); diff --git a/packages/vitnode/src/api/modules/admin/users/routes/sso.route.test.ts b/packages/vitnode/src/api/modules/admin/users/routes/sso.route.test.ts index 184163c775..7c5331522b 100644 --- a/packages/vitnode/src/api/modules/admin/users/routes/sso.route.test.ts +++ b/packages/vitnode/src/api/modules/admin/users/routes/sso.route.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import type { Context } from "hono"; import { OpenAPIHono } from "@hono/zod-openapi"; diff --git a/packages/vitnode/src/api/modules/admin/users/routes/update.route.test.ts b/packages/vitnode/src/api/modules/admin/users/routes/update.route.test.ts index fc5cd74936..4f25bb58e2 100644 --- a/packages/vitnode/src/api/modules/admin/users/routes/update.route.test.ts +++ b/packages/vitnode/src/api/modules/admin/users/routes/update.route.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import type { Context } from "hono"; import { OpenAPIHono } from "@hono/zod-openapi"; @@ -29,7 +28,6 @@ import { updateUserAdminRoute } from "./update.route"; const ROOT_ROLE = 1; const EDITOR_ROLE = 2; const MEMBER_ROLE = 3; -const PLUGINS_ROLE = 9; const EDITOR = { email: "editor@example.com", id: 1, roleId: EDITOR_ROLE }; const TARGET_ID = 7; @@ -93,7 +91,6 @@ const harness = async ({ { guest: false, id: ROOT_ROLE, root: true }, { guest: false, id: EDITOR_ROLE, root: false }, { guest: false, id: MEMBER_ROLE, root: false }, - { guest: false, id: PLUGINS_ROLE, root: false }, ], ], [ @@ -103,10 +100,6 @@ const harness = async ({ permissions: [CAN_EDIT, CAN_EDIT_ADMIN], roleId: EDITOR_ROLE, }), - entryRow({ - permissions: [permission("can_manage", "plugins")], - roleId: PLUGINS_ROLE, - }), ], ], [ @@ -225,12 +218,6 @@ describe("PATCH /admin/users/{id} - privilege ceiling", () => { TARGET_ID, { secondaryRoleIds: [ROOT_ROLE] }, ], - [ - "as a secondary role of themselves", - EDITOR.id, - { secondaryRoleIds: [ROOT_ROLE] }, - ], - ["as the primary role of themselves", EDITOR.id, { roleId: ROOT_ROLE }], ])( "refuses a non-root admin assigning the root role %s", async (_label, userId, body) => { @@ -254,24 +241,6 @@ describe("PATCH /admin/users/{id} - privilege ceiling", () => { expect(response.status).toBe(200); expect(h.secondaryRoleIdsOf(TARGET_ID)).toEqual([ROOT_ROLE]); }); - - it("refuses a role carrying an admin permission the editor lacks", async () => { - const h = await harness({ editor: ELEVATED }); - - const response = await h.patch(TARGET_ID, { roleId: PLUGINS_ROLE }); - - expect(response.status).toBe(403); - expect(h.userRow(TARGET_ID)?.roleId).toBe(MEMBER_ROLE); - }); - - it("allows a role whose permissions the editor holds", async () => { - const h = await harness({ editor: ELEVATED }); - - const response = await h.patch(TARGET_ID, { roleId: EDITOR_ROLE }); - - expect(response.status).toBe(200); - expect(h.userRow(TARGET_ID)?.roleId).toBe(EDITOR_ROLE); - }); }); describe("PATCH /admin/users/{id} - personal information and preferences", () => { diff --git a/packages/vitnode/src/api/modules/ai/routes/stream.route.test.ts b/packages/vitnode/src/api/modules/ai/routes/stream.route.test.ts index 9fc2b04d01..8a2f952099 100644 --- a/packages/vitnode/src/api/modules/ai/routes/stream.route.test.ts +++ b/packages/vitnode/src/api/modules/ai/routes/stream.route.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import type { Context } from "hono"; import { OpenAPIHono } from "@hono/zod-openapi"; diff --git a/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.test.ts b/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.test.ts index 46ef9a0f61..9a0f103a77 100644 --- a/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.test.ts +++ b/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import type { SQL } from "drizzle-orm"; import type { Context } from "hono"; diff --git a/packages/vitnode/src/api/modules/cron/cron/clean.cron.test.ts b/packages/vitnode/src/api/modules/cron/cron/clean.cron.test.ts deleted file mode 100644 index 6d66ffbac9..0000000000 --- a/packages/vitnode/src/api/modules/cron/cron/clean.cron.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { PgDialect } from "drizzle-orm/pg-core"; -import { drizzle } from "drizzle-orm/postgres-js"; -import { describe, expect, it } from "vitest"; - -import { isOrphanedDevice } from "./clean.cron"; - -describe("isOrphanedDevice", () => { - it("keeps devices referenced by public or admin sessions", () => { - const { sql } = new PgDialect().sqlToQuery( - isOrphanedDevice(drizzle.mock()), - ); - - expect(sql.match(/not exists/g)).toHaveLength(2); - expect(sql).toContain('from "core_sessions"'); - expect(sql).toContain('from "core_admin_sessions"'); - expect(sql).toContain( - '"core_sessions"."deviceId" = "core_sessions_known_devices"."id"', - ); - expect(sql).toContain( - '"core_admin_sessions"."deviceId" = "core_sessions_known_devices"."id"', - ); - }); -}); diff --git a/packages/vitnode/src/api/modules/middleware/route.test.ts b/packages/vitnode/src/api/modules/middleware/route.test.ts index 6a5b1f3601..4098a0a866 100644 --- a/packages/vitnode/src/api/modules/middleware/route.test.ts +++ b/packages/vitnode/src/api/modules/middleware/route.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import type { Context } from "hono"; import { OpenAPIHono } from "@hono/zod-openapi"; diff --git a/packages/vitnode/src/api/modules/pages/page-layout-routes.test.ts b/packages/vitnode/src/api/modules/pages/page-layout-routes.test.ts index 5f7f415294..a5a735f4f5 100644 --- a/packages/vitnode/src/api/modules/pages/page-layout-routes.test.ts +++ b/packages/vitnode/src/api/modules/pages/page-layout-routes.test.ts @@ -1,8 +1,7 @@ -// @vitest-environment node import type { MiddlewareHandler } from "hono"; import { OpenAPIHono } from "@hono/zod-openapi"; -import { beforeAll, describe, expect, it } from "vitest"; +import { beforeAll, describe, expect, it, vi } from "vitest"; import type { PermissionsStaffArgs } from "@/api/lib/permission-staff"; import type { AnyEditablePageDefinition } from "@/content/editor/types"; @@ -170,26 +169,18 @@ const harness = async ({ ]), ); const events: EmittedEvent[] = []; - const statements: string[] = []; const writes: string[] = []; const db = { - execute: async () => { - statements.push("lock"); - - return await Promise.resolve([]); - }, + execute: async () => await Promise.resolve([]), transaction: async ( body: (tx: typeof db) => Promise, ): Promise => { - statements.push("begin"); - return await body(db); }, delete: () => ({ where: async (condition: unknown) => { const pageId = pageIdOf(condition); - statements.push("delete"); writes.push(`delete ${pageId}`); store.delete(pageId); @@ -200,7 +191,6 @@ const harness = async ({ values: (values: StoredRow) => ({ onConflictDoUpdate: () => ({ returning: async () => { - statements.push("upsert"); writes.push(`upsert ${values.pageId}`); store.set(values.pageId, values); @@ -213,7 +203,6 @@ const harness = async ({ from: () => ({ where: (condition: unknown) => ({ limit: async () => { - statements.push("select"); if (readFails) throw new Error("connection terminated"); const row = store.get(pageIdOf(condition)); @@ -247,7 +236,7 @@ const harness = async ({ app.openapi(read.route, read.handler); app.openapi(write.route, write.handler); - return { app, events, grant, statements, store, writes }; + return { app, events, grant, store, writes }; }; const save = async (app: OpenAPIHono, body: unknown) => @@ -260,24 +249,6 @@ const save = async (app: OpenAPIHono, body: unknown) => const read = async (app: OpenAPIHono, pageId: string) => await app.request(`/layout?pageId=${encodeURIComponent(pageId)}`); -describe("route shape", () => { - it("mounts one public read and one guarded save", () => { - const routes = buildPageLayoutRoutes({ pluginId: PLUGIN_ID }).map( - entry => ({ - method: entry.route.method, - middleware: (entry.route as unknown as { middleware: unknown[] }) - .middleware.length, - path: entry.route.path, - }), - ); - - expect(routes).toEqual([ - { method: "get", middleware: 1, path: "/layout" }, - { method: "put", middleware: 1, path: "/layout" }, - ]); - }); -}); - describe("GET /layout", () => { it("answers the effective layout without asking for a permission", async () => { const { app } = await harness({ @@ -308,22 +279,13 @@ describe("GET /layout", () => { it("fails rather than answering defaults when the read fails", async () => { const { app } = await harness({ readFails: true }); + const logged = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); expect((await read(app, "example:settings")).status).toBe(500); - }); - - it("answers a repeated read from the cache without querying the database", async () => { - const { app, statements } = await harness({ - permissions: [], - rows: { "example:settings": { sidebar: [quote("s1", "Stored")] } }, - }); - - const first = await read(app, "example:settings"); - const second = await read(app, "example:settings"); - - expect(second.status).toBe(200); - expect(await second.json()).toEqual(await first.json()); - expect(statements).toEqual(["select"]); + expect(logged).toHaveBeenCalled(); + logged.mockRestore(); }); it("answers the saved layout on the next read after a save", async () => { @@ -402,41 +364,6 @@ describe("PUT /layout", () => { expect(events).toEqual([]); }); - it("reads and writes inside one locked transaction", async () => { - const { app, statements } = await harness({ - rows: { "example:settings": { sidebar: [quote("s1", "Old")] } }, - }); - - expect( - ( - await save(app, { - expectedZones: { sidebar: [quote("s1", "Old")] }, - pageId: "example:settings", - zones: { sidebar: [quote("s1", "New")] }, - }) - ).status, - ).toBe(200); - expect(statements).toEqual(["begin", "lock", "select", "upsert"]); - }); - - it("stores nothing when a zone is saved back to its shipped default", async () => { - const { app, events, writes } = await harness(); - - const response = await save(app, { - expectedZones: { intro: [text("intro-shipped", "Shipped intro")] }, - pageId: "example:settings", - zones: { intro: [text("intro-shipped", "Shipped intro")] }, - }); - - expect(response.status).toBe(200); - expect(await response.json()).toMatchObject({ - updatedAt: null, - zones: { intro: [text("intro-shipped", "Shipped intro", "prose")] }, - }); - expect(writes).toEqual([]); - expect(events).toEqual([]); - }); - it("keeps an area and counts the blocks inside it", async () => { const { app, store } = await harness(); @@ -495,50 +422,6 @@ describe("a save built on a zone somebody else already moved", () => { expect(store.get("example:settings")?.updatedAt).toBe(STORED_AT); }); - it("is a 200 that writes nothing when it asks for what is already stored", async () => { - const { app, events, writes } = await harness({ - rows: { "example:settings": { sidebar: [quote("s1", "Two")] } }, - }); - - const response = await save(app, { - expectedZones: { sidebar: [quote("s1", "One")] }, - pageId: "example:settings", - zones: { sidebar: [quote("s1", "Two")] }, - }); - - expect(response.status).toBe(200); - expect(writes).toEqual([]); - expect(events).toEqual([]); - }); - - it("does not stand in the way of two people editing different zones", async () => { - const { app, store } = await harness({ - rows: { - "example:settings": { - "after-profile": [quote("a1", "After one")], - sidebar: [quote("s1", "Sidebar one")], - }, - }, - }); - - const first = await save(app, { - expectedZones: { sidebar: [quote("s1", "Sidebar one")] }, - pageId: "example:settings", - zones: { sidebar: [quote("s1", "Sidebar two")] }, - }); - const second = await save(app, { - expectedZones: { "after-profile": [quote("a1", "After one")] }, - pageId: "example:settings", - zones: { "after-profile": [quote("a1", "After two")] }, - }); - - expect([first.status, second.status]).toEqual([200, 200]); - expect(store.get("example:settings")?.zones).toEqual({ - "after-profile": [quote("a1", "After two")], - sidebar: [quote("s1", "Sidebar two")], - }); - }); - it("does not refuse the very save that brings a zone back inside its bounds", async () => { const overfull = [ hero("h1", "One"), diff --git a/packages/vitnode/src/api/modules/search/routes/timeline.route.test.ts b/packages/vitnode/src/api/modules/search/routes/timeline.route.test.ts index 508cddd041..18d7568933 100644 --- a/packages/vitnode/src/api/modules/search/routes/timeline.route.test.ts +++ b/packages/vitnode/src/api/modules/search/routes/timeline.route.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import type { Context } from "hono"; import { OpenAPIHono } from "@hono/zod-openapi"; diff --git a/packages/vitnode/src/api/modules/search/tasks/rebuild-index.task.test.ts b/packages/vitnode/src/api/modules/search/tasks/rebuild-index.task.test.ts index ea7e488be9..5e3b5c2e59 100644 --- a/packages/vitnode/src/api/modules/search/tasks/rebuild-index.task.test.ts +++ b/packages/vitnode/src/api/modules/search/tasks/rebuild-index.task.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import type { Context } from "hono"; import { describe, expect, it, vi } from "vitest"; @@ -278,106 +277,6 @@ describe("rebuild-search-index", () => { }); describe("the deprecated array result", () => { - it("indexes a legacy page and stops on the empty one", async () => { - const { config, offsets } = scriptedIndexer({ - itemType: "legacy.item", - pages: [[document("legacy.item", 1)], []], - pluginId: "@vitnode/legacy", - }); - const { c, indexed } = harness([config]); - - await rebuildSearchIndexTask.handler(c, {}); - - // No source count to advance by, so the cursor moves a whole page - which - // is exactly what the old rebuild did. - expect(offsets).toEqual([0, 200]); - expect(indexed).toHaveLength(1); - expect(indexed[0]?.[0]?.itemId).toBe(1); - }); - - it("stamps the registering plugin on a legacy document", async () => { - const { config } = scriptedIndexer({ - itemType: "legacy.item", - pages: [[document("legacy.item", 1)], []], - pluginId: "@vitnode/legacy", - }); - const { c, indexed } = harness([config]); - - await rebuildSearchIndexTask.handler(c, {}); - - expect(indexed[0]?.[0]?.pluginId).toBe("@vitnode/legacy"); - }); - - it("keeps an owner a legacy document declared itself", async () => { - const { config } = scriptedIndexer({ - itemType: "legacy.item", - pages: [ - [document("legacy.item", 1, { pluginId: "@vitnode/elsewhere" })], - [], - ], - pluginId: "@vitnode/legacy", - }); - const { c, indexed } = harness([config]); - - await rebuildSearchIndexTask.handler(c, {}); - - expect(indexed[0]?.[0]?.pluginId).toBe("@vitnode/elsewhere"); - }); - - it("treats a blank declared owner as absent", async () => { - const { config } = scriptedIndexer({ - itemType: "legacy.item", - pages: [[document("legacy.item", 1, { pluginId: " " })], []], - pluginId: "@vitnode/legacy", - }); - const { c, indexed } = harness([config]); - - await rebuildSearchIndexTask.handler(c, {}); - - expect(indexed[0]?.[0]?.pluginId).toBe("@vitnode/legacy"); - }); - - it("does not mistake a multilingual document count for a source count", async () => { - // Four documents, two source items, two languages. Advancing by the array - // length would jump to offset 4 and skip most of a 200-row page. - const { config, offsets } = scriptedIndexer({ - itemType: "legacy.multilingual", - pages: [ - [ - document("legacy.multilingual", 1, { languageCode: "en" }), - document("legacy.multilingual", 1, { languageCode: "pl" }), - document("legacy.multilingual", 2, { languageCode: "en" }), - document("legacy.multilingual", 2, { languageCode: "pl" }), - ], - [], - ], - pluginId: "@vitnode/legacy", - }); - const { c, indexed } = harness([config]); - - await rebuildSearchIndexTask.handler(c, {}); - - expect(offsets).toEqual([0, 200]); - expect(offsets).not.toContain(4); - expect(indexed[0]).toHaveLength(4); - }); - - it("indexes an empty first page as an exhausted source", async () => { - // The ambiguity the modern contract exists to remove: an array cannot say - // whether rows were read and all filtered out, so this ends the rebuild. - const { config, offsets } = scriptedIndexer({ - itemType: "legacy.item", - pages: [[], [document("legacy.item", 1)]], - pluginId: "@vitnode/legacy", - }); - const { c, indexed } = harness([config]); - - await rebuildSearchIndexTask.handler(c, {}); - - expect(offsets).toEqual([0]); - expect(indexed).toEqual([]); - }); - it("runs alongside a modern indexer", async () => { const legacy = scriptedIndexer({ itemType: "legacy.item", @@ -426,44 +325,6 @@ describe("rebuild-search-index", () => { expect(indexed).toEqual([]); }); - it("refuses even when no indexer is registered at all", async () => { - const { c, search } = harness([]); - - await expect( - rebuildSearchIndexTask.handler(c, { itemType: "removed.collection" }), - ).rejects.toThrow(/removed.collection/); - - expect(search.clear).not.toHaveBeenCalled(); - }); - - it("names the collection it refused", async () => { - const { c } = harness([]); - - await expect( - rebuildSearchIndexTask.handler(c, { itemType: "removed.collection" }), - ).rejects.toThrow(/Cannot rebuild collection "removed.collection"/); - }); - - it("still lets a full rebuild clear the whole index", async () => { - // A full rebuild refills only what has an indexer, so documents without one - // are removed by it. That is the documented behaviour, and it must not be - // blocked by the scoped guard. - const registered = scriptedIndexer({ - itemType: "example.article", - pages: [ - { documents: [document("example.article", 1)], itemsRead: 1 }, - { documents: [], itemsRead: 0 }, - ], - pluginId: "@vitnode/example", - }); - const { c, cleared, indexed } = harness([registered.config]); - - await rebuildSearchIndexTask.handler(c, {}); - - expect(cleared).toEqual([undefined]); - expect(indexed.flat().map(doc => doc.itemId)).toEqual([1]); - }); - it("still lets a full rebuild run with no indexers registered", async () => { const { c, cleared, indexed } = harness([]); diff --git a/packages/vitnode/src/api/modules/users/passkeys/passkeys.test.ts b/packages/vitnode/src/api/modules/users/passkeys/passkeys.test.ts index 58826ccc08..77e1724131 100644 --- a/packages/vitnode/src/api/modules/users/passkeys/passkeys.test.ts +++ b/packages/vitnode/src/api/modules/users/passkeys/passkeys.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import type { Context } from "hono"; import { OpenAPIHono } from "@hono/zod-openapi"; diff --git a/packages/vitnode/src/api/modules/users/routes/credentials-hardening.test.ts b/packages/vitnode/src/api/modules/users/routes/credentials-hardening.test.ts index 34595c9a0a..7a50c5a0b3 100644 --- a/packages/vitnode/src/api/modules/users/routes/credentials-hardening.test.ts +++ b/packages/vitnode/src/api/modules/users/routes/credentials-hardening.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import type { Context } from "hono"; import { OpenAPIHono } from "@hono/zod-openapi"; diff --git a/packages/vitnode/src/api/modules/users/routes/devices.route.test.ts b/packages/vitnode/src/api/modules/users/routes/devices.route.test.ts index bdb90474e1..a5bead6462 100644 --- a/packages/vitnode/src/api/modules/users/routes/devices.route.test.ts +++ b/packages/vitnode/src/api/modules/users/routes/devices.route.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import { describe, expect, it } from "vitest"; import { diff --git a/packages/vitnode/src/api/modules/users/routes/email-verification.test.ts b/packages/vitnode/src/api/modules/users/routes/email-verification.test.ts index 8ad110a09b..3e1f080aba 100644 --- a/packages/vitnode/src/api/modules/users/routes/email-verification.test.ts +++ b/packages/vitnode/src/api/modules/users/routes/email-verification.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import type { Context } from "hono"; import { OpenAPIHono } from "@hono/zod-openapi"; diff --git a/packages/vitnode/src/api/modules/users/routes/password-sign-in.test.ts b/packages/vitnode/src/api/modules/users/routes/password-sign-in.test.ts index 5ee20cf25f..622622e6bb 100644 --- a/packages/vitnode/src/api/modules/users/routes/password-sign-in.test.ts +++ b/packages/vitnode/src/api/modules/users/routes/password-sign-in.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import type { Context } from "hono"; import { OpenAPIHono } from "@hono/zod-openapi"; diff --git a/packages/vitnode/src/api/modules/users/routes/revoke-device.test.ts b/packages/vitnode/src/api/modules/users/routes/revoke-device.test.ts index bb781886b1..f7341d0c5b 100644 --- a/packages/vitnode/src/api/modules/users/routes/revoke-device.test.ts +++ b/packages/vitnode/src/api/modules/users/routes/revoke-device.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import { describe, expect, it } from "vitest"; import { diff --git a/packages/vitnode/src/api/modules/users/routes/session.route.test.ts b/packages/vitnode/src/api/modules/users/routes/session.route.test.ts index eb23b7db97..452297d818 100644 --- a/packages/vitnode/src/api/modules/users/routes/session.route.test.ts +++ b/packages/vitnode/src/api/modules/users/routes/session.route.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import type { Context } from "hono"; import { OpenAPIHono } from "@hono/zod-openapi"; @@ -6,14 +5,12 @@ import { describe, expect, it } from "vitest"; import type { Route } from "@/api/lib/route"; -import { invalidateStaffEntry } from "@/api/lib/staff-permission-cache"; import { core_admin_permissions } from "@/database/admins"; import { core_moderators_permissions } from "@/database/moderators"; import { core_roles } from "@/database/roles"; import { core_users, core_users_secondary_roles } from "@/database/users"; import { createTestCache } from "@/tests/cache"; import { createMemoryDb } from "@/tests/memory-db"; -import { grantStaffPermissions } from "@/tests/staff-permissions"; import { sessionRoute } from "./session.route"; @@ -27,17 +24,12 @@ const USER = { roleId: MEMBER_ROLE, }; -type Shape = "admin" | "member" | "moderator" | "root via a secondary role"; +type Shape = "moderator"; const sessionApp = (shape: null | Shape) => { const { db } = createMemoryDb([ [core_users, [USER]], - [ - core_users_secondary_roles, - shape === "root via a secondary role" - ? [{ roleId: ROOT_ROLE, userId: USER.id }] - : [], - ], + [core_users_secondary_roles, []], [ core_roles, [ @@ -45,19 +37,7 @@ const sessionApp = (shape: null | Shape) => { { id: MEMBER_ROLE, root: false }, ], ], - [ - core_admin_permissions, - shape === "admin" - ? [ - { - permissions: [], - roleId: null, - unrestricted: false, - userId: USER.id, - }, - ] - : [], - ], + [core_admin_permissions, []], [ core_moderators_permissions, shape === "moderator" @@ -73,10 +53,6 @@ const sessionApp = (shape: null | Shape) => { ], ]); const cache = createTestCache(); - const context = { - get: (key: string) => ({ cache, db })[key], - } as unknown as Context; - const app = new OpenAPIHono(); app.use("*", async (c, next) => { c.set("cache", cache); @@ -95,7 +71,7 @@ const sessionApp = (shape: null | Shape) => { }; }; - return { cache, context, db, read }; + return { read }; }; const readSession = async (shape: null | Shape) => @@ -109,53 +85,6 @@ describe("GET /users/session", () => { }); }); - it("reports root through a secondary role as both", async () => { - expect((await readSession("root via a secondary role")).user).toMatchObject( - { isAdmin: true, isModerator: true }, - ); - }); - - it("reports a member as neither", async () => { - expect((await readSession("member")).user).toMatchObject({ - isAdmin: false, - isModerator: false, - }); - }); - - it("reports an admin as an admin, not a moderator", async () => { - expect((await readSession("admin")).user).toMatchObject({ - isAdmin: true, - isModerator: false, - }); - }); - - it("reports cached grants without waiting for the database", async () => { - const session = sessionApp("member"); - await grantStaffPermissions(session.cache, { - permissions: [], - userId: USER.id, - }); - - expect((await session.read()).user).toMatchObject({ - isAdmin: true, - isModerator: false, - }); - }); - - it("stops reporting a revoked moderator once the entry is invalidated", async () => { - const session = sessionApp("moderator"); - expect((await session.read()).user).toMatchObject({ isModerator: true }); - - await session.db.delete(core_moderators_permissions); - expect((await session.read()).user).toMatchObject({ isModerator: true }); - - await invalidateStaffEntry(session.context, { userId: USER.id }); - expect((await session.read()).user).toMatchObject({ - isAdmin: false, - isModerator: false, - }); - }); - it("answers with no user when signed out", async () => { expect(await readSession(null)).toEqual({ user: null }); }); diff --git a/packages/vitnode/src/api/modules/users/routes/time-zone.route.test.ts b/packages/vitnode/src/api/modules/users/routes/time-zone.route.test.ts index a3277ad537..c182534b21 100644 --- a/packages/vitnode/src/api/modules/users/routes/time-zone.route.test.ts +++ b/packages/vitnode/src/api/modules/users/routes/time-zone.route.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import type { Context } from "hono"; import { describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/api/modules/users/sso/connections/connections.test.ts b/packages/vitnode/src/api/modules/users/sso/connections/connections.test.ts index e6f8415322..b7afe991c7 100644 --- a/packages/vitnode/src/api/modules/users/sso/connections/connections.test.ts +++ b/packages/vitnode/src/api/modules/users/sso/connections/connections.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import type { Context } from "hono"; import { OpenAPIHono } from "@hono/zod-openapi"; @@ -1153,25 +1152,4 @@ describe("syncing now, without signing out", () => { }); expect(h.users.get(ALICE.id)?.firstName).toBe("Alicja"); }); - - it("keeps local values the provider did not send", async () => { - const h = prepare(); - - const response = await syncFrom(h, { firstName: " " }); - - expect(await response.json()).toMatchObject({ - results: { avatar: "missing", firstName: "missing" }, - }); - expect(h.users.get(ALICE.id)?.firstName).toBe("Alicja"); - }); - - it("refuses a different account at the provider", async () => { - const h = prepare(); - - const response = await syncFrom(h, { firstName: "Bob", id: "google-bob" }); - - expect(response.status).toBe(409); - expect(await errorOf(response)).toBe("account_mismatch"); - expect(h.users.get(ALICE.id)?.firstName).toBe("Alicja"); - }); }); diff --git a/packages/vitnode/src/api/modules/users/sso/routes/callback.test.ts b/packages/vitnode/src/api/modules/users/sso/routes/callback.test.ts index be40138b14..c120c516ae 100644 --- a/packages/vitnode/src/api/modules/users/sso/routes/callback.test.ts +++ b/packages/vitnode/src/api/modules/users/sso/routes/callback.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import type { Context } from "hono"; import { OpenAPIHono } from "@hono/zod-openapi"; diff --git a/packages/vitnode/src/blocks/area.test.ts b/packages/vitnode/src/blocks/area.test.ts index e3285c7c66..cf9acf29c9 100644 --- a/packages/vitnode/src/blocks/area.test.ts +++ b/packages/vitnode/src/blocks/area.test.ts @@ -206,34 +206,6 @@ describe("walking a content list", () => { }); describe("the layout class table", () => { - it("names a class for every token and no token it does not define", () => { - expect( - Object.keys(AREA_COLUMN_CLASSES) - .map(Number) - .sort((a, b) => a - b), - ).toStrictEqual([...AREA_COLUMNS]); - expect(Object.keys(AREA_ALIGN_CLASSES).sort()).toStrictEqual( - [...AREA_ALIGNS].sort(), - ); - expect(Object.keys(AREA_JUSTIFY_CLASSES).sort()).toStrictEqual( - [...AREA_JUSTIFIES].sort(), - ); - }); - - it("stays on one column until the medium breakpoint", () => { - expect(AREA_COLUMN_CLASSES[1]).toBe("grid-cols-1"); - expect(AREA_COLUMN_CLASSES[2]).toBe("grid-cols-1 md:grid-cols-2"); - expect(AREA_COLUMN_CLASSES[3]).toBe("grid-cols-1 md:grid-cols-3"); - expect(AREA_COLUMN_CLASSES[4]).toBe("grid-cols-1 md:grid-cols-4"); - }); - - it("leaves the gap out, because it is pixels rather than a token", () => { - const classes = areaLayoutClassNames({ columns: 2, gap: 24 }); - - expect(classes).not.toContain("gap-"); - expect(classes).not.toContain("["); - }); - it("is total over every combination of tokens", () => { const seen = new Set(); @@ -262,12 +234,6 @@ describe("the layout class table", () => { areaLayoutClassNames(DEFAULT_AREA_LAYOUT), ); }); - - it("uses only the tokens it was given, with the defaults filled in", () => { - expect(areaLayoutClassNames({ columns: 2 })).toBe( - "grid grid-cols-1 md:grid-cols-2 items-stretch justify-items-stretch", - ); - }); }); describe("spacing measured in pixels", () => { diff --git a/packages/vitnode/src/blocks/boundaries.test.ts b/packages/vitnode/src/blocks/boundaries.test.ts index b10498e2b5..6cf38f75ae 100644 --- a/packages/vitnode/src/blocks/boundaries.test.ts +++ b/packages/vitnode/src/blocks/boundaries.test.ts @@ -1,5 +1,3 @@ -// @vitest-environment node -import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; @@ -40,26 +38,6 @@ describe("block metadata", () => { expect(eagerSpecifiers(entry)).not.toContain("react"); }); - it("does not re-export the renderer or the zone component", () => { - const surface = readFileSync(entry, "utf8"); - - expect(surface).not.toContain("./renderer"); - expect(surface).not.toContain('from "./zone"'); - expect(surface).toContain("./registry"); - expect(surface).toContain("./zone-meta"); - }); - - it("does not re-export the area renderer either", () => { - expect(readFileSync(entry, "utf8")).not.toContain("./area-renderer"); - }); - - it("carries the area model, which is React-free", () => { - const surface = readFileSync(entry, "utf8"); - - expect(surface).toContain("./area"); - expect(surface).toContain("areaLayoutClassNames"); - }); - it("reaches nothing an editor would need", () => { expect(offenders(entry, EDITOR_PACKAGES)).toStrictEqual([]); }); @@ -92,11 +70,6 @@ describe("the area model", () => { expect(reachedSpecifiers(entry)).toStrictEqual([]); }); - it("holds no React, so the editor and the API can both read it", () => { - expect(reachedSpecifiers(entry)).not.toContain("react"); - expect(readFileSync(entry, "utf8")).not.toContain("use client"); - }); - it("reaches no file under the editor", () => { expect(editorFiles(entry)).toStrictEqual([]); }); @@ -134,10 +107,6 @@ describe("the public renderer", () => { it("does not reach a validation library", () => { expect(offenders(entry, ["zod"])).toStrictEqual([]); }); - - it("renders an area through the area renderer rather than its own grid", () => { - expect(readFileSync(entry, "utf8")).toContain("./area-renderer"); - }); }); describe("the structural check the public renderer always makes", () => { @@ -154,11 +123,6 @@ describe("the structural check the public renderer always makes", () => { }); it("is the shape pass, never the write-time validator", () => { - const source = readFileSync(entry, "utf8"); - - expect(source).toContain('from "./shape"'); - expect(source).not.toContain('from "./validate"'); - expect(source).not.toContain('from "./schema"'); expect(reachedFiles(entry)).toContain("blocks/shape.ts"); expect(reachedFiles(entry)).not.toContain("blocks/validate.ts"); }); @@ -166,17 +130,6 @@ describe("the structural check the public renderer always makes", () => { it("reaches that pass through a module with no third-party import at all", () => { expect(reachedSpecifiers(join(here, "shape.ts"))).toStrictEqual([]); }); - - it("is not gated behind a mode, and runs before a component is created", () => { - const source = readFileSync(entry, "utf8"); - - expect(source).toContain( - "const issue = blockDataShapeIssue(entry.definition, instance.data);", - ); - expect(source.indexOf("blockDataShapeIssue")).toBeLessThan( - source.indexOf("createElement(entry.definition.component"), - ); - }); }); describe("the public area renderer", () => { @@ -207,23 +160,6 @@ describe("the public area renderer", () => { ]), ).toStrictEqual([]); }); - - it("stays server-rendered: no client boundary and no state of its own", () => { - const source = readFileSync(entry, "utf8"); - - expect(source).not.toContain("use client"); - expect(source).not.toContain("useState"); - expect(source).not.toContain("useEffect"); - expect(source).not.toContain("useLayoutEffect"); - expect(source).not.toContain("useRef"); - }); - - it("takes the layout classes from the model, never building one by hand", () => { - const source = readFileSync(entry, "utf8"); - - expect(source).toContain("areaLayoutClassNames"); - expect(source).not.toContain("grid-cols-"); - }); }); describe("the public content zone", () => { @@ -264,17 +200,6 @@ describe("the public content zone", () => { offenders(entry, ["drizzle-orm", "drizzle-kit", "hono", "postgres"]), ).toStrictEqual([]); }); - - it("renders through the public renderer rather than its own loop", () => { - expect(readFileSync(entry, "utf8")).toContain("./renderer"); - }); - - it("takes an allowlist, never a content type", () => { - const source = readFileSync(entry, "utf8"); - - expect(source).not.toContain("../content/"); - expect(source).toContain("allowedBlocks"); - }); }); describe("why a zone takes an allowlist and not a content type", () => { diff --git a/packages/vitnode/src/blocks/built-in/links.test.tsx b/packages/vitnode/src/blocks/built-in/links.test.tsx index 99b3d5001f..125e8be83d 100644 --- a/packages/vitnode/src/blocks/built-in/links.test.tsx +++ b/packages/vitnode/src/blocks/built-in/links.test.tsx @@ -1,4 +1,3 @@ -// @vitest-environment jsdom import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; diff --git a/packages/vitnode/src/blocks/capabilities.test.ts b/packages/vitnode/src/blocks/capabilities.test.ts index 8e02ec0e9d..7920b21eb6 100644 --- a/packages/vitnode/src/blocks/capabilities.test.ts +++ b/packages/vitnode/src/blocks/capabilities.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import { describe, expect, it } from "vitest"; import { field } from "../content/fields"; @@ -121,12 +120,6 @@ describe("constraints a block's data could actually satisfy", () => { ).toThrow(/against a maxLength of 3/); }); - it("refuses a number range no value falls inside", () => { - expect(() => - holding({ weight: field.number({ integer: true, max: 1, min: 10 }) }), - ).toThrow(/min 10 greater than max 1/); - }); - it("refuses a whole-number range no whole number falls inside", () => { expect(() => holding({ weight: field.number({ integer: true, max: 0.9, min: 0.1 }) }), @@ -214,16 +207,4 @@ describe("constraints a block's data could actually satisfy", () => { }), ).toThrow(/"seo\.title"[\s\S]*minLength 60 greater than maxLength 4/); }); - - it("still refuses a kind a JSON document cannot hold", () => { - expect(() => holding({ cover: field.file({ maxBytes: 1024 }) })).toThrow( - /Supported kinds/, - ); - }); - - it("still refuses a localized field with the message it always gave", () => { - expect(() => - holding({ title: field.text({ localized: true, required: true }) }), - ).toThrow(/field\.blocks\(\{ localized: true \}\)/); - }); }); diff --git a/packages/vitnode/src/blocks/define.test.ts b/packages/vitnode/src/blocks/define.test.ts index 3039435145..b597692557 100644 --- a/packages/vitnode/src/blocks/define.test.ts +++ b/packages/vitnode/src/blocks/define.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import { describe, expect, it } from "vitest"; import type { AnyBlockVariantDefinition } from "./types"; diff --git a/packages/vitnode/src/blocks/edit-widgets-host.test.tsx b/packages/vitnode/src/blocks/edit-widgets-host.test.tsx index 2bd286fce8..ef1b0bf52f 100644 --- a/packages/vitnode/src/blocks/edit-widgets-host.test.tsx +++ b/packages/vitnode/src/blocks/edit-widgets-host.test.tsx @@ -1,4 +1,3 @@ -// @vitest-environment jsdom import { render, type RenderResult, screen } from "@testing-library/react"; import { act, type ReactNode, useEffect } from "react"; import { describe, expect, it, vi } from "vitest"; @@ -165,16 +164,6 @@ const slidOut = async (): Promise => { }); }; -const shell = (container: HTMLElement): HTMLElement => { - const host = container.firstElementChild; - - if (!(host instanceof HTMLElement)) { - throw new Error("the site shell never rendered"); - } - - return host; -}; - describe("the Edit widgets action the header offers", () => { it("is offered by an editable page the visitor may edit", async () => { render(site(editablePage(true))); @@ -264,42 +253,9 @@ describe("walking away from the page being edited", () => { }); }); -describe("the room the site leaves the editing sidebar", () => { - const reserved = (container: HTMLElement): boolean => - shell(container).className.includes("md:pe-(--editor-sidebar-width)"); - - it("takes none of it while nobody is editing", async () => { - const { container } = render(site(editablePage(true))); - await settled(); - - expect(reserved(container)).toBe(false); - expect(shell(container).className).toContain("transition-[padding]"); - }); - - it("reserves the sidebar's width from the whole site once editing opens", async () => { - const { container } = render(site(editablePage(true))); - await settled(); - await open(); - - expect(reserved(container)).toBe(true); - expect(shell(container).className).toContain("pb-(--editor-sheet-height)"); - }); - - it("keeps the box it pads, so both directions are a transition and not a jump", async () => { - const { container } = render(site(editablePage(true))); - await settled(); - - const before = shell(container); - - await open(); - - expect(shell(container)).toBe(before); - expect(before.style.display).toBe(""); - expect(before.className).toContain("transition-[padding]"); - }); - - it("gives it back the moment editing ends, and keeps the sidebar until it has slid out", async () => { - const { container } = render(site(editablePage(true))); +describe("leaving edit mode", () => { + it("keeps the sidebar until it has slid out", async () => { + render(site(editablePage(true))); await settled(); await open(); @@ -308,8 +264,6 @@ describe("the room the site leaves the editing sidebar", () => { }); await settled(); - expect(reserved(container)).toBe(false); - expect(shell(container).className).toContain("transition-[padding]"); expect(editorIsOpen()).toBe(true); await slidOut(); diff --git a/packages/vitnode/src/blocks/edit.test.tsx b/packages/vitnode/src/blocks/edit.test.tsx index d86311e360..46636db4be 100644 --- a/packages/vitnode/src/blocks/edit.test.tsx +++ b/packages/vitnode/src/blocks/edit.test.tsx @@ -1,4 +1,3 @@ -// @vitest-environment jsdom import { render } from "@testing-library/react"; import { act, diff --git a/packages/vitnode/src/blocks/instance.test.ts b/packages/vitnode/src/blocks/instance.test.ts index ad7f2ee2e1..3832fe0831 100644 --- a/packages/vitnode/src/blocks/instance.test.ts +++ b/packages/vitnode/src/blocks/instance.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import { afterEach, describe, expect, it, vi } from "vitest"; import { diff --git a/packages/vitnode/src/blocks/page-save.test.tsx b/packages/vitnode/src/blocks/page-save.test.tsx index 67ea9a1257..927e5a1038 100644 --- a/packages/vitnode/src/blocks/page-save.test.tsx +++ b/packages/vitnode/src/blocks/page-save.test.tsx @@ -1,4 +1,3 @@ -// @vitest-environment jsdom import { render, screen } from "@testing-library/react"; import { act, type ReactNode } from "react"; import { describe, expect, it, vi } from "vitest"; @@ -12,7 +11,7 @@ import type { BlockComponentProps, BlockData, ContentNode } from "./types"; import { EditablePageSaveRefused } from "../content/editor/adapter"; import { defineEditablePage } from "../content/editor/define"; import { field } from "../content/fields"; -import { isSaveConflict, saveRefusalOf } from "../editor/adapter/refusal"; +import { isSaveConflict } from "../editor/adapter/refusal"; import { defineBlock } from "./define"; import { EditablePage } from "./page"; import { createBlockRegistry } from "./registry"; @@ -112,13 +111,12 @@ describe("a save the server refused", () => { expect(screen.queryByText("Edited")).toBeNull(); }); - it("reaches the editor as words it can put in front of a person", () => { + it("reads a refusal the server marked as a conflict", () => { const refused = new EditablePageSaveRefused(CONFLICT, { conflict: true, pageId: page.id, }); - expect(saveRefusalOf(refused)).toBe(CONFLICT); expect(isSaveConflict(refused)).toBe(true); }); @@ -127,7 +125,6 @@ describe("a save the server refused", () => { pageId: page.id, }); - expect(saveRefusalOf(refused)).toBe("That zone is full."); expect(isSaveConflict(refused)).toBe(false); expect(isSaveConflict(new Error("fetch failed"))).toBe(false); expect(isSaveConflict({ conflict: true })).toBe(false); diff --git a/packages/vitnode/src/blocks/page.test.tsx b/packages/vitnode/src/blocks/page.test.tsx index 8277705865..300fdc9197 100644 --- a/packages/vitnode/src/blocks/page.test.tsx +++ b/packages/vitnode/src/blocks/page.test.tsx @@ -1,4 +1,3 @@ -// @vitest-environment jsdom import { render, screen } from "@testing-library/react"; import { act, type ReactNode } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/blocks/registry.test.ts b/packages/vitnode/src/blocks/registry.test.ts index eabb7a95be..e580f74076 100644 --- a/packages/vitnode/src/blocks/registry.test.ts +++ b/packages/vitnode/src/blocks/registry.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import { describe, expect, it } from "vitest"; import type { BlockPluginSource } from "./types"; diff --git a/packages/vitnode/src/blocks/renderer.test.tsx b/packages/vitnode/src/blocks/renderer.test.tsx index b996cc7556..dfdd03bbf6 100644 --- a/packages/vitnode/src/blocks/renderer.test.tsx +++ b/packages/vitnode/src/blocks/renderer.test.tsx @@ -1,4 +1,3 @@ -// @vitest-environment jsdom import { render, screen } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -192,6 +191,7 @@ describe("ContentRenderer", () => { }); it("accepts a fallback of the application's own", () => { + vi.spyOn(console, "warn").mockImplementation(() => undefined); render( { }); it("hands the fallback the reason, so an application can render its own", () => { + vi.spyOn(console, "warn").mockImplementation(() => undefined); render( - screen - .getByRole("alertdialog") - .querySelector("[data-slot=alert-dialog-media]"); - describe("ConfirmActionAlertDialog", () => { - it("shows the icon above the title in the compact layout", async () => { - render( - } - onSubmit={() => {}} - title="Delete chat?" - > - - , - ); - fireEvent.click(screen.getByRole("button", { name: "Delete" })); - - expect(await screen.findByText("Delete chat?")).toBeTruthy(); - expect(dialogMedia()?.querySelector("svg")?.classList).toContain( - "lucide-trash-2", - ); - expect(screen.getByRole("alertdialog").dataset.size).toBe("sm"); - }); - - it("keeps the default layout without an icon", async () => { - render( - {}} title="Delete chat?"> - - , - ); - fireEvent.click(screen.getByRole("button", { name: "Delete" })); - - expect(await screen.findByText("Delete chat?")).toBeTruthy(); - expect(dialogMedia()).toBeNull(); - expect(screen.getByRole("alertdialog").dataset.size).toBe("default"); - }); - it("locks cancel while the confirmation is still submitting", async () => { let finishSubmit = () => {}; render( @@ -69,16 +36,16 @@ describe("ConfirmActionAlertDialog", () => { }), ); - await vi.waitFor(() => { + await waitFor(() => { expect(cancel.hasAttribute("disabled")).toBe(true); }); fireEvent.click(cancel); expect(screen.getByRole("alertdialog")).toBeTruthy(); - act(() => { + await act(async () => { finishSubmit(); }); - await vi.waitFor(() => { + await waitFor(() => { expect(cancel.hasAttribute("disabled")).toBe(false); }); }); diff --git a/packages/vitnode/src/components/form/auto-form.test.tsx b/packages/vitnode/src/components/form/auto-form.test.tsx deleted file mode 100644 index c0beae776a..0000000000 --- a/packages/vitnode/src/components/form/auto-form.test.tsx +++ /dev/null @@ -1,296 +0,0 @@ -// @vitest-environment jsdom -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import React from "react"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import z from "zod"; - -import type { ItemAutoFormComponentProps } from "./auto-form"; - -import { FormControl, FormMessage } from "../ui/form"; -import { AutoForm } from "./auto-form"; -import { AutoFormDesc } from "./common/desc"; - -afterEach(() => { - vi.unstubAllGlobals(); -}); - -const LabelContext = React.createContext("first label"); - -const formSchema = z.object({ title: z.string() }); - -let mountCount = 0; - -const TitleField = ({ field }: ItemAutoFormComponentProps) => { - const [id] = React.useState(() => `title-control-${(mountCount += 1)}`); - const label = React.use(LabelContext); - - return ( - - ); -}; - -const Fields = React.memo(() => ( - -)); -Fields.displayName = "Fields"; - -const Harness = () => { - const [label, setLabel] = React.useState("first label"); - - return ( - - - - - ); -}; - -describe("a field component that reads context and keeps its own state", () => { - it("survives a context change that re-renders it alone", () => { - render(); - const input = screen.getByLabelText("first label"); - fireEvent.change(input, { target: { value: "Hello" } }); - - fireEvent.click(screen.getByRole("button", { name: "rename" })); - - const renamed = screen.getByLabelText("second label"); - expect(renamed.value).toBe("Hello"); - expect(renamed.id).toBe(input.id); - }); -}); - -const requiredSchema = z.object({ - title: z.string().min(1, { message: "title is required" }).default(""), -}); - -const RequiredTitleField = ({ field }: ItemAutoFormComponentProps) => ( - <> - - - -); - -describe("a form that can be submitted while invalid", () => { - it("keeps the submit button enabled, reveals the errors and focuses the first invalid field", async () => { - const onSubmit = vi.fn(); - render( - , - ); - const submit = screen.getByRole("button", { name: "core.global.submit" }); - - expect(submit).toHaveProperty("disabled", false); - expect(screen.queryByText("title is required")).toBeNull(); - - fireEvent.click(submit); - - expect(await screen.findByText("title is required")).toBeDefined(); - await waitFor(() => { - expect(document.activeElement).toBe(screen.getByLabelText("title")); - }); - expect(onSubmit).not.toHaveBeenCalled(); - }); - - it("shows a field's error once it has been left, then keeps it live", async () => { - render( - , - ); - const input = screen.getByLabelText("title"); - - fireEvent.change(input, { target: { value: "Hello" } }); - fireEvent.change(input, { target: { value: "" } }); - expect(screen.queryByText("title is required")).toBeNull(); - - fireEvent.blur(input); - expect(await screen.findByText("title is required")).toBeDefined(); - - fireEvent.change(input, { target: { value: "Hello again" } }); - await waitFor(() => { - expect(screen.queryByText("title is required")).toBeNull(); - }); - }); - - it("reveals the errors on click without calling onSubmit", async () => { - const onSubmit = vi.fn(); - render( - , - ); - const submit = screen.getByRole("button", { name: "core.global.submit" }); - expect(screen.queryByText("title is required")).toBeNull(); - - expect(submit).toHaveProperty("disabled", false); - fireEvent.click(submit); - - expect(await screen.findByText("title is required")).toBeDefined(); - expect(onSubmit).not.toHaveBeenCalled(); - }); - - it("submits once the values are valid", async () => { - const onSubmit = vi.fn(); - render( - , - ); - - fireEvent.change(screen.getByLabelText("title"), { - target: { value: "Hello" }, - }); - fireEvent.click(screen.getByRole("button", { name: "core.global.submit" })); - - await waitFor(() => { - expect(onSubmit).toHaveBeenCalledWith( - { title: "Hello" }, - expect.anything(), - expect.anything(), - ); - }); - }); -}); - -const DescribedTitleField = ({ field }: ItemAutoFormComponentProps) => ( - <> - - - - - Shown on the card - - -); - -const describedTextOf = (element: HTMLElement) => - (element.getAttribute("aria-describedby") ?? "") - .split(" ") - .map(id => document.getElementById(id)?.textContent) - .filter(Boolean); - -describe("a field control described by its description and message", () => { - it("points aria-describedby at the description, then the error too", async () => { - render( - , - ); - const input = screen.getByLabelText("title"); - - expect(describedTextOf(input)).toEqual(["Shown on the card"]); - - fireEvent.click(screen.getByRole("button", { name: "core.global.submit" })); - - await waitFor(() => { - expect(describedTextOf(input)).toEqual([ - "Shown on the card", - "title is required", - ]); - }); - }); -}); - -const tabbedSchema = z.object({ - title: z.string().default("Hello"), - slug: z.string().min(1, { message: "slug is required" }).default(""), -}); - -const SlugField = ({ field }: ItemAutoFormComponentProps) => ( - <> - - - -); - -describe("a tabbed form with an invalid field on another tab", () => { - it("marks the tab, switches to it and focuses the field", async () => { - vi.stubGlobal( - "ResizeObserver", - class { - disconnect() {} - observe() {} - unobserve() {} - }, - ); - render( - , - ); - - expect(screen.queryByText("core.global.tab_has_errors")).toBeNull(); - - fireEvent.click(screen.getByRole("button", { name: "core.global.submit" })); - - expect(await screen.findByText("core.global.tab_has_errors")).toBeDefined(); - await waitFor(() => { - expect(document.activeElement).toBe(screen.getByLabelText("slug")); - }); - expect( - screen - .getByRole("tab", { name: /Advanced/ }) - .getAttribute("aria-selected"), - ).toBe("true"); - }); -}); - -describe("the submit button's accessible name", () => { - it("matches its visible label", () => { - render( - , - ); - - expect(screen.getByRole("button", { name: "Save category" })).toBeDefined(); - }); -}); diff --git a/packages/vitnode/src/components/form/fields/combobox.test.tsx b/packages/vitnode/src/components/form/fields/combobox.test.tsx deleted file mode 100644 index 05e062c768..0000000000 --- a/packages/vitnode/src/components/form/fields/combobox.test.tsx +++ /dev/null @@ -1,64 +0,0 @@ -// @vitest-environment jsdom -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; -import z from "zod"; - -import { AutoForm } from "../auto-form"; -import { AutoFormCombobox } from "./combobox"; - -const fetchMembers = () => [ - { label: "Ada Lovelace", value: "1" }, - { label: "Grace Hopper", value: "2" }, -]; - -const renderMemberForm = (formSchema: z.ZodObject) => - render( - - ( - - ), - }, - ]} - formSchema={formSchema} - /> - , - ); - -describe("AutoFormCombobox with a single async value", () => { - it("starts empty instead of showing undefined", () => { - renderMemberForm( - z.object({ member: z.object({ label: z.string(), value: z.string() }) }), - ); - - expect(screen.getByRole("combobox")).toHaveProperty("value", ""); - }); - - it("does not mark a required object field as optional", () => { - renderMemberForm( - z.object({ member: z.object({ label: z.string(), value: z.string() }) }), - ); - - expect(screen.queryByText("core.global.optional")).toBeNull(); - }); - - it("still marks an optional object field as optional", () => { - renderMemberForm( - z.object({ - member: z.object({ label: z.string(), value: z.string() }).optional(), - }), - ); - - expect(screen.getByText("core.global.optional")).toBeDefined(); - }); -}); diff --git a/packages/vitnode/src/components/form/fields/file-dropzone.test.tsx b/packages/vitnode/src/components/form/fields/file-dropzone.test.tsx deleted file mode 100644 index 86da5e8598..0000000000 --- a/packages/vitnode/src/components/form/fields/file-dropzone.test.tsx +++ /dev/null @@ -1,120 +0,0 @@ -// @vitest-environment jsdom -import { fireEvent, render, screen } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -import { FileDropzone } from "./file-shared"; - -const filesTransfer = (files: File[] = []) => ({ - dataTransfer: { dropEffect: "none", files, types: ["Files"] }, -}); - -const textTransfer = { - dataTransfer: { dropEffect: "none", files: [], types: ["text/plain"] }, -}; - -const renderDropzone = ( - props: Partial> = {}, -) => { - const onPick = vi.fn(); - const view = render( - , - ); - const zone = view.container.querySelector( - "[data-slot=file-dropzone]", - ); - if (!zone) throw new Error("dropzone not rendered"); - - return { onPick, zone }; -}; - -afterEach(() => { - vi.restoreAllMocks(); -}); - -describe("FileDropzone", () => { - it("highlights the moment files are dragged in and stays lit while crossing its children", () => { - const { zone } = renderDropzone(); - const prompt = screen.getByText("Drop a file here"); - - fireEvent.dragEnter(zone, filesTransfer()); - expect(zone.hasAttribute("data-dragging")).toBe(true); - - fireEvent.dragEnter(prompt, filesTransfer()); - fireEvent.dragLeave(zone, filesTransfer()); - expect(zone.hasAttribute("data-dragging")).toBe(true); - - fireEvent.dragLeave(prompt, filesTransfer()); - expect(zone.hasAttribute("data-dragging")).toBe(false); - }); - - it("ignores a drag that carries no files", () => { - const { zone } = renderDropzone(); - - fireEvent.dragEnter(zone, textTransfer); - - expect(zone.hasAttribute("data-dragging")).toBe(false); - }); - - it("hands the dropped files over and clears the highlight", () => { - const { onPick, zone } = renderDropzone(); - const file = new File(["bytes"], "photo.webp", { type: "image/webp" }); - - fireEvent.dragEnter(zone, filesTransfer()); - fireEvent.drop(zone, filesTransfer([file])); - - expect(onPick).toHaveBeenCalledWith([file]); - expect(zone.hasAttribute("data-dragging")).toBe(false); - }); - - it("opens the file dialog once from anywhere in the zone, including its button", () => { - const { zone } = renderDropzone(); - const input = document.querySelector("[data-slot=file-input]"); - const click = vi.fn(); - input?.addEventListener("click", click); - - fireEvent.click(zone); - expect(click).toHaveBeenCalledTimes(1); - - fireEvent.click( - screen.getByRole("button", { name: "core.global.file.choose" }), - ); - expect(click).toHaveBeenCalledTimes(2); - }); - - it("does nothing while disabled", () => { - const file = new File(["bytes"], "photo.webp", { type: "image/webp" }); - const { onPick, zone } = renderDropzone({ disabled: true }); - const input = document.querySelector("[data-slot=file-input]"); - const click = vi.fn(); - input?.addEventListener("click", click); - - fireEvent.click(zone); - fireEvent.dragEnter(zone, filesTransfer()); - fireEvent.drop(zone, filesTransfer([file])); - - expect(click).not.toHaveBeenCalled(); - expect(onPick).not.toHaveBeenCalled(); - expect(zone.hasAttribute("data-dragging")).toBe(false); - }); - - it("shows how far the upload has got and lets it be cancelled", () => { - const onCancel = vi.fn(); - renderDropzone({ onCancel, pending: true, progress: 0.4 }); - - expect(screen.getByText("40%")).toBeDefined(); - expect(screen.getByRole("progressbar").getAttribute("aria-valuenow")).toBe( - "40", - ); - - fireEvent.click( - screen.getByRole("button", { name: "core.global.file.cancel_upload" }), - ); - expect(onCancel).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/vitnode/src/components/form/fields/file-upload-queue.test.ts b/packages/vitnode/src/components/form/fields/file-upload-queue.test.ts index 73f25651d7..173a3530e9 100644 --- a/packages/vitnode/src/components/form/fields/file-upload-queue.test.ts +++ b/packages/vitnode/src/components/form/fields/file-upload-queue.test.ts @@ -161,42 +161,6 @@ const harness = ({ }; describe("createFileUploadQueue", () => { - it("stores the order they were picked in, not the order they answered", async () => { - const files = harness(); - await files.pick("A.webp", "B.webp", "C.webp"); - - // The reported case: the smallest file wins the race, the largest loses it. - await files.resolve("B.webp", 102); - await files.resolve("C.webp", 103); - await files.resolve("A.webp", 101); - - expect(files.ids).toEqual([101, 102, 103]); - }); - - it("appends a selection after the files the field already held", async () => { - const files = harness({ ids: [10, 11] }); - await files.pick("A.webp", "B.webp"); - - await files.resolve("B.webp", 102); - await files.resolve("A.webp", 101); - - expect(files.ids).toEqual([10, 11, 101, 102]); - }); - - it("leaves no gap where an upload failed", async () => { - const files = harness(); - await files.pick("A.webp", "B.webp", "C.webp"); - - await files.resolve("A.webp", 101); - await files.reject("B.webp"); - await files.resolve("C.webp", 103); - - // Two files, in the places they were picked in. Not three with a hole, and - // not [C, A] because C answered while B was still being refused. - expect(files.ids).toEqual([101, 103]); - expect(files.failed).toEqual(["B.webp"]); - }); - it("keeps an existing gallery, a refusal and the pick order all at once", async () => { // The three rules meeting in one selection, which is where the old version // came apart: two files already stored, three picked, one of the three diff --git a/packages/vitnode/src/components/form/fields/file.test.tsx b/packages/vitnode/src/components/form/fields/file.test.tsx deleted file mode 100644 index 1c5cdf4241..0000000000 --- a/packages/vitnode/src/components/form/fields/file.test.tsx +++ /dev/null @@ -1,104 +0,0 @@ -// @vitest-environment jsdom -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; -import z from "zod"; - -import type { ItemAutoFormComponentProps } from "../auto-form"; -import type { AutoFormFileDisplay } from "./file-display"; -import type { AutoFormFileValue } from "./file-shared"; - -import { AutoForm } from "../auto-form"; -import { AutoFormFile, AutoFormFileDisplayContext } from "./file"; - -const image: AutoFormFileValue = { - id: 7, - mimeType: "image/webp", - name: "cover.webp", - size: 4096, - url: "https://cdn.test/cover.webp", -}; - -const pdf: AutoFormFileValue = { - id: 7, - mimeType: "application/pdf", - name: "brief.pdf", - size: 4096, - url: "https://cdn.test/brief.pdf", -}; - -const renderFile = ({ - display, - file, -}: { - display?: AutoFormFileDisplay; - file: AutoFormFileValue; -}) => { - const CoverField = (props: ItemAutoFormComponentProps) => ( - await Promise.resolve(file)} - /> - ); - const form = ( - - ); - - return render( - - {display ? ( - - {form} - - ) : ( - form - )} - , - ); -}; - -describe("AutoFormFile cover display", () => { - it("shows a stored image as a cover with replace and remove actions", () => { - const view = renderFile({ display: "cover", file: image }); - - const cover = view.container.querySelector("[data-slot=file-cover]"); - expect(cover?.querySelector("img")?.getAttribute("src")).toBe(image.url); - expect( - screen.getByRole("button", { name: "core.global.file.replace" }), - ).toBeTruthy(); - expect(view.container.querySelector("[data-slot=attachment]")).toBeNull(); - }); - - it("goes back to the dropzone after the cover is removed", () => { - const view = renderFile({ display: "cover", file: image }); - - fireEvent.click( - screen.getByRole("button", { name: "core.global.file.remove" }), - ); - - expect(view.container.querySelector("[data-slot=file-cover]")).toBeNull(); - expect( - view.container.querySelector("[data-slot=file-dropzone]"), - ).toBeTruthy(); - }); - - it("keeps the file card for a file that is not an image", () => { - const view = renderFile({ display: "cover", file: pdf }); - - expect(view.container.querySelector("[data-slot=file-cover]")).toBeNull(); - expect(view.container.querySelector("[data-slot=attachment]")).toBeTruthy(); - }); - - it("keeps the file card without a cover display", () => { - const view = renderFile({ file: image }); - - expect(view.container.querySelector("[data-slot=file-cover]")).toBeNull(); - expect(view.container.querySelector("[data-slot=attachment]")).toBeTruthy(); - }); -}); diff --git a/packages/vitnode/src/components/form/fields/files.test.tsx b/packages/vitnode/src/components/form/fields/files.test.tsx deleted file mode 100644 index 4c5dc95d8e..0000000000 --- a/packages/vitnode/src/components/form/fields/files.test.tsx +++ /dev/null @@ -1,178 +0,0 @@ -// @vitest-environment jsdom -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { act, fireEvent, render, screen, within } from "@testing-library/react"; -import { Toaster } from "sonner"; -import { describe, expect, it, vi } from "vitest"; -import z from "zod"; - -import type { ItemAutoFormComponentProps } from "../auto-form"; -import type { AutoFormFileValue } from "./file-shared"; -import type { FileUploadOptions } from "./file-upload-queue"; - -import { AutoForm } from "../auto-form"; -import { AutoFormFiles } from "./files"; - -const stored = (id: number, name: string): AutoFormFileValue => ({ - id, - mimeType: "application/pdf", - name, - size: 2048, - url: `https://cdn.test/${id}.pdf`, -}); - -const schema = z.object({ gallery: z.array(z.number()).default([1, 2]) }); - -const renderFiles = ( - onUpload: ( - file: File, - options: FileUploadOptions, - ) => Promise, -) => { - const GalleryField = (props: ItemAutoFormComponentProps) => ( - - ); - - const view = render( - - - - , - ); - - const pick = (name: string) => { - const input = view.container.querySelector( - "[data-slot=file-input]", - ); - if (!input) throw new Error("file input not rendered"); - - fireEvent.change(input, { - target: { - files: [new File(["bytes"], name, { type: "application/pdf" })], - }, - }); - }; - - const names = () => - [...view.container.querySelectorAll("[data-slot=attachment-title]")].map( - title => title.getAttribute("title"), - ); - - return { names, pick }; -}; - -const deferred = () => { - let resolve: (value: AutoFormFileValue) => void = () => {}; - let reject: (error: unknown) => void = () => {}; - const promise = new Promise((done, fail) => { - resolve = done; - reject = fail; - }); - - return { promise, reject, resolve }; -}; - -describe("AutoFormFiles", () => { - it("removes a file with an undo that puts it back where it was", async () => { - const { names } = renderFiles(vi.fn()); - - const [removeFirst] = screen.getAllByRole("button", { - name: "core.global.file.remove_named", - }); - fireEvent.click(removeFirst); - expect(names()).toEqual(["second.pdf"]); - - fireEvent.click( - await screen.findByRole("button", { name: "core.global.file.undo" }), - ); - - expect(names()).toEqual(["first.pdf", "second.pdf"]); - }); - - it("shows upload progress and cancels an upload in flight", async () => { - const upload = deferred(); - let options: FileUploadOptions | undefined; - const { names, pick } = renderFiles(async (_file, given) => { - options = given; - - return await upload.promise; - }); - - await act(async () => { - pick("third.pdf"); - await Promise.resolve(); - }); - act(() => { - options?.onProgress(0.25); - }); - - expect(screen.getByText("25%")).toBeDefined(); - - fireEvent.click( - screen.getByRole("button", { - name: "core.global.file.cancel_upload_named", - }), - ); - - expect(options?.signal.aborted).toBe(true); - expect(screen.queryByText("25%")).toBeNull(); - - await act(async () => { - upload.resolve(stored(3, "third.pdf")); - await Promise.resolve(); - }); - - expect(names()).toEqual(["first.pdf", "second.pdf"]); - }); - - it("keeps a failed upload as a card that can be retried", async () => { - const attempts = [deferred(), deferred()]; - let call = 0; - const { names, pick } = renderFiles(async () => { - const attempt = attempts[call]; - call += 1; - - return await attempt.promise; - }); - - await act(async () => { - pick("third.pdf"); - await Promise.resolve(); - }); - await act(async () => { - attempts[0].reject(new Error("The network went away")); - await new Promise(resolve => setTimeout(resolve, 0)); - }); - - const alert = await screen.findByText("The network went away"); - expect(alert.getAttribute("role")).toBe("alert"); - - const failedCard = alert.closest("[data-slot=attachment]"); - if (!failedCard) throw new Error("failed card not rendered"); - - await act(async () => { - fireEvent.click( - within(failedCard).getByRole("button", { - name: "core.global.file.retry_named", - }), - ); - await Promise.resolve(); - }); - await act(async () => { - attempts[1].resolve(stored(3, "third.pdf")); - await new Promise(resolve => setTimeout(resolve, 0)); - }); - - expect(screen.queryByText("The network went away")).toBeNull(); - expect(names()).toEqual(["first.pdf", "second.pdf", "third.pdf"]); - }); -}); diff --git a/packages/vitnode/src/components/form/fields/multi-lang.test.tsx b/packages/vitnode/src/components/form/fields/multi-lang.test.tsx deleted file mode 100644 index adc6940eb5..0000000000 --- a/packages/vitnode/src/components/form/fields/multi-lang.test.tsx +++ /dev/null @@ -1,81 +0,0 @@ -// @vitest-environment jsdom -import { act, renderHook } from "@testing-library/react"; -import React from "react"; -import { describe, expect, it, vi } from "vitest"; - -import type { MultiLangValue } from "@/lib/helpers/multi-lang"; - -import { LanguagesProvider } from "@/components/languages-provider"; - -import { useMultiLangField } from "./multi-lang"; -import { MultiLangLanguageContext } from "./multi-lang-language"; - -const languages = [ - { code: "en", name: "English" }, - { code: "pl", name: "Polski" }, -]; - -const value: MultiLangValue = [ - { languageCode: "en", value: "Hello" }, - { languageCode: "pl", value: "Cześć" }, -]; - -const renderField = (lockedLanguage: null | string) => { - const onChange = vi.fn(); - const wrapper = ({ children }: { children: React.ReactNode }) => ( - - - {children} - - - ); - const { result } = renderHook( - () => - useMultiLangField({ - name: "title", - onBlur: vi.fn(), - onChange, - value, - }), - { wrapper }, - ); - - return { onChange, result }; -}; - -describe("useMultiLangField", () => { - it("lets the user pick a language when nothing locks it", () => { - const { result } = renderField(null); - - expect(result.current.canSelect).toBe(true); - expect(result.current.selected).toBe("en"); - expect(result.current.currentValue).toBe("Hello"); - }); - - it("edits the locked language and hides the picker", () => { - const { onChange, result } = renderField("pl"); - - expect(result.current.canSelect).toBe(false); - expect(result.current.selected).toBe("pl"); - expect(result.current.currentValue).toBe("Cześć"); - - act(() => { - result.current.setValue("Dzień dobry"); - }); - - expect(onChange).toHaveBeenCalledWith([ - { languageCode: "en", value: "Hello" }, - { languageCode: "pl", value: "Dzień dobry" }, - ]); - }); - - it("ignores a language picked before the lock", () => { - const { result } = renderField("pl"); - - act(() => { - result.current.setSelected("en"); - }); - - expect(result.current.selected).toBe("pl"); - }); -}); diff --git a/packages/vitnode/src/components/form/fields/roles-boundaries.test.ts b/packages/vitnode/src/components/form/fields/roles-boundaries.test.ts deleted file mode 100644 index be452022d9..0000000000 --- a/packages/vitnode/src/components/form/fields/roles-boundaries.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -// @vitest-environment node -import { readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; - -import { reachedSpecifiers, runtimeImports } from "@/tests/import-graph"; - -const here = dirname(fileURLToPath(import.meta.url)); - -const SHARED = { - /** The framework-neutral field. */ - field: join(here, "input-roles.tsx"), - /** The type and the search signature, with no imports at all. */ - types: join(here, "roles.ts"), -}; - -const read = (path: string) => readFileSync(path, "utf8"); - -describe("the shared role field is framework-neutral", () => { - it("takes its translations from use-intl", () => { - expect(reachedSpecifiers(SHARED.field)).toContain("use-intl"); - }); - - it("declares its role types in a module that imports nothing", () => { - expect(runtimeImports(SHARED.types)).toEqual([]); - }); - - it("reads RoleOption from that module rather than from a transport", () => { - expect(read(SHARED.field)).toMatch( - /import type \{ RoleOption, RoleSearch \} from "\.\/roles"/, - ); - }); -}); - -describe("the search dependency is injected, and stays injected", () => { - it("is required on the props type", () => { - expect(read(SHARED.field)).toMatch(/\n {2}search: RoleSearch;/); - }); - - it("has no default parameter", () => { - // The destructured parameter, at its own indent - `search={...}` further - // down is the prop handed to `AsyncPicker` and is not what this is about. - const source = read(SHARED.field); - - expect(source).toMatch(/\n {2}search,\n/); - expect(source).not.toMatch(/\n {2}search\s*=/); - }); - - it("has no fallback and no host detection", () => { - const source = read(SHARED.field); - - expect(source).not.toContain("searchRolesLazily"); - expect(source).not.toMatch(/typeof window|process\.env|import\.meta\.env/); - }); - - it("still exports the canonical field and its two types", () => { - // The three names a host binds to. - expect(read(SHARED.field)).toContain("export const AutoFormRoles"); - expect(read(SHARED.types)).toContain("export interface RoleOption"); - expect(read(SHARED.types)).toContain("export type RoleSearch"); - }); - - it("leaves a browser search for the host to pass, in the AdminCP's own module", () => { - // `searchAdminRolesInBrowser` lives with the roles screen's other reads - // rather than beside the field - which is the point: the field does not - // know how roles are found. - const rolesQuery = join( - here, - "../../../views/admin/views/core/users/roles/roles-query.ts", - ); - - expect(read(rolesQuery)).toContain( - "export const searchAdminRolesInBrowser", - ); - }); -}); - -describe("a default search may not return", () => { - it("is not replaced by a default inside the field", () => { - // The specific regression: the tempting fix for a caller that forgot - // `search` is a default here. That would put a transport back inside a - // framework-neutral component and re-close the seam. - const source = read(SHARED.field); - - expect(source).not.toMatch(/search\s*=\s*search[A-Z]/); - }); -}); diff --git a/packages/vitnode/src/components/pet/pet.test.tsx b/packages/vitnode/src/components/pet/pet.test.tsx index 6e6dc8687c..ad896d2519 100644 --- a/packages/vitnode/src/components/pet/pet.test.tsx +++ b/packages/vitnode/src/components/pet/pet.test.tsx @@ -1,4 +1,3 @@ -// @vitest-environment jsdom import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; @@ -37,12 +36,11 @@ describe("Pet", () => { }); describe("getPetSvgMarkup", () => { - it("exports a standalone animated SVG with room for tails and props", () => { + it("exports a standalone animated SVG", () => { const { container } = render(); const markup = getPetSvgMarkup(petOf(container)); expect(markup).toContain('xmlns="http://www.w3.org/2000/svg"'); - expect(markup).toContain('viewBox="-48 -24 496 488"'); expect(markup).toContain("@keyframes vn-pet-confetti"); expect(markup).not.toContain("aria-hidden"); }); diff --git a/packages/vitnode/src/components/rich-text/boundaries.test.ts b/packages/vitnode/src/components/rich-text/boundaries.test.ts index 5a9bafe1c6..d57e6608db 100644 --- a/packages/vitnode/src/components/rich-text/boundaries.test.ts +++ b/packages/vitnode/src/components/rich-text/boundaries.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment node import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; diff --git a/packages/vitnode/src/components/rich-text/rich-text-content.test.tsx b/packages/vitnode/src/components/rich-text/rich-text-content.test.tsx index 7f78eda1a2..9ecb48cd75 100644 --- a/packages/vitnode/src/components/rich-text/rich-text-content.test.tsx +++ b/packages/vitnode/src/components/rich-text/rich-text-content.test.tsx @@ -1,4 +1,3 @@ -// @vitest-environment jsdom import { render, screen, within } from "@testing-library/react"; import { describe, expect, it } from "vitest"; @@ -103,7 +102,7 @@ describe("RichTextContent", () => { ); const styled = screen.getByText("red"); - expect(styled.style.color).toBe("rgb(255, 0, 0)"); + expect(styled.style.color).toBe("#ff0000"); expect(styled.style.fontSize).toBe("18px"); expect(screen.getByText("evil", { exact: false }).tagName).toBe("P"); expect(container.querySelectorAll("span")).toHaveLength(1); diff --git a/packages/vitnode/src/components/switchers/themes/theme-switchers.test.tsx b/packages/vitnode/src/components/switchers/themes/theme-switchers.test.tsx index 9166004e3f..83210ef823 100644 --- a/packages/vitnode/src/components/switchers/themes/theme-switchers.test.tsx +++ b/packages/vitnode/src/components/switchers/themes/theme-switchers.test.tsx @@ -1,4 +1,3 @@ -// @vitest-environment jsdom import { fireEvent, render, screen, within } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/components/table/content.test.tsx b/packages/vitnode/src/components/table/content.test.tsx index 8b11cda0d2..d0e370a689 100644 --- a/packages/vitnode/src/components/table/content.test.tsx +++ b/packages/vitnode/src/components/table/content.test.tsx @@ -1,4 +1,3 @@ -// @vitest-environment jsdom import { fireEvent, render, screen, within } from "@testing-library/react"; import React from "react"; import { describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/components/table/expansion.test.tsx b/packages/vitnode/src/components/table/expansion.test.tsx index 1797d9cfed..a7c0450b6c 100644 --- a/packages/vitnode/src/components/table/expansion.test.tsx +++ b/packages/vitnode/src/components/table/expansion.test.tsx @@ -1,4 +1,3 @@ -// @vitest-environment jsdom import { fireEvent, render, diff --git a/packages/vitnode/src/components/table/order-table-head.test.tsx b/packages/vitnode/src/components/table/order-table-head.test.tsx index 663ced8b4b..1086efa94b 100644 --- a/packages/vitnode/src/components/table/order-table-head.test.tsx +++ b/packages/vitnode/src/components/table/order-table-head.test.tsx @@ -1,4 +1,3 @@ -// @vitest-environment jsdom import { act, fireEvent, render, screen, within } from "@testing-library/react"; import React from "react"; import { describe, expect, it } from "vitest"; diff --git a/packages/vitnode/src/components/table/reorder.test.tsx b/packages/vitnode/src/components/table/reorder.test.tsx index cfbe1cd6c3..a533077214 100644 --- a/packages/vitnode/src/components/table/reorder.test.tsx +++ b/packages/vitnode/src/components/table/reorder.test.tsx @@ -1,4 +1,3 @@ -// @vitest-environment jsdom import { act, fireEvent, diff --git a/packages/vitnode/src/components/table/table-boundaries.test.ts b/packages/vitnode/src/components/table/table-boundaries.test.ts deleted file mode 100644 index 92539b3e5f..0000000000 --- a/packages/vitnode/src/components/table/table-boundaries.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -// @vitest-environment node -import { readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; - -import { runtimeImports } from "@/tests/import-graph"; - -const here = dirname(fileURLToPath(import.meta.url)); - -const SHARED = { - filters: join(here, "filters.tsx"), - orderHead: join(here, "order-table-head.tsx"), - pagination: join(here, "pagination.tsx"), - search: join(here, "search.tsx"), - urlState: join(here, "url-state.ts"), -}; - -describe("the shared data table is framework-neutral", () => { - it("keeps the URL arithmetic free of every import", () => { - // The point of `url-state.ts`: no router, no React, nothing to mock. If an - // import ever appears here, the seam has started growing a second job. - expect(runtimeImports(SHARED.urlState)).toEqual([]); - }); -}); - -describe("the shared controls take their navigation from the seam", () => { - const withoutComments = (path: string): string => - readFileSync(path, "utf8") - .replace(/\/\*[\s\S]*?\*\//g, "") - .replace(/\/\/.*$/gm, ""); - - const controls = [ - SHARED.filters, - SHARED.orderHead, - SHARED.pagination, - SHARED.search, - ]; - - it("asks the seam where it is rather than a router", () => { - for (const path of controls) { - const code = withoutComments(path); - - expect(code).toContain("useDataTableUrl"); - expect(code).not.toContain("useSearchParams"); - expect(code).not.toContain("useRouter"); - expect(code).not.toContain("usePathname"); - } - }); - - it("builds no URLs of its own", () => { - // Every `new URLSearchParams(...)` in a control was a copy of the current - // search about to be edited by hand. That is `url-state.ts`'s job now, and - // a control that starts doing it again is a rule nobody can test. - for (const path of controls) { - expect(withoutComments(path)).not.toContain("new URLSearchParams"); - } - }); - - it("never names a pathname, because it is not given one", () => { - for (const path of controls) { - expect(withoutComments(path)).not.toContain("pathname"); - } - }); -}); diff --git a/packages/vitnode/src/components/tiptap/collaboration.test.tsx b/packages/vitnode/src/components/tiptap/collaboration.test.tsx deleted file mode 100644 index 024c35e25c..0000000000 --- a/packages/vitnode/src/components/tiptap/collaboration.test.tsx +++ /dev/null @@ -1,25 +0,0 @@ -// @vitest-environment jsdom -import { render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; - -import { CollaborativeEditorSlot } from "./collaboration"; - -describe("CollaborativeEditorSlot", () => { - it("renders the collaborative editor in the same render, without suspending", () => { - const editor = vi.fn(({ locale }: { locale: null | string }) => ( -

Shared editor ({locale})

- )); - - render( - {}} - render={editor} - value={null} - />, - ); - - expect(screen.getByText("Shared editor (pl)")).toBeTruthy(); - expect(editor).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/vitnode/src/components/tiptap/extension.test.ts b/packages/vitnode/src/components/tiptap/extension.test.ts index 1e394e38d5..0d532b3109 100644 --- a/packages/vitnode/src/components/tiptap/extension.test.ts +++ b/packages/vitnode/src/components/tiptap/extension.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vitest"; import { matchEmojis } from "./emoji/match-emojis"; -import { createTipTapExtensions, SUPPORTED_HEADINGS_LEVELS } from "./extension"; const emojis = [ { @@ -128,54 +127,3 @@ describe("matchEmojis", () => { ]); }); }); - -describe("createTipTapExtensions", () => { - const names = createTipTapExtensions().map(extension => extension.name); - - it("registers every extension the toolbar drives", () => { - expect(names).toEqual( - expect.arrayContaining([ - "audio", - "color", - "emoji", - "fontSize", - "panel", - "placeholder", - "slashCommand", - "starterKit", - "submitFormShortcut", - "tableKit", - "taskItem", - "taskList", - "textAlign", - "textStyle", - "typography", - ]), - ); - }); - - it("keeps dropcursor and trailingNode inside the starter kit", () => { - const starterKit = createTipTapExtensions().find( - extension => extension.name === "starterKit", - ); - - expect(starterKit?.options.dropcursor).toMatchObject({ - class: "tiptap-dropcursor", - width: 2, - }); - expect(starterKit?.options.trailingNode).toMatchObject({ - node: "paragraph", - }); - expect(starterKit?.options.heading.levels).toEqual([ - ...SUPPORTED_HEADINGS_LEVELS, - ]); - }); - - it("passes the placeholder through to the placeholder extension", () => { - const placeholder = createTipTapExtensions({ - placeholder: "Write something", - }).find(extension => extension.name === "placeholder"); - - expect(placeholder?.options.placeholder).toBe("Write something"); - }); -}); diff --git a/packages/vitnode/src/components/tiptap/panel.test.ts b/packages/vitnode/src/components/tiptap/panel.test.tsx similarity index 98% rename from packages/vitnode/src/components/tiptap/panel.test.ts rename to packages/vitnode/src/components/tiptap/panel.test.tsx index 948f83368b..d2477d30f7 100644 --- a/packages/vitnode/src/components/tiptap/panel.test.ts +++ b/packages/vitnode/src/components/tiptap/panel.test.tsx @@ -1,4 +1,3 @@ -// @vitest-environment jsdom import { Editor } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import { afterEach, describe, expect, it } from "vitest"; diff --git a/packages/vitnode/src/components/tiptap/quick-ask/quick-ask-editor.test.ts b/packages/vitnode/src/components/tiptap/quick-ask/quick-ask-editor.test.tsx similarity index 99% rename from packages/vitnode/src/components/tiptap/quick-ask/quick-ask-editor.test.ts rename to packages/vitnode/src/components/tiptap/quick-ask/quick-ask-editor.test.tsx index cea3527ab0..f64fb89e1a 100644 --- a/packages/vitnode/src/components/tiptap/quick-ask/quick-ask-editor.test.ts +++ b/packages/vitnode/src/components/tiptap/quick-ask/quick-ask-editor.test.tsx @@ -1,4 +1,3 @@ -// @vitest-environment jsdom import { Editor } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import { afterEach, describe, expect, it } from "vitest"; diff --git a/packages/vitnode/src/components/tiptap/slash-command/slash-command-list.test.tsx b/packages/vitnode/src/components/tiptap/slash-command/slash-command-list.test.tsx index 0e293f36de..9b3c7adda9 100644 --- a/packages/vitnode/src/components/tiptap/slash-command/slash-command-list.test.tsx +++ b/packages/vitnode/src/components/tiptap/slash-command/slash-command-list.test.tsx @@ -1,4 +1,3 @@ -// @vitest-environment jsdom import type { SuggestionKeyDownProps } from "@tiptap/suggestion"; import { act, fireEvent, render, screen, within } from "@testing-library/react"; diff --git a/packages/vitnode/src/components/tiptap/submit-form-shortcut.test.ts b/packages/vitnode/src/components/tiptap/submit-form-shortcut.test.tsx similarity index 97% rename from packages/vitnode/src/components/tiptap/submit-form-shortcut.test.ts rename to packages/vitnode/src/components/tiptap/submit-form-shortcut.test.tsx index 7a7a708bc8..57b8ab559a 100644 --- a/packages/vitnode/src/components/tiptap/submit-form-shortcut.test.ts +++ b/packages/vitnode/src/components/tiptap/submit-form-shortcut.test.tsx @@ -1,4 +1,3 @@ -// @vitest-environment jsdom import { Editor } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import { afterEach, describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/components/tiptap/toolbar/actions/quick-ask-action.test.tsx b/packages/vitnode/src/components/tiptap/toolbar/actions/quick-ask-action.test.tsx index 1a88fa19a0..e881314266 100644 --- a/packages/vitnode/src/components/tiptap/toolbar/actions/quick-ask-action.test.tsx +++ b/packages/vitnode/src/components/tiptap/toolbar/actions/quick-ask-action.test.tsx @@ -1,4 +1,3 @@ -// @vitest-environment jsdom import { fireEvent, render, screen } from "@testing-library/react"; import { IntlProvider } from "use-intl"; import { describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/components/tiptap/toolbar/tiptap-toolbar.test.tsx b/packages/vitnode/src/components/tiptap/toolbar/tiptap-toolbar.test.tsx index c2f90947ad..a8c014617d 100644 --- a/packages/vitnode/src/components/tiptap/toolbar/tiptap-toolbar.test.tsx +++ b/packages/vitnode/src/components/tiptap/toolbar/tiptap-toolbar.test.tsx @@ -1,4 +1,3 @@ -// @vitest-environment jsdom import { cleanup, fireEvent, diff --git a/packages/vitnode/src/components/ui/alert-dialog.test.tsx b/packages/vitnode/src/components/ui/alert-dialog.test.tsx deleted file mode 100644 index c969860318..0000000000 --- a/packages/vitnode/src/components/ui/alert-dialog.test.tsx +++ /dev/null @@ -1,47 +0,0 @@ -// @vitest-environment jsdom -import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; - -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogTitle, -} from "./alert-dialog"; - -describe("AlertDialog buttons", () => { - it("announce their visible text", () => { - render( - - - Delete this thread? - Keep it - Delete thread - - , - ); - - expect(screen.getByRole("button", { name: "Keep it" })).toBeDefined(); - expect(screen.getByRole("button", { name: "Delete thread" })).toBeDefined(); - }); - - it("fall back to translated labels without text", () => { - render( - - - Delete this thread? - - - - , - ); - - expect( - screen.getByRole("button", { name: "core.global.cancel" }), - ).toBeDefined(); - expect( - screen.getByRole("button", { name: "core.global.confirm" }), - ).toBeDefined(); - }); -}); diff --git a/packages/vitnode/src/components/ui/alert.test.tsx b/packages/vitnode/src/components/ui/alert.test.tsx index 11b55cc07b..9e520081fb 100644 --- a/packages/vitnode/src/components/ui/alert.test.tsx +++ b/packages/vitnode/src/components/ui/alert.test.tsx @@ -1,32 +1,9 @@ -// @vitest-environment jsdom import { render, screen } from "@testing-library/react"; -import { HardDriveIcon } from "lucide-react"; import { describe, expect, it } from "vitest"; import { Alert, AlertDescription, AlertTitle } from "./alert"; -const iconOf = (alert: HTMLElement) => - alert.querySelector("[data-slot=alert-icon] svg"); - describe("Alert", () => { - it.each([ - ["info", "status", "lucide-info"], - ["success", "status", "lucide-circle-check"], - ["warning", "alert", "lucide-triangle-alert"], - ["destructive", "alert", "lucide-circle-x"], - ] as const)( - "shows the %s icon without being given one", - (variant, role, icon) => { - render( - - Plugins need a workspace - , - ); - - expect(iconOf(screen.getByRole(role))?.classList).toContain(icon); - }, - ); - it("renders the neutral default notice without an icon", () => { render( @@ -69,18 +46,6 @@ describe("Alert", () => { expect(screen.queryByRole("alert")).toBeNull(); }); - it("renders a custom icon in place of the default one", () => { - render( - } variant="info"> - Storage - , - ); - - expect(iconOf(screen.getByRole("status"))?.classList).toContain( - "lucide-hard-drive", - ); - }); - it("drops the icon when it is set to null", () => { render( diff --git a/packages/vitnode/src/components/ui/button.test.tsx b/packages/vitnode/src/components/ui/button.test.tsx index 7daad518fe..78c6ed115f 100644 --- a/packages/vitnode/src/components/ui/button.test.tsx +++ b/packages/vitnode/src/components/ui/button.test.tsx @@ -1,4 +1,3 @@ -// @vitest-environment jsdom import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/components/ui/calendar.test.tsx b/packages/vitnode/src/components/ui/calendar.test.tsx index 6d5d8d0553..8b700d243c 100644 --- a/packages/vitnode/src/components/ui/calendar.test.tsx +++ b/packages/vitnode/src/components/ui/calendar.test.tsx @@ -1,6 +1,5 @@ -// @vitest-environment jsdom import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { Calendar } from "./calendar"; @@ -14,22 +13,6 @@ const dayButton = (container: HTMLElement, day: string) => { }; describe("Calendar", () => { - it("selects a single day on click", () => { - const onSelect = vi.fn(); - const { container } = render( - , - ); - - fireEvent.click(dayButton(container, "2026-10-07")); - - expect(onSelect).toHaveBeenCalledTimes(1); - expect(onSelect.mock.calls[0][0]).toEqual(new Date(2026, 9, 7)); - }); - it("marks the ends and the middle of a selected range", () => { const { container } = render( { expect(dayButton(container, "2026-10-09").dataset.rangeEnd).toBe("true"); }); - it("does not select a disabled day", () => { - const onSelect = vi.fn(); - const { container } = render( - , - ); - - const saturday = dayButton(container, "2026-10-10"); - fireEvent.click(saturday); - - expect(saturday.disabled).toBe(true); - expect(onSelect).not.toHaveBeenCalled(); - }); - it("moves between months with translated navigation buttons", () => { render(); diff --git a/packages/vitnode/src/components/ui/carousel.test.tsx b/packages/vitnode/src/components/ui/carousel.test.tsx index 5ee9394a29..02d9d9a709 100644 --- a/packages/vitnode/src/components/ui/carousel.test.tsx +++ b/packages/vitnode/src/components/ui/carousel.test.tsx @@ -1,4 +1,3 @@ -// @vitest-environment jsdom import { act, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/components/ui/cascader.test.tsx b/packages/vitnode/src/components/ui/cascader.test.tsx index 125c3f85c2..199653f432 100644 --- a/packages/vitnode/src/components/ui/cascader.test.tsx +++ b/packages/vitnode/src/components/ui/cascader.test.tsx @@ -1,4 +1,3 @@ -// @vitest-environment jsdom import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/components/ui/chart.test.tsx b/packages/vitnode/src/components/ui/chart.test.tsx index 27c18c211d..f3fed852a3 100644 --- a/packages/vitnode/src/components/ui/chart.test.tsx +++ b/packages/vitnode/src/components/ui/chart.test.tsx @@ -1,4 +1,3 @@ -// @vitest-environment jsdom import type { ChartPoint } from "@tanstack/charts"; import { render, screen, within } from "@testing-library/react"; diff --git a/packages/vitnode/src/components/ui/copy-button.test.tsx b/packages/vitnode/src/components/ui/copy-button.test.tsx index a54f96f557..f664fcc66a 100644 --- a/packages/vitnode/src/components/ui/copy-button.test.tsx +++ b/packages/vitnode/src/components/ui/copy-button.test.tsx @@ -1,4 +1,3 @@ -// @vitest-environment jsdom import { act, fireEvent, render, screen } from "@testing-library/react"; import { toast } from "sonner"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/components/ui/counter.test.tsx b/packages/vitnode/src/components/ui/counter.test.tsx index c8d1db7111..11d3385966 100644 --- a/packages/vitnode/src/components/ui/counter.test.tsx +++ b/packages/vitnode/src/components/ui/counter.test.tsx @@ -1,4 +1,3 @@ -// @vitest-environment jsdom import { fireEvent, render, screen } from "@testing-library/react"; import React from "react"; import { describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/components/ui/date-picker.test.tsx b/packages/vitnode/src/components/ui/date-picker.test.tsx index 299612b474..4834918acf 100644 --- a/packages/vitnode/src/components/ui/date-picker.test.tsx +++ b/packages/vitnode/src/components/ui/date-picker.test.tsx @@ -1,4 +1,3 @@ -// @vitest-environment jsdom import { fireEvent, render, screen } from "@testing-library/react"; import React from "react"; import { describe, expect, it } from "vitest"; diff --git a/packages/vitnode/src/components/ui/date-range-picker.test.tsx b/packages/vitnode/src/components/ui/date-range-picker.test.tsx index a7f3cfe944..5d7ab8c66f 100644 --- a/packages/vitnode/src/components/ui/date-range-picker.test.tsx +++ b/packages/vitnode/src/components/ui/date-range-picker.test.tsx @@ -1,4 +1,3 @@ -// @vitest-environment jsdom import { fireEvent, render, screen } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/components/ui/dialog.test.tsx b/packages/vitnode/src/components/ui/dialog.test.tsx index eae71930e9..016441d1e1 100644 --- a/packages/vitnode/src/components/ui/dialog.test.tsx +++ b/packages/vitnode/src/components/ui/dialog.test.tsx @@ -1,4 +1,3 @@ -// @vitest-environment jsdom import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import React from "react"; import { describe, expect, it } from "vitest"; diff --git a/packages/vitnode/src/components/ui/dynamic-icon.client.test.tsx b/packages/vitnode/src/components/ui/dynamic-icon.client.test.tsx index 1d5a839a59..50fb20a27d 100644 --- a/packages/vitnode/src/components/ui/dynamic-icon.client.test.tsx +++ b/packages/vitnode/src/components/ui/dynamic-icon.client.test.tsx @@ -1,4 +1,3 @@ -// @vitest-environment jsdom import { render, waitFor } from "@testing-library/react"; import React from "react"; import { expect, test } from "vitest"; diff --git a/packages/vitnode/src/components/ui/input-group.test.tsx b/packages/vitnode/src/components/ui/input-group.test.tsx deleted file mode 100644 index 2480609def..0000000000 --- a/packages/vitnode/src/components/ui/input-group.test.tsx +++ /dev/null @@ -1,60 +0,0 @@ -// @vitest-environment jsdom -import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; - -import { - InputGroup, - InputGroupAddon, - InputGroupButton, - InputGroupInput, - InputGroupTextarea, -} from "./input-group"; - -describe("InputGroupAddon", () => { - it("focuses the input when the addon is clicked", () => { - render( - - - Find - , - ); - - fireEvent.click(screen.getByText("Find")); - - expect(document.activeElement).toBe( - screen.getByRole("textbox", { name: "Search" }), - ); - }); - - it("focuses the textarea when the addon is clicked", () => { - render( - - - Markdown supported - , - ); - - fireEvent.click(screen.getByText("Markdown supported")); - - expect(document.activeElement).toBe( - screen.getByRole("textbox", { name: "Message" }), - ); - }); - - it("leaves focus alone when a button inside the addon is clicked", () => { - render( - - - - Clear - - , - ); - - fireEvent.click(screen.getByRole("button", { name: "Clear" })); - - expect(document.activeElement).not.toBe( - screen.getByRole("textbox", { name: "Search" }), - ); - }); -}); diff --git a/packages/vitnode/src/components/ui/number-field.test.tsx b/packages/vitnode/src/components/ui/number-field.test.tsx deleted file mode 100644 index 1bba9d8a19..0000000000 --- a/packages/vitnode/src/components/ui/number-field.test.tsx +++ /dev/null @@ -1,137 +0,0 @@ -// @vitest-environment jsdom -import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; - -import { - NumberField, - NumberFieldDecrement, - NumberFieldGroup, - NumberFieldIncrement, - NumberFieldInput, - NumberFieldScrubArea, -} from "./number-field"; - -const renderNumberField = ( - props: React.ComponentProps = {}, -) => - render( - - - - - - - - , - ); - -const input = () => screen.getByLabelText("Amount"); -const increase = () => - screen.getByRole("button", { name: "core.global.increase" }); -const decrease = () => - screen.getByRole("button", { name: "core.global.decrease" }); - -const pressButton = (button: HTMLElement) => { - fireEvent.pointerDown(button, { button: 0, pointerType: "mouse" }); - fireEvent.pointerUp(button, { button: 0, pointerType: "mouse" }); - fireEvent.click(button, { detail: 1 }); -}; - -const changedValues = (onValueChange: ReturnType) => - onValueChange.mock.calls.map(([value]) => value as null | number); - -describe("NumberField", () => { - it("steps up and down by the step with the buttons", () => { - const onValueChange = vi.fn(); - renderNumberField({ defaultValue: 10, onValueChange, step: 5 }); - - pressButton(increase()); - expect(input().value).toBe("15"); - - pressButton(decrease()); - pressButton(decrease()); - expect(input().value).toBe("5"); - expect(changedValues(onValueChange)).toEqual([15, 10, 5]); - }); - - it("clamps at min and max and disables the button at the limit", () => { - const onValueChange = vi.fn(); - renderNumberField({ defaultValue: 9, max: 10, min: 8, onValueChange }); - - pressButton(increase()); - expect(input().value).toBe("10"); - expect(increase().hasAttribute("disabled")).toBe(true); - - pressButton(decrease()); - pressButton(decrease()); - expect(input().value).toBe("8"); - expect(decrease().hasAttribute("disabled")).toBe(true); - expect(changedValues(onValueChange)).toEqual([10, 9, 8]); - }); - - it("steps with the arrow keys and takes the large step with Shift", () => { - renderNumberField({ defaultValue: 0, max: 25 }); - - fireEvent.keyDown(input(), { key: "ArrowUp" }); - expect(input().value).toBe("1"); - - fireEvent.keyDown(input(), { key: "ArrowUp", shiftKey: true }); - expect(input().value).toBe("11"); - - fireEvent.keyDown(input(), { key: "ArrowUp", shiftKey: true }); - fireEvent.keyDown(input(), { key: "ArrowUp", shiftKey: true }); - expect(input().value).toBe("25"); - - fireEvent.keyDown(input(), { key: "ArrowDown" }); - expect(input().value).toBe("24"); - }); - - it("commits a typed value on blur, clamped to the range", () => { - const onValueCommitted = vi.fn(); - renderNumberField({ defaultValue: 1, max: 100, onValueCommitted }); - - fireEvent.focus(input()); - fireEvent.change(input(), { target: { value: "250" } }); - expect(onValueCommitted).not.toHaveBeenCalled(); - - fireEvent.blur(input()); - expect(onValueCommitted).toHaveBeenLastCalledWith(100, expect.anything()); - expect(input().value).toBe("100"); - }); - - it("ignores the buttons and the keyboard when disabled", () => { - const onValueChange = vi.fn(); - renderNumberField({ defaultValue: 3, disabled: true, onValueChange }); - - expect(input().disabled).toBe(true); - expect(increase().hasAttribute("disabled")).toBe(true); - expect(decrease().hasAttribute("disabled")).toBe(true); - - pressButton(increase()); - fireEvent.keyDown(input(), { key: "ArrowUp" }); - expect(onValueChange).not.toHaveBeenCalled(); - expect(input().value).toBe("3"); - }); - - it("keeps a read-only value but leaves the input focusable", () => { - const onValueChange = vi.fn(); - renderNumberField({ defaultValue: 3, onValueChange, readOnly: true }); - - expect(input().readOnly).toBe(true); - expect(increase().getAttribute("aria-disabled")).toBe("true"); - - pressButton(increase()); - fireEvent.keyDown(input(), { key: "ArrowUp" }); - expect(onValueChange).not.toHaveBeenCalled(); - }); - - it("formats the value with the given Intl options", () => { - renderNumberField({ - defaultValue: 1234.5, - format: { currency: "USD", style: "currency" }, - locale: "en-US", - }); - - expect(input().value).toBe("$1,234.50"); - }); -}); diff --git a/packages/vitnode/src/components/ui/pagination.test.tsx b/packages/vitnode/src/components/ui/pagination.test.tsx deleted file mode 100644 index 6e5edff95a..0000000000 --- a/packages/vitnode/src/components/ui/pagination.test.tsx +++ /dev/null @@ -1,108 +0,0 @@ -// @vitest-environment jsdom -import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; - -import { - Pagination, - PaginationContent, - PaginationEllipsis, - PaginationItem, - PaginationLink, - PaginationNext, - PaginationPrevious, -} from "./pagination"; - -const renderPagination = () => - render( - - - - - - {[1, 2, 3].map(page => ( - - - {page} - - - ))} - - - - - , - ); - -describe("Pagination", () => { - it("names every page link by its number", () => { - renderPagination(); - - expect(screen.getByRole("link", { name: "1" }).getAttribute("href")).toBe( - "?page=1", - ); - expect(screen.getByRole("link", { name: "2" })).toBeTruthy(); - expect(screen.getByRole("link", { name: "3" })).toBeTruthy(); - expect(screen.queryByRole("button")).toBeNull(); - }); - - it("marks only the active page as current", () => { - renderPagination(); - - expect( - screen.getByRole("link", { name: "1" }).getAttribute("aria-current"), - ).toBe("page"); - expect( - screen.getByRole("link", { name: "2" }).hasAttribute("aria-current"), - ).toBe(false); - }); - - it("labels previous and next with translated text by default", () => { - renderPagination(); - - const next = screen.getByRole("link", { - name: "core.global.next_page", - }); - const previous = screen.getByRole("link", { - name: "core.global.previous_page", - }); - - expect(next.textContent).toBe("core.global.next"); - expect(previous.textContent).toBe("core.global.previous"); - expect(previous.getAttribute("aria-disabled")).toBe("true"); - expect(previous.hasAttribute("href")).toBe(false); - }); - - it("prefers custom previous and next text", () => { - render( - <> - - - , - ); - - expect( - screen.getByRole("link", { name: "core.global.previous_page" }) - .textContent, - ).toBe("Newer posts"); - expect( - screen.getByRole("link", { name: "core.global.next_page" }).textContent, - ).toBe("Older posts"); - }); - - it("names the navigation landmark with translated text", () => { - renderPagination(); - - expect( - screen.getByRole("navigation", { name: "core.global.pagination" }), - ).toBeTruthy(); - }); - - it("announces skipped pages to screen readers", () => { - render(); - - expect(screen.getByText("core.global.more_pages")).toBeTruthy(); - expect( - screen.getByText("core.global.more_pages").closest("[aria-hidden]"), - ).toBeNull(); - }); -}); diff --git a/packages/vitnode/src/components/ui/progress.test.tsx b/packages/vitnode/src/components/ui/progress.test.tsx deleted file mode 100644 index bc4731c87c..0000000000 --- a/packages/vitnode/src/components/ui/progress.test.tsx +++ /dev/null @@ -1,44 +0,0 @@ -// @vitest-environment jsdom -import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; - -import { Progress, ProgressLabel, ProgressValue } from "./progress"; - -describe("Progress", () => { - it("names the bar with its label and shows the value", () => { - render( - - Uploading - - , - ); - - const bar = screen.getByRole("progressbar", { name: "Uploading" }); - - expect(bar.getAttribute("aria-valuenow")).toBe("42"); - expect(bar.querySelector("[data-slot=progress-value]")?.textContent).toBe( - "42%", - ); - }); - - it("still renders a plain bar without a label", () => { - render(); - - expect(screen.getByRole("progressbar").getAttribute("aria-valuenow")).toBe( - "70", - ); - }); - - it("reports an unknown amount when the value is null", () => { - render( - - Preparing - , - ); - - const bar = screen.getByRole("progressbar", { name: "Preparing" }); - - expect(bar.hasAttribute("aria-valuenow")).toBe(false); - expect(bar.hasAttribute("data-indeterminate")).toBe(true); - }); -}); diff --git a/packages/vitnode/src/components/ui/sidebar.test.tsx b/packages/vitnode/src/components/ui/sidebar.test.tsx index d81a4ddf68..b7dc8df922 100644 --- a/packages/vitnode/src/components/ui/sidebar.test.tsx +++ b/packages/vitnode/src/components/ui/sidebar.test.tsx @@ -1,4 +1,3 @@ -// @vitest-environment jsdom import { fireEvent, render, screen } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/components/ui/spinner.test.tsx b/packages/vitnode/src/components/ui/spinner.test.tsx deleted file mode 100644 index e1d4379d91..0000000000 --- a/packages/vitnode/src/components/ui/spinner.test.tsx +++ /dev/null @@ -1,27 +0,0 @@ -// @vitest-environment jsdom -import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; - -import { Spinner } from "./spinner"; - -describe("Spinner", () => { - it("is labelled 'Loading' in the user's language by default", () => { - render(); - - expect( - screen.getByRole("status", { name: "core.global.loading" }), - ).toBeTruthy(); - }); - - it("keeps a custom label", () => { - render(); - - expect(screen.getByRole("status", { name: "Searching" })).toBeTruthy(); - }); - - it("can be hidden from assistive tech next to text that says it all", () => { - render(