@app-studio/qa-seed
v0.5.0
Published
Seeds as per-feature modules composed in dependency order under a cross-process lock, plus the dependency-free HTTP verifier that doubles as the end-to-end readiness gate.
Readme
@app-studio/qa-seed
Seeds as per-feature modules, composed in dependency order under a lock — plus the dependency-free HTTP verifier that doubles as the end-to-end readiness gate.
What a seed is for
Not a foundation the system rests on. The world a given scenario needs, so it can be replayed in the same shape by anyone, forever. "An administrator invites someone who is already a member" needs a workspace, an administrator and that member; the seed for it is exactly that and nothing more.
Which is why seeds come after the API decisions, never before: data written before the API is data written against a guess.
Discovered, not declared
// src/features/identity/seed/index.ts
import type { SeedModule } from '@app-studio/qa-seed';
const seed: SeedModule<AppContext> = {
key: 'identity',
async run({ auth }) {
const admin = await auth.signUp({ email: '[email protected]', password: 'password123456' });
return [{ name: 'identity.adminId', value: admin.id }];
},
};
export default seed;const modules = await discoverSeedModules({ rootDir, pattern: 'src/features/*/seed/index.ts' });
await runSeed({ rootDir, modules, context });Dropping the file into a feature makes it seed; deleting the feature deletes its seed. There is no central list, because a central list is the thing that silently goes wrong: a feature is removed, its entry stays, and the next person to read the list believes in a feature that no longer exists.
Three rules that are not style preferences
Call the real services, never SQL. An administrator inserted into a members table is a different administrator from one who accepted an invitation — no audit entry, no analytics event, no realtime publish. Every test that then relies on those side effects is testing a world the application cannot actually produce.
Declare the order; do not assume it. File order is not a dependency graph,
and a seed that happens to work because of how a glob sorted its inputs breaks
the day someone renames a feature. composeSeeds topologically sorts by
dependsOn, names the cycle when there is one, and is deterministic so two runs
of one registry produce one order.
Idempotence is the property that breaks. A seed run twice against a database somebody forgot to reset must produce the same world, not a second copy of it.
The lock
runSeed holds a cross-process lock for the duration. Not caution: two seed
runs against one database interleave into a state neither of them describes, and
the resulting failures look like application bugs — a row that exists twice, a
unique constraint firing on data the code never wrote.
The lock is keyed by checkout, so two worktrees seed concurrently and two shells in the same checkout do not. A stale lock is reported with the holder's details and the exact command to clear it, never deleted automatically — two waiters can both observe a dead holder and both delete the file, and the second delete evicts a lock the first has just legitimately taken.
Verifying, over HTTP
import { expectNonEmpty, verifySeed, waitForSeed } from '@app-studio/qa-seed';
export const checks = [
expectNonEmpty(
'a workspace exists',
'/api/workspaces',
'Every journey signs in and lands in a workspace; with none seeded, they all fail on the first screen.',
),
];Over HTTP and not over SQL, deliberately. In an end-to-end run the API often holds the only connection to the database — an embedded engine, a pooled connection, a container the test process cannot reach — so a SQL verifier would either fail to connect or, worse, connect to a different database and cheerfully report success.
Every check carries a proves line, and the report prints it. "Expected 1, got
0" sends somebody to read the check; "with none seeded, they all fail on the
first screen" sends them to fix the seed.
The same function is the readiness gate. Nothing that drives a browser
should start before it passes: a UI suite launched against an unseeded database
fails somewhere in the middle of a screen, with a screenshot of an empty list
and nothing saying the data was never there. qa e2e runs this between starting
the API and starting anything a browser will talk to.
waitForSeed is the same checks with a deadline, for the boot window. Failure
reports what was still wrong, not just that time ran out.
Exports
| Export | Purpose |
| --- | --- |
| SeedModule, composeSeeds | The unit, and the dependency-ordered composition. |
| discoverSeedModules | Find them by looking. |
| runSeed | Run them in order, under the lock, collecting published artifacts. |
| verifySeed, waitForSeed, formatVerifyReport | Prove the world is there. |
| expectNonEmpty, expectJson | The commonest checks, written out. |
Requirements
Node.js 20 or newer. No runtime dependencies beyond @app-studio/qa-core and a
glob — the verifier in particular is dependency-free so it can run anywhere,
including in a container that has nothing else installed.
