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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .claude/skills/writing-unit-tests/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
---
name: writing-unit-tests
description: Use when writing or editing *.test.ts files in this repo (@gravity-ui/create).
---

# Writing Unit Tests (gravity-ui/create)

## Overview

Reference skill distilled from this repo's AGENTS.md files. Native `node:test`, colocated `*.test.ts`, never touch real disk.

## Quick Reference

| Rule | Why |
|---|---|
| Use `test.describe` + `test(name, (t: TestContext) => ...)` with `t.assert.*` | Not top-level `node:assert/strict` + nested `await t.test()` — lint/prettier normalizes to the former anyway |
| Colocate `foo.test.ts` next to `foo.ts` | Repo convention, no `__tests__` dirs |
| Generators: never call real `node:fs` | Always take a `FileSystem` param; tests pass `memfs.ts` |
| Generator tests: `runGenerators({dryRun: true})`, assert on captured files | Never write to real disk in a test |
| `nvm use && npm test` after any behavior change | `.nvmrc` pins Node; wrong Node breaks the TS loader |
| Chicago school: assert on state (captured files, return values), not call-args | London-style interaction mocks aren't used anywhere in this repo's tests |

## Chicago school (state-based), not London (interaction-based)

Verify state/output of the code under test, not "was function X called with args Y" on its collaborators. Concretely in this repo:

- `mock.module('node:fs', ...)` in `destination.test.ts` mocks a builtin, but still fits Chicago school: it substitutes a working fake (backed by `memfs` from the `memfs` npm package) at the one true I/O boundary, and the tests assert on `validateDestination`'s return value (state) — never on whether `existsSync` was called. The distinction is state-vs-interaction verification, not "mocking is banned."
- Don't add call-count/call-args assertions (`t.mock.fn`/`t.mock.method` + `assert(called with...)`) for code that takes a `FileSystem` param — pass a real `memfs` instance and assert on what it captured instead.

## Mocking `node:fs` directly

`eslint.config.js`'s `no-restricted-imports` scopes which file(s) may import `node:fs`/`node:fs/promises` directly — check that rule to find the current file(s). When testing one of those:

`mock.module('node:fs', ...)` only rebinds modules that import the mocked specifier *directly*. Once such a module has been evaluated and its top-level `import {existsSync} from 'node:fs'` linked, `mock.restore()` + a fresh `mock.module()` does **not** rebind it — Node's ESM cache never re-evaluates the module for the same specifier. Remocking per test silently reuses the *first* test's mock in every later test, with no error.

**Working pattern**:
- Mock `node:fs` **once** at module top-level, backed by a single `memfs()` volume.
- Import the module under test once.
- Give each test its own path namespace (`/case-<name>/cwd/...`) inside that shared volume instead of remocking per test.

## Testing pure logic split out of I/O

When a module mixes pure decision logic with unavoidable I/O (terminal prompts, `p.note` formatting), pull the decision logic into a small pure function over plain data and unit-test that directly — e.g. `buildNextSteps(model, cwd)` extracted from `main()` in `src/index.ts`. Don't try to test the I/O wrapper.

## Common Mistakes

- Writing to real disk in a generator test instead of `{dryRun: true}` + memfs.
- Remocking `node:fs` per test instead of once at module top-level.
- Nested `await t.test()` instead of flat `test.describe`/`test`.
- Mocking a direct import of the module under test (e.g. `mock.module`-ing `utils/isModulePackage.js` while testing `generators/base.ts`) instead of driving it with real input data. These are plain functions over the `model`/args already in scope — construct a `model` that produces the branch you want, don't intercept the import.
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
README.md linguist-generated=true
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@
/test-results
/playwright-report
/blob-report
/docs/superpowers/
/lcov.info
57 changes: 57 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# AGENTS.md

## What this is

`@gravity-ui/create` — CLI that scaffolds a new project on the gravity-ui stack. Asks a few questions (or reads CLI flags), builds a `ProjectModel`, then runs generators that write files into the chosen destination folder.

## Source layout

- `src/cli` — arg parsing, help/version/dry-run rendering. Flag behavior itself lives in `CliSchema` (see Conventions).
- `src/prompts` — interactive questionnaire (`@clack/prompts`).
- `src/model` — `ProjectModel`: mutable accumulator, built up by prompts, consumed and further mutated by generators.
- `src/generators` — one file per feature. Each generator registers deps/scripts on the model (via `utils/pm.ts`) and writes files through the `FileSystem` abstraction.
- `templates/` — `.hbs` sources; see `templates/AGENTS.md` for the precompile hook and an eslint-config-specific gotcha.
- `src/utils` — `fs.ts` is the only file eslint allows to import `node:fs`/`node:fs/promises`/`fs` (`no-restricted-imports`, scoped to this one file, covers sync too) — any new fs-touching helper must live here.
- `e2e` — Playwright specs driving CLI-scaffolded output, run via `.github/workflows/e2e.yml`. See `e2e/AGENTS.md`.
- Any `__fixtures__` dir anywhere under `src/` is test-only, excluded from the publish build (`tsconfig.publish.json`'s glob exclude) — never put anything meant to ship in `lib` there.

## Checks

All `npm`/`node`/`npx` commands below MUST be prefixed with `nvm use &&` — wrong Node version breaks the TS loader.

- To run a one-off `.ts` file directly, plain `node file.ts` fails on module resolution — source imports use `.js` extensions (NodeNext) that don't exist on disk. Use: `node --import ./scripts/test-runner-register.js file.ts` (same loader hook `npm test` registers).
- `npm test` — run after any behavior change, always.
- `npm test -- --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=lcov.info --test-coverage-exclude='**/*.test.ts' --test-coverage-exclude='**/__fixtures__/**' --test-coverage-exclude='**/*.hbs.ts' --test-coverage-exclude='**/.*.hbs.ts' --test-coverage-include='src/**'` — coverage report, on demand.
- **Gap**: node's coverage only instruments files actually `import`ed during the run — an untouched file is silently absent from `lcov.info` (not listed at 0%), inflating the reported %. Find genuinely untested files: diff `find src -name '*.ts' ! -name '*.test.ts' ! -path '*/__fixtures__/*' ! -name '*.hbs.ts'` against `grep '^SF:' lcov.info | sed 's/^SF://'` — anything only on the left was never touched by a test.
- `npx tsc --noEmit` — run after any change, always. Running it directly (vs `npm run typecheck`) skips the `pretypecheck` hook that regenerates `.hbs.ts` — see the `.hbs` note below if templates changed.
- `npm run lint -- --fix` and `npm run lint:prettier` — run once a file is believed done (style pass, not per-edit).
- `npm run knip` — run before declaring work finished, to catch unused exports left behind by a refactor. The dummy `E2E_APP_DIR=.` in its script exists only so knip's Playwright plugin can import `playwright.config.ts` (which throws if unset) — it's never used to actually launch anything.
- `.hbs` templates compile to gitignored `.hbs.ts` (never hand-edit). Regen is hooked into `pretest`/`pretypecheck`/`prebuild`/`preknip`, so `npm test`/`npm run knip` always see fresh output — `npx tsc --noEmit` doesn't, run `npm run precompile-templates` manually first if `.hbs` files changed. See `templates/AGENTS.md`.
- `README.md` is generated (see header comment) — `scripts/generate-readme.test.ts` catches drift.

## Conventions

- **Types**: put `types.ts` next to the code that owns it. Only hoist to a shared parent (`generators/types.ts`, `utils/types.ts`) when colocating would cause a circular import.
- **Filesystem**: generators never touch `node:fs` directly — always take a `FileSystem` param, so `--dry-run` and tests can swap in `memfs` instead of writing to disk.
- **CLI flags**: add fields to `CliSchema` only — parsing, per-flag help text (description/placeholder), and validation all derive from it automatically. Don't hand-wire a new flag into `parseArgs` separately. Known gap: `help.ts`'s `Examples` section is hand-written prose (command strings with literal flag names), not derived from the schema — renaming a flag silently leaves it stale, so grep the old name there too.
- **Tests**: native `node:test` (`npm test`), colocated as `*.test.ts` next to the source under test. See `writing-unit-tests` skill for conventions/gotchas.
- **Style**: never hand-fix styling/formatting (import order, quotes, indentation) — run the lint/prettier commands from `## Checks` instead, even for a single misplaced import.
- **View/logic split for testability**: when a module mixes pure data-building with rendering (e.g. terminal `p.note` formatting), separate the two — e.g. `src/cli/dryRun.ts`'s `buildDryRunSummary` (pure, unit-tested) vs `renderDryRunSummary`/`formatDryRunSummary` (terminal output).
- **Derived flags**: a simple single-field check (e.g. `model.hasBackend`) stays inline. Once a decision needs more than one `ProjectModel` field (e.g. `hasAppBuilder = hasBackend || hasFrontend`), add it to `calculateFlags(model)` (`src/utils/calculateFlags.ts`) instead of a one-off predicate.
- **Generated files**: every JS/TS output file goes through a `.hbs` template — no generator writes JS/TS content directly. JSON files are the exception, built via `writeJson`/`JSON.stringify` instead.

## Generated project structure

See [README.md](./README.md#what-gets-generated) for the three shapes and the app-builder directory layout.

Dev/build scripts pick a side via `--target <client|server>` when only one of ui/server exists.

Plain-JS shape's entry stays at project root, not under `src/`: with no bundler and no frontend/backend, there's no convention to commit to, so it stays at the root instead of guessing a layout.

## Valid parameter combinations

`styles`/`react` are only settable when frontend is enabled — enforced twice: `CliSchema`'s `.refine()` checks (`schema.ts`) and structurally by `runPromptFlow.ts` (styles/react prompts only run `if (model.frontend)`). Keep both in sync if this changes. So the frontend axis collapses to 5 states — `false`, or enabled × `{none, styles-only, react-only, styles+react}` — not a raw 2×2 sub-cross.

Combined with `language` (2) and `hasBackend` (2): **20** distinct configs. `registry` (default vs. custom URL) is an independent axis, only toggling whether `generateBase` writes `.npmrc`. No package-manager (npm/yarn/pnpm) axis exists.

What's actually observable at runtime per combo (browser vs stdout vs nothing), and current e2e coverage gaps: see `e2e/AGENTS.md`.
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@AGENTS.md
106 changes: 106 additions & 0 deletions README.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 30 additions & 0 deletions e2e/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# e2e/AGENTS.md

Playwright specs that assert on CLI-scaffolded output rendered in a real browser. Not run by `npm test` — driven by `.github/workflows/e2e.yml`, which builds the CLI, scaffolds one or more throwaway projects into `/tmp`, and points Playwright at whichever one is under test.

## Targeting

- All specs flat in one `testDir` (config) — no per-shape split.
- CI job picks target app by setting `E2E_APP_DIR`/`APP_PORT` before `npx playwright test`, not by pointing at a different testDir.
- Since one unfiltered run would hit every spec against whatever's scaffolded, each spec is tagged `{tag: '@name'}` (e.g. `@frontend-react`/`@frontend-no-react`) and each CI job filters `--grep @tag`. New spec file → add its tag to the right job's grep, else other jobs pick it up and fail.

## What's actually observable per combo (verified by running each, not just reading templates)

`language` and `styles` never affect runtime output — only `hasFrontend`/`hasBackend`/`hasReact` do. That collapses the 20 configs in the root AGENTS.md's parameter matrix to 4 buckets:

| hasFrontend | hasBackend | what you can assert | where |
|---|---|---|---|
| false | false | stdout `Hello, world!` after `npm run build && npm start` | plain entry (`src/index.ts` or root `index.js`), CI `test-cli` job — **ts only, no js coverage** |
| false | true | HTTP body is the literal string `Hello, world!`, **no HTML markup at all** despite `Content-Type: text/html` — verified via curl. A Playwright `h1` locator finds nothing; must assert raw body text | not covered by any CI job |
| true | false | **nothing app-related to assert.** `npm run dev` (`app-builder dev --target client`) only builds the JS bundle to `dist/public/build/`; hitting `/` in a browser gets webpack-dev-server's raw directory listing, not the app — there's no HTML shell without a backend to render `@gravity-ui/app-layout` | not covered, not coverable as scaffolded |
| true | true | `GET /` renders the `@gravity-ui/app-layout` shell with a real `h1` — assert via `assertHeading` fixture (below). Text is `Hello, Gravity UI!` for `--react`, `Hello, world!` for `--no-react`; `--styles` never changes it | CI: `test-e2e-browser` (react, ts only) + `test-e2e-no-react` (no-react, ts **and** js) |

Gaps if adding coverage: no js-language run of the CLI-stdout or react-frontend jobs, and the backend-only / frontend-only buckets have no test at all.

## `--out` must resolve inside the invoking process's cwd

`validateDestination` (`src/utils/destination.ts`) rejects `--out` resolving outside cwd. So `.github/workflows/e2e.yml` scaffolds via `working-directory: /tmp` + relative `--out`, calling CLI by absolute `$GITHUB_WORKSPACE/lib/index.js`. Changing either side: check the other didn't break.

## Fixtures

`fixtures.ts` extends Playwright's base `test` with an `assertHeading(expected)` fixture (goto `/` + assert `h1` text). New spec asserting `h1` after `goto('/')`: use `assertHeading`, don't inline goto+expect. Different assertion shape (different element, multiple assertions, no goto): add a new fixture to `fixtures.ts`, not inline boilerplate — specs stay tag + expected value only.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"lint": "eslint --quiet",
"lint:prettier": "prettier --list-different '**/*.ts'",
"precompile-templates": "node scripts/precompile-templates.ts",
"generate-readme": "node --import ./scripts/test-runner-register.js scripts/generate-readme.ts > README.md",
"pretest": "npm run precompile-templates",
"test": "node --experimental-test-module-mocks --import ./scripts/test-runner-register.js --test",
"test:e2e": "playwright test",
Expand Down
21 changes: 21 additions & 0 deletions scripts/generate-readme.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import {execFile as execFileCb} from 'node:child_process';
import path from 'node:path';
import {type TestContext, test} from 'node:test';
import {promisify} from 'node:util';

import readme from '../README.md' with {type: 'text'};

const execFile = promisify(execFileCb);
const rootDir = path.join(import.meta.dirname, '..');

test.describe('generate-readme', () => {
test('README.md matches generated output', async (t: TestContext) => {
const {stdout} = await execFile(
process.execPath,
['--import', './scripts/test-runner-register.js', 'scripts/generate-readme.ts'],
{cwd: rootDir},
);

t.assert.strictEqual(stdout, readme);
});
});
46 changes: 46 additions & 0 deletions scripts/generate-readme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import {readFile} from 'node:fs/promises';
import path from 'node:path';

import Handlebars from 'handlebars';

import {buildHelpData} from '../src/cli/help.js';
import type {FlagSignature} from '../src/cli/types.js';

const rootDir = path.join(import.meta.dirname, '..');
const templatesDir = path.join(import.meta.dirname, 'readme');

function formatSignature({parts, placeholder}: FlagSignature): string {
return placeholder ? `${parts.join(', ')} ${placeholder}` : parts.join(', ');
}

function buildContext(pkg: {engines: {node: string}}) {
const {groups, examples} = buildHelpData();

return {
groups: groups.map(({label, flags}) => ({
label,
flags: flags.map((flag) => ({
signature: formatSignature(flag.signature),
description: flag.choices
? `${flag.description} (${flag.choices.join('|')})`
: flag.description,
})),
})),
examples: examples.map(([cmd, comment]) => ({cmd, comment})),
engines: pkg.engines.node,
};
}

const [readmeSource, flagsTableSource, pkgSource] = await Promise.all([
readFile(path.join(templatesDir, 'readme.hbs'), 'utf8'),
readFile(path.join(templatesDir, 'flagsTable.hbs'), 'utf8'),
readFile(path.join(rootDir, 'package.json'), 'utf8'),
]);

Handlebars.registerHelper(
'tableCell',
(value: string) => new Handlebars.SafeString(value.replaceAll('|', '\\|')),
);
Handlebars.registerPartial('flagsTable', flagsTableSource);
const template = Handlebars.compile(readmeSource);
process.stdout.write(template(buildContext(JSON.parse(pkgSource))));
4 changes: 4 additions & 0 deletions scripts/md.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
declare module '*.md' {
const content: string;
export default content;
}
Loading
Loading