Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
33 changes: 28 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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")),
Expand Down
16 changes: 0 additions & 16 deletions packages/create-vitnode-app/src/plugin/validation.test.ts

This file was deleted.

10 changes: 6 additions & 4 deletions packages/vitnode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
},
Expand Down
1 change: 0 additions & 1 deletion packages/vitnode/scripts/cli/builder/analysis.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
12 changes: 1 addition & 11 deletions packages/vitnode/scripts/cli/builder/measure.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
// @vitest-environment node
import { 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 type { BuildOutputFile, FileCategory } from "./output-files";

import { brotliSize, gzipSize, measureFiles } from "./measure";
import { measureFiles } from "./measure";

let dir: string;

Expand Down Expand Up @@ -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);
});
});
1 change: 0 additions & 1 deletion packages/vitnode/scripts/cli/builder/output-files.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";

import {
Expand Down
65 changes: 40 additions & 25 deletions packages/vitnode/scripts/cli/builder/package-build.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
// @vitest-environment node
import { execFileSync } from "node:child_process";
import {
cpSync,
Expand All @@ -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";
Expand Down Expand Up @@ -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
Expand All @@ -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 });
Expand Down Expand Up @@ -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<ReturnType<typeof build>>;

beforeAll(async () => {
createPackage();
result = await build();
});
Comment on lines +186 to +189

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the build's 60-second timeout

On slower CI runners where the tsdown/typecheck build takes more than 10 seconds, this setup now fails before any assertions run: the former build tests explicitly allowed 60 seconds, but moving the build into beforeAll drops that allowance, and Vitest documents a 10-second default hook timeout. Pass the previous timeout as the hook's second argument or configure an equivalent hookTimeout for the integration project.

Useful? React with 👍 / 👎.


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");
Expand All @@ -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",
);
Expand All @@ -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["'`]/,
Expand All @@ -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("@/");
Expand All @@ -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');
Expand Down
1 change: 0 additions & 1 deletion packages/vitnode/scripts/cli/builder/scripts-build.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
// @vitest-environment node
import { execFileSync, spawnSync } from "node:child_process";
import {
existsSync,
Expand Down
1 change: 0 additions & 1 deletion packages/vitnode/scripts/cli/builder/size-severity.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";

import { classifySize, needsWarning } from "./size-severity";
Expand Down
1 change: 0 additions & 1 deletion packages/vitnode/scripts/cli/builder/snapshot.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
29 changes: 1 addition & 28 deletions packages/vitnode/scripts/cli/commands/build.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
// @vitest-environment node
import type { InlineConfig, Plugin } from "vite";

import {
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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"]);
Expand Down
1 change: 0 additions & 1 deletion packages/vitnode/scripts/cli/commands/db.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
// @vitest-environment node
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
Expand Down
9 changes: 0 additions & 9 deletions packages/vitnode/scripts/cli/commands/dev.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
// @vitest-environment node
import type { ViteDevServer } from "vite";

import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
Expand Down Expand Up @@ -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",
);
});
});
Loading
Loading