@jskit-ai/agent-docs 0.1.280 → 0.1.282

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/guide/agent/app-extras/assistant-conversation.md +5 -1
  2. package/guide/agent/app-extras/realtime.md +20 -10
  3. package/guide/agent/app-setup/authentication.md +20 -1
  4. package/guide/agent/app-setup/database-layer.md +75 -31
  5. package/guide/agent/app-setup/existing-application-migration.md +7 -1
  6. package/guide/agent/app-setup/multi-homing.md +6 -2
  7. package/guide/agent/app-setup/upgrading-jskit.md +78 -0
  8. package/guide/agent/framework/client-features.md +35 -1
  9. package/guide/agent/framework/sanity-checks.md +4 -3
  10. package/guide/agent/framework/typed-features.md +79 -30
  11. package/package.json +1 -1
  12. package/patterns/feature-package/example/booking-engine/package.json +1 -1
  13. package/patterns/minimal-foundation/example/AGENTS.md +14 -7
  14. package/patterns/minimal-foundation/example/package.json +3 -3
  15. package/patterns/minimal-foundation/example/playwright.config.mjs +3 -33
  16. package/patterns/minimal-foundation/example/server.js +7 -1
  17. package/patterns/minimal-foundation/example/tests/e2e/startup.spec.ts +13 -0
  18. package/patterns/shell-foundation/example/AGENTS.md +14 -7
  19. package/patterns/shell-foundation/example/package.json +4 -4
  20. package/patterns/shell-foundation/example/packages/main/package.json +1 -1
  21. package/patterns/shell-foundation/example/playwright.config.mjs +3 -33
  22. package/patterns/shell-foundation/example/server.js +7 -1
  23. package/patterns/shell-foundation/example/tests/e2e/startup.spec.ts +13 -0
  24. package/reference/autogen/KERNEL_MAP.md +2 -0
  25. package/reference/autogen/PATTERN_INDEX.md +37 -37
  26. package/reference/autogen/packages/architecture-inspector.md +1 -0
  27. package/reference/autogen/packages/assistant-runtime.md +1 -1
  28. package/reference/autogen/packages/auth-provider-local-core.md +2 -1
  29. package/reference/autogen/packages/connectors-catalog.md +8 -0
  30. package/reference/autogen/packages/database-runtime.md +33 -0
  31. package/reference/autogen/packages/http-runtime.md +1 -1
  32. package/reference/autogen/packages/kernel.md +29 -1
  33. package/reference/autogen/packages/oidc-issuer-core.md +0 -1
  34. package/reference/autogen/packages/realtime.md +8 -5
  35. package/reference/autogen/packages/shell-web.md +4 -3
  36. package/reference/autogen/packages/workspaces-core.md +1 -1
  37. package/reference/autogen/tooling/jskit-catalog.md +71 -1
  38. package/skills/jskit/SKILL.md +8 -6
  39. package/skills/jskit/references/client-features.md +35 -1
  40. package/skills/jskit/references/existing-application-migration.md +7 -1
  41. package/skills/jskit/references/heuristics/client-requests.md +1 -0
  42. package/skills/jskit/references/heuristics/typed-features.md +2 -2
  43. package/skills/jskit/references/heuristics/ui-testing.md +21 -16
  44. package/skills/jskit/references/multi-homing.md +6 -2
  45. package/skills/jskit/references/pattern-index.md +37 -37
  46. package/skills/jskit/references/patterns/app/minimal-foundation/example/AGENTS.md +14 -7
  47. package/skills/jskit/references/patterns/app/minimal-foundation/example/package.json +3 -3
  48. package/skills/jskit/references/patterns/app/minimal-foundation/example/playwright.config.mjs +3 -33
  49. package/skills/jskit/references/patterns/app/minimal-foundation/example/server.js +7 -1
  50. package/skills/jskit/references/patterns/app/minimal-foundation/example/tests/e2e/startup.spec.ts +13 -0
  51. package/skills/jskit/references/patterns/app/shell-foundation/example/AGENTS.md +14 -7
  52. package/skills/jskit/references/patterns/app/shell-foundation/example/package.json +4 -4
  53. package/skills/jskit/references/patterns/app/shell-foundation/example/packages/main/package.json +1 -1
  54. package/skills/jskit/references/patterns/app/shell-foundation/example/playwright.config.mjs +3 -33
  55. package/skills/jskit/references/patterns/app/shell-foundation/example/server.js +7 -1
  56. package/skills/jskit/references/patterns/app/shell-foundation/example/tests/e2e/startup.spec.ts +13 -0
  57. package/skills/jskit/references/patterns/auth/accounts-hub/example/package.json +2 -2
  58. package/skills/jskit/references/patterns/auth/oidc-sign-in/example/package.json +2 -2
  59. package/skills/jskit/references/patterns/auth/supabase-auth/example/package.json +1 -1
  60. package/skills/jskit/references/patterns/connectors/calendar-cli/example/package.json +4 -4
  61. package/skills/jskit/references/patterns/crud/json-api-resource-package/example/packages/books/package.json +2 -2
  62. package/skills/jskit/references/patterns/database/mysql-application/PATTERN.md +34 -24
  63. package/skills/jskit/references/patterns/database/mysql-application/example/package.json +3 -2
  64. package/skills/jskit/references/patterns/database/mysql-application/example/tests/e2e/databases.js +25 -0
  65. package/skills/jskit/references/patterns/database/postgres-application/PATTERN.md +12 -0
  66. package/skills/jskit/references/patterns/database/postgres-application/example/package.json +3 -2
  67. package/skills/jskit/references/patterns/database/postgres-application/example/tests/e2e/databases.js +25 -0
  68. package/skills/jskit/references/patterns/realtime/realtime-application/example/package.json +2 -2
  69. package/skills/jskit/references/patterns/server/feature-package/example/booking-engine/package.json +1 -1
  70. package/skills/jskit/references/patterns/users/user-administration-server/example/packages/users/package.json +2 -2
  71. package/skills/jskit/references/patterns/users/user-administration-server/example/packages/users-workspace/package.json +3 -3
  72. package/skills/jskit/references/sanity-checks.md +4 -3
  73. package/skills/jskit/references/typed-features.md +79 -30
  74. package/skills/jskit/references/patterns/database/mysql-application/example/tests/browser/database.js +0 -68
@@ -2,6 +2,6 @@
2
2
  "private": true,
3
3
  "type": "module",
4
4
  "dependencies": {
5
- "@jskit-ai/auth-provider-supabase-core": "0.2.13"
5
+ "@jskit-ai/auth-provider-supabase-core": "0.2.15"
6
6
  }
7
7
  }
@@ -7,10 +7,10 @@
7
7
  "db:migrate:status": "knex --knexfile ./knexfile.js migrate:list"
8
8
  },
9
9
  "dependencies": {
10
- "@jskit-ai/connectors-core": "0.2.12",
11
- "@jskit-ai/connector-google-calendar": "0.2.12",
12
- "@jskit-ai/database-runtime": "0.1.260",
13
- "@jskit-ai/database-runtime-mysql": "0.1.258",
10
+ "@jskit-ai/connectors-core": "0.2.14",
11
+ "@jskit-ai/connector-google-calendar": "0.2.14",
12
+ "@jskit-ai/database-runtime": "0.1.262",
13
+ "@jskit-ai/database-runtime-mysql": "0.1.260",
14
14
  "knex": "^3.1.0"
15
15
  }
16
16
  }
@@ -9,8 +9,8 @@
9
9
  "./shared": "./src/shared/index.js"
10
10
  },
11
11
  "dependencies": {
12
- "@jskit-ai/crud-core": "0.2.12",
13
- "@jskit-ai/resource-crud-core": "0.1.202"
12
+ "@jskit-ai/crud-core": "0.2.14",
13
+ "@jskit-ai/resource-crud-core": "0.1.204"
14
14
  },
15
15
  "jskit": {
16
16
  "kind": "runtime",
@@ -61,27 +61,38 @@ metadata. Do not combine it with the PostgreSQL pattern's workflow: a MySQL
61
61
  application's workflow has no `postgres` service, `POSTGRES_*` variables, port
62
62
  `5432`, or `pg_isready` option.
63
63
 
64
- `example/tests/browser/database.js` is the application-owned browser preparation
65
- helper. It requires the exact `BROWSER_TEST_DB_NAME`, rejects the ordinary and
66
- disposable `TEST_DB_NAME` databases,
67
- retains the schema and migration ledger, applies pending migrations, then clears
68
- test rows and calls one explicit fixture seed transaction. The seed must restore
69
- any migration-owned baseline rows the app needs. Call it once before starting
70
- the isolated test server, never in `beforeEach`. It closes its connections without
71
- dropping the database. The launcher selects that database only for its own server
72
- and identity process, disables external effects, and proves actual server identity
73
- before the suite writes data. It must not edit the normal environment. Provision
74
- both test databases with exact grants for the application account. A disposable
75
- integration or migration test must never drop the browser database.
76
-
77
- Startup reports whether the schema exists, the number of migrations applied,
78
- and the migration, fixture-reset and seed durations. Use those measurements to
79
- diagnose a slow start before rerunning a browser case.
64
+ Tests get their own databases from
65
+ `@jskit-ai/database-runtime/server/testDatabases`. The `test` script runs
66
+ `jskit test --databases`: each Node test file running at once gets its own
67
+ copy of `TEST_DB_NAME` (`<name>`, `<name>_1`, ...), restored from a stored
68
+ snapshot of the current migrations' schema and the rows they write, in
69
+ `node_modules/.cache/jskit-test-databases/`. The snapshot is replayed once, and
70
+ again only when a migration's name or contents or `package-lock.json` change;
71
+ a file that migrates or drops tables leaves its copy to be restored before the
72
+ next file takes it.
73
+
74
+ `example/tests/e2e/databases.js` is the browser run's `prepare` module for
75
+ `browserTestConfig({ prepare: "tests/e2e/databases.js" })` from
76
+ `@jskit-ai/jskit-catalog/browser-tests`. It gives each Playwright worker's
77
+ server its own copy of `BROWSER_TEST_DB_NAME`, restored from the same kind of
78
+ snapshot and seeded in one transaction by the app's fixture `seed`, which
79
+ restores any migration-owned baseline rows the app needs. Every server serves
80
+ the production build. Both refuse the application's database (`DB_NAME`,
81
+ `DATABASE_URL`'s, the Knex connection's), `PROD_DB_NAME` and the other test
82
+ database and its copies. Provision `TEST_DB_NAME`, `BROWSER_TEST_DB_NAME` and
83
+ their numbered copies with grants for the application account (a `<app>_%`
84
+ grant covers them); a user who may create no copies runs one file or worker at
85
+ a time. Two test runs at once need different database names. Test-only server
86
+ settings that disable external effects (email, payments) go in
87
+ `browserTestConfig({ env })`; a suite that writes data proves the server's
88
+ identity first.
89
+
90
+ Each preparation reports how many databases it made, whether it replayed the
91
+ migrations for a new snapshot, and the restore and seed durations. Use those
92
+ measurements to diagnose a slow start before rerunning a browser case.
80
93
 
81
94
  Keep fixtures small for the selected test scope. Run related cases in one suite
82
- invocation. Use the development launcher for ordinary browser checks; test the
83
- production build separately when needed. Fresh-schema migration proofs are
84
- separate from browser startup.
95
+ invocation. Fresh-schema migration proofs are separate from browser startup.
85
96
 
86
97
  ## Variation points
87
98
 
@@ -99,11 +110,10 @@ it as `seed` to `prepareDatabaseFromApp()`.
99
110
  - Run `npm run db:migrate:status` and the application verification command.
100
111
  - Exercise one transaction and one negative connection case.
101
112
  - When a seed exists, run `db:prepare` twice and require the second run to be safe.
102
- - For browser preparation, run a disposable test between two starts: the second applies no
103
- old migrations, stale fixture data is removed, baseline rows are restored,
104
- foreign-key checks remain enabled, and the normal database is unchanged.
105
- Also prove that a new pending migration is applied. Keep this as a regression
106
- test when adapting the example; reject a harness that recreates the schema.
113
+ - For the test databases, run the suite twice: the second run restores the
114
+ stored snapshot instead of replaying the migrations, stale rows are gone,
115
+ baseline rows are restored, foreign keys are enforced, and the normal
116
+ database is unchanged. A new migration takes a new snapshot.
107
117
  - Confirm `.github/workflows/verify.yml` parses as YAML and declares only the
108
118
  `mariadb` service. GitHub reports an unparseable workflow only as a failed
109
119
  run with "no jobs were run".
@@ -5,9 +5,10 @@
5
5
  "db:prepare": "node ./scripts/prepare-database.js",
6
6
  "db:migrate": "knex --knexfile ./knexfile.js migrate:latest",
7
7
  "db:migrate:rollback": "knex --knexfile ./knexfile.js migrate:rollback",
8
- "db:migrate:status": "knex --knexfile ./knexfile.js migrate:list"
8
+ "db:migrate:status": "knex --knexfile ./knexfile.js migrate:list",
9
+ "test": "jskit test --databases"
9
10
  },
10
11
  "dependencies": {
11
- "@jskit-ai/database-runtime-mysql": "0.1.258"
12
+ "@jskit-ai/database-runtime-mysql": "0.1.260"
12
13
  }
13
14
  }
@@ -0,0 +1,25 @@
1
+ import { prepareTestDatabases } from "@jskit-ai/database-runtime/server/testDatabases";
2
+ import knexConfig from "../../knexfile.js";
3
+
4
+ // The browser run's `prepare` module: `browserTestConfig({ prepare: "tests/e2e/databases.js" })` in
5
+ // playwright.config.mjs runs it once, before the servers start. Every Playwright worker gets its own copy of
6
+ // BROWSER_TEST_DB_NAME (`<name>`, `<name>_1`, ...), restored from the schema snapshot of the current migrations, then
7
+ // seeded; the normal and disposable TEST_DB_NAME databases are refused. It is also given each worker's `baseUrls`,
8
+ // for a server setting that names its own address.
9
+
10
+ /** The fixtures the specs rely on, including any migration-owned baseline rows the app needs. */
11
+ async function seed(transaction) {
12
+ await transaction("fixtures").insert({ id: 1, label: "Browser fixture" });
13
+ }
14
+
15
+ export default async function prepareBrowserDatabases({ workers }) {
16
+ const { databases, summary } = await prepareTestDatabases({
17
+ knexConfig,
18
+ environment: process.env,
19
+ variable: "BROWSER_TEST_DB_NAME",
20
+ count: workers,
21
+ seed
22
+ });
23
+ console.log(`[browser-tests] ${summary}.`);
24
+ return databases.map((database) => ({ DB_NAME: database }));
25
+ }
@@ -53,6 +53,18 @@ dialect. `example/scripts/prepare-database.js` is the portable
53
53
  migrate-then-seed entrypoint for managed sessions and deployments.
54
54
  `example/.env.example` names the five connection values without a secret.
55
55
 
56
+ Tests get their own databases from
57
+ `@jskit-ai/database-runtime/server/testDatabases`. The `test` script runs
58
+ `jskit test --databases`: each Node test file running at once gets a copy of
59
+ `TEST_DB_NAME`, created from `<TEST_DB_NAME>_snapshot`, a database the
60
+ migrations are replayed into once and again only when a migration changes.
61
+ `example/tests/e2e/databases.js` is the browser run's `prepare` module for
62
+ `browserTestConfig({ prepare: "tests/e2e/databases.js" })` from
63
+ `@jskit-ai/jskit-catalog/browser-tests`: each Playwright worker's server gets
64
+ its own seeded copy of `BROWSER_TEST_DB_NAME`. The database user needs
65
+ `CREATEDB`; without it, Node test files run one at a time on the connected
66
+ database.
67
+
56
68
  `example/.github/workflows/verify.yml` is a normal app-owned CI workflow with
57
69
  exactly one database service: `postgres` (`postgres:17`, `POSTGRES_*`
58
70
  variables, port `5432`, a `pg_isready` health check). Copy it whole from this
@@ -5,9 +5,10 @@
5
5
  "db:prepare": "node ./scripts/prepare-database.js",
6
6
  "db:migrate": "knex --knexfile ./knexfile.js migrate:latest",
7
7
  "db:migrate:rollback": "knex --knexfile ./knexfile.js migrate:rollback",
8
- "db:migrate:status": "knex --knexfile ./knexfile.js migrate:list"
8
+ "db:migrate:status": "knex --knexfile ./knexfile.js migrate:list",
9
+ "test": "jskit test --databases"
9
10
  },
10
11
  "dependencies": {
11
- "@jskit-ai/database-runtime-postgres": "0.1.257"
12
+ "@jskit-ai/database-runtime-postgres": "0.1.259"
12
13
  }
13
14
  }
@@ -0,0 +1,25 @@
1
+ import { prepareTestDatabases } from "@jskit-ai/database-runtime/server/testDatabases";
2
+ import knexConfig from "../../knexfile.js";
3
+
4
+ // The browser run's `prepare` module: `browserTestConfig({ prepare: "tests/e2e/databases.js" })` in
5
+ // playwright.config.mjs runs it once, before the servers start. Every Playwright worker gets its own copy of
6
+ // BROWSER_TEST_DB_NAME (`<name>`, `<name>_1`, ...), created from the snapshot database of the current migrations with
7
+ // CREATE DATABASE ... TEMPLATE, then seeded; the normal and disposable TEST_DB_NAME databases are refused. The database
8
+ // user needs CREATEDB.
9
+
10
+ /** The fixtures the specs rely on, including any migration-owned baseline rows the app needs. */
11
+ async function seed(transaction) {
12
+ await transaction("fixtures").insert({ id: 1, label: "Browser fixture" });
13
+ }
14
+
15
+ export default async function prepareBrowserDatabases({ workers }) {
16
+ const { databases, summary } = await prepareTestDatabases({
17
+ knexConfig,
18
+ environment: process.env,
19
+ variable: "BROWSER_TEST_DB_NAME",
20
+ count: workers,
21
+ seed
22
+ });
23
+ console.log(`[browser-tests] ${summary}.`);
24
+ return databases.map((database) => ({ DB_NAME: database }));
25
+ }
@@ -2,7 +2,7 @@
2
2
  "private": true,
3
3
  "type": "module",
4
4
  "dependencies": {
5
- "@jskit-ai/realtime": "0.2.17",
6
- "@jskit-ai/shell-web": "0.2.14"
5
+ "@jskit-ai/realtime": "0.2.19",
6
+ "@jskit-ai/shell-web": "0.2.16"
7
7
  }
8
8
  }
@@ -5,7 +5,7 @@
5
5
  "private": true,
6
6
  "type": "module",
7
7
  "dependencies": {
8
- "@jskit-ai/kernel": "0.2.11",
8
+ "@jskit-ai/kernel": "0.2.13",
9
9
  "json-rest-schema": "^1.0.17"
10
10
  },
11
11
  "exports": {
@@ -10,8 +10,8 @@
10
10
  "./shared": "./src/shared/index.js"
11
11
  },
12
12
  "dependencies": {
13
- "@jskit-ai/crud-core": "0.2.12",
14
- "@jskit-ai/resource-crud-core": "0.1.202"
13
+ "@jskit-ai/crud-core": "0.2.14",
14
+ "@jskit-ai/resource-crud-core": "0.1.204"
15
15
  },
16
16
  "jskit": {
17
17
  "kind": "runtime",
@@ -10,9 +10,9 @@
10
10
  "./shared": "./src/shared/index.js"
11
11
  },
12
12
  "dependencies": {
13
- "@jskit-ai/crud-core": "0.2.12",
14
- "@jskit-ai/resource-crud-core": "0.1.202",
15
- "@jskit-ai/workspaces-core": "0.2.13"
13
+ "@jskit-ai/crud-core": "0.2.14",
14
+ "@jskit-ai/resource-crud-core": "0.1.204",
15
+ "@jskit-ai/workspaces-core": "0.2.15"
16
16
  },
17
17
  "jskit": {
18
18
  "kind": "runtime",
@@ -78,9 +78,10 @@ expects the relationship id or an unselected field never gets it.
78
78
 
79
79
  ### Query counts and batches
80
80
 
81
- - Count a path's queries with the test Knex instance's `query` event at a few
82
- rows and at a realistic number; the count should follow tables and pages,
83
- not rows ([Query counts at realistic
81
+ - Count a path's queries with `countQueries()` from
82
+ `@jskit-ai/database-runtime/server/testDatabases` at a few rows and at a
83
+ realistic number; the count should follow tables and pages, not rows
84
+ ([Query counts at realistic
84
85
  sizes](typed-features.md#query-counts-at-realistic-sizes)).
85
86
  - A JSON:API read adds a few lookup and visibility queries per page and per
86
87
  relationship field. That follows pages, so it is expected.
@@ -1025,12 +1025,27 @@ export function resetInvoiceCacheForTests() {
1025
1025
 
1026
1026
  ### Query counts at realistic sizes
1027
1027
 
1028
- **A list, batch or write path's database test counts its queries with the
1029
- test Knex instance's `query` event at a few rows and at a realistic number,
1030
- and fails when the count grows with the rows instead of the pages.**
1031
-
1028
+ **A list, batch or write path's database test counts its queries with
1029
+ `countQueries()` at a few rows and at a realistic number, and fails when the
1030
+ count grows with the rows instead of the pages.**
1031
+
1032
+ - `countQueries(knex, work)`, from
1033
+ `@jskit-ai/database-runtime/server/testDatabases`, runs `work` and returns
1034
+ its `result`, the `count` of statements the Knex instance or transaction
1035
+ sent meanwhile (a transaction's `BEGIN` and `COMMIT` included) and their
1036
+ SQL in `queries`. Pass the Knex the path runs on: the one the test built its
1037
+ repository or service with.
1038
+ - A route is counted through the server the test boots in its own process
1039
+ (`createServer()`, then `app.inject()`): `countRuntimeQueries(work)`, from
1040
+ the same module, records what the app's own `runtime.database` sends while
1041
+ `work` runs, and leaves out the test's own Knex. A server in another process
1042
+ is out of its reach, so the route's guard boots it in the test.
1032
1043
  - Pages add a few queries; rows should add none. A path that pages nothing
1033
1044
  (one read, one write) compares a short and a long history.
1045
+ - When the counts differ more than the pages explain, read `queries`: the
1046
+ statement that repeats once per row is the one to batch. Judge the
1047
+ comparison by what the path has to read; never tune a fixed ceiling until
1048
+ it passes.
1034
1049
  - When it fails, fix the reads ([Lists read related rows in
1035
1050
  batches](#lists-read-related-rows-in-batches), [Bounded access
1036
1051
  checks](#bounded-access-checks)).
@@ -1041,17 +1056,7 @@ cannot.
1041
1056
 
1042
1057
  ```js
1043
1058
  // good
1044
- async function countQueries(knex, work) {
1045
- let queries = 0;
1046
- const count = () => { queries += 1; };
1047
- knex.on("query", count);
1048
- try {
1049
- await work();
1050
- } finally {
1051
- knex.off("query", count);
1052
- }
1053
- return queries;
1054
- }
1059
+ import { countQueries, countRuntimeQueries } from "@jskit-ai/database-runtime/server/testDatabases";
1055
1060
 
1056
1061
  test("the invoice list's queries follow its pages, not its invoices", async () => {
1057
1062
  const listAt = async (count) => {
@@ -1062,7 +1067,22 @@ test("the invoice list's queries follow its pages, not its invoices", async () =
1062
1067
  const few = await listAt(10);
1063
1068
  const many = await listAt(300);
1064
1069
  // Thirty times the invoices may add pages; a read per invoice would add about 290 queries.
1065
- assert.ok(many <= few * 2, `${few} queries for 10 invoices, ${many} for 300`);
1070
+ assert.ok(many.count <= few.count * 2,
1071
+ `${few.count} queries for 10 invoices, ${many.count} for 300:\n${many.queries.join("\n")}`);
1072
+ });
1073
+
1074
+ // good: the same comparison for the route, through the server this test booted
1075
+ test("GET /api/invoices reads per page, not per invoice", async () => {
1076
+ const app = await createServer({ runtimeEnv });
1077
+ const listAt = async (count) => {
1078
+ const workspace = await createWorkspace(knex);
1079
+ await seedInvoices(knex, { workspace, count, linesEach: 5 });
1080
+ return countRuntimeQueries(() => app.inject({ url: invoicesUrl(workspace), headers: signedIn }));
1081
+ };
1082
+ const few = await listAt(10);
1083
+ const many = await listAt(300);
1084
+ assert.ok(many.count <= few.count * 2,
1085
+ `${few.count} queries for 10 invoices, ${many.count} for 300:\n${many.queries.join("\n")}`);
1066
1086
  });
1067
1087
 
1068
1088
  // bad: three invoices, nothing counted, so a read per invoice passes
@@ -1096,37 +1116,63 @@ and Vitest's `maxWorkers` come from `testConcurrency()`.**
1096
1116
  file uses, its own Vite `cacheDir` for each dev server it starts (Vite
1097
1117
  replaces a shared dependency cache under servers still reading it), and no
1098
1118
  writes into the source tree. Files that collide are isolated, never
1099
- serialised. A Playwright spec works on its own records, so
1100
- specs can share one server.
1119
+ serialised. A database test file gets its own copy of `TEST_DB_NAME` from
1120
+ `jskit test --databases`
1121
+ ([integration test databases](https://mobily-enterprises.github.io/jskit-ai/guide/app-setup/database-layer#integration-test-databases)).
1122
+ - An application's `playwright.config.mjs` is
1123
+ `defineConfig(browserTestConfig({ testDir: "./tests/e2e" }))` from
1124
+ `@jskit-ai/jskit-catalog/browser-tests`. It builds the production frontend
1125
+ once (reused while its sources are unchanged) and gives each worker its own
1126
+ server, port and, through its `prepare` module, its own seeded database; a
1127
+ spec file's tests run in order in one worker. With `PLAYWRIGHT_BASE_URL`, the
1128
+ tests use that managed target as its host serves it. Every run is bounded by
1129
+ a `globalTimeout` (20 minutes, or the suite's own measured bound) and ends
1130
+ with its 10 slowest tests. With a `signIn` module, each worker's server is
1131
+ signed in once and every test of that worker starts signed in, instead of
1132
+ each test signing in. `jskit check` fails a webServer that runs the development server
1133
+ (`npm run develop`, `vite`), one webServer shared by every worker, and a
1134
+ config without a `globalTimeout`.
1101
1135
  - A file that truly cannot run beside others, because it holds a resource of
1102
1136
  the whole machine, says why on its first line,
1103
1137
  `// jskit-test: alone - <reason>`. `jskit test` runs it after the others,
1104
1138
  one such file at a time, and fails on a marker without a reason or below the
1105
1139
  first line.
1140
+ - `jskit test` ends with its 10 slowest test files and tests, records each
1141
+ file's time in `node_modules/.cache/jskit-test/file-times.json`, and fails
1142
+ when a file takes more than 10 minutes or the whole command more than 45:
1143
+ bounds only a clearly broken suite reaches, such as a file waiting out its
1144
+ timeouts. A suite whose measured work needs more sets `runMs`, `fileMs` or a
1145
+ file's own bound in `test-time-bounds.json`, with the measurement in the
1146
+ commit that raises it; a file that got slow is fixed instead. A file still
1147
+ running at twice its bound, or sooner at ten times the time it last finished
1148
+ in (at least two minutes), is stopped and fails the run by name, so the other
1149
+ files still finish and report: look for what it leaves open (a server, child
1150
+ process, timer or socket) or a test without a timeout. A file with its own
1151
+ bound is stopped at twice that bound.
1106
1152
 
1107
1153
  Why: `jskit test` and `testConcurrency()` read one budget, so every runner
1108
1154
  uses the machine without starving the other runs on it.
1109
1155
 
1110
1156
  ```js
1111
1157
  // good: package.json "test": "jskit test", and playwright.config.mjs
1112
- import { testConcurrency } from "@jskit-ai/jskit-catalog/test-budget";
1158
+ import { browserTestConfig } from "@jskit-ai/jskit-catalog/browser-tests";
1113
1159
 
1114
- export default defineConfig({ workers: testConcurrency() });
1160
+ export default defineConfig(browserTestConfig({ testDir: "./tests/e2e" }));
1115
1161
 
1116
1162
  // bad: "test": "node --test --test-concurrency=1", and
1117
- export default defineConfig({ workers: 1 });
1163
+ export default defineConfig({ workers: 1, webServer: { command: "npm run develop" } });
1118
1164
  ```
1119
1165
 
1120
1166
  ### Test databases are prepared once
1121
1167
 
1122
1168
  **A test run prepares each database once and restores it from a stored
1123
- snapshot of the schema, migration ledger and seed rows, checked by a digest of
1124
- their sources.**
1169
+ snapshot of the schema and the rows the migrations write, migration ledger
1170
+ included, checked by a digest of the migrations.**
1125
1171
 
1126
- - The snapshot is rebuilt only when the digest of the migration and seed
1127
- sources changes, once and outside the timed tests; every disposable
1172
+ - The snapshot is rebuilt only when the digest of the migrations' names and
1173
+ contents changes, once and outside the timed tests; every disposable
1128
1174
  database, and every database a test migrated up or down, is restored from
1129
- it.
1175
+ it. A browser run's fixture seed runs after each restore.
1130
1176
  - The full migration history runs once, in its own fresh-schema check
1131
1177
  ([database verification](https://mobily-enterprises.github.io/jskit-ai/guide/app-setup/database-layer#verification)).
1132
1178
  - The harness reports each preparation phase and its duration, and those
@@ -1140,8 +1186,8 @@ Why: an application's migrations include those of every installed package, so
1140
1186
  replaying them costs more with each package as well as each migration.
1141
1187
 
1142
1188
  ```js
1143
- // good: the application's test support restores the snapshot its digest names
1144
- before(async () => restoreTestDatabase(knex, await testDatabaseSnapshot()));
1189
+ // good: package.json "test": "jskit test --databases"; each file gets a restored copy of TEST_DB_NAME
1190
+ const { databases } = await prepareTestDatabases({ knexConfig, environment: process.env, count: workers, seed });
1145
1191
 
1146
1192
  // bad: every file replays every migration
1147
1193
  before(() => knex.migrate.latest());
@@ -1341,8 +1387,11 @@ that must run alone says so on its own first line instead.
1341
1387
  | Rule | Reports | Section |
1342
1388
  | --- | --- | --- |
1343
1389
  | `test/node-test` | a script that runs `node --test` directly instead of `jskit test` | [Test files run in parallel](#test-files-run-in-parallel) |
1344
- | `test/fixed-concurrency` | `--test-concurrency` in a script | [Test files run in parallel](#test-files-run-in-parallel) |
1345
- | `test/playwright-workers` | `--workers` or `-j` passed to `playwright test`, or its config without `workers: testConcurrency()` | [Test files run in parallel](#test-files-run-in-parallel) |
1390
+ | `test/fixed-concurrency` | `--test-concurrency` in a script, or a script that pins `V64_TEST_CONCURRENCY` or `PLAYWRIGHT_WORKERS` | [Test files run in parallel](#test-files-run-in-parallel) |
1391
+ | `test/playwright-workers` | `--workers` or `-j` passed to `playwright test`, its config without `workers: testConcurrency()` or `browserTestConfig()`, or with `workers` set over `browserTestConfig()`'s | [Test files run in parallel](#test-files-run-in-parallel) |
1392
+ | `test/playwright-dev-server` | a Playwright webServer command that runs the development server (`npm run develop`, `npm run dev`, `bin/develop.js`, `vite`, `vite dev`) | [Test files run in parallel](#test-files-run-in-parallel) |
1393
+ | `test/playwright-isolation` | a Playwright webServer every worker shares, without `browserTestConfig()` | [Test files run in parallel](#test-files-run-in-parallel) |
1394
+ | `test/playwright-global-timeout` | a Playwright config without a `globalTimeout` (or `browserTestConfig()`), or with `globalTimeout: 0` | [Test files run in parallel](#test-files-run-in-parallel) |
1346
1395
  | `test/vitest-workers` | `--maxWorkers`, `--minWorkers` or `--no-file-parallelism` passed to Vitest, or its config without `maxWorkers: testConcurrency()` or with `fileParallelism: false` | [Test files run in parallel](#test-files-run-in-parallel) |
1347
1396
 
1348
1397
  The rules see shapes, not intent. Feature files, owned state beyond
@@ -1,68 +0,0 @@
1
- import knex from "knex";
2
-
3
- // Application-owned test setup. Schema migration tests use a separate fresh database.
4
- async function prepareBrowserTestDatabase({ knexConfig, environment = process.env, seed }) {
5
- const started = performance.now();
6
- if (environment.NODE_ENV === "production") throw new Error("Browser tests cannot prepare a production runtime.");
7
- if (typeof seed !== "function") throw new TypeError("An explicit browser fixture seed is required.");
8
- if (knexConfig?.client !== "mysql2" || !knexConfig.connection || typeof knexConfig.connection !== "object") {
9
- throw new Error("Browser preparation requires a resolved MySQL Knex configuration.");
10
- }
11
- const database = String(environment.BROWSER_TEST_DB_NAME || "").trim();
12
- if (!/^[a-zA-Z0-9_]{1,64}$/.test(database)) throw new Error("BROWSER_TEST_DB_NAME must name the exact browser database.");
13
- const protectedNames = [knexConfig.connection.database, environment.DB_NAME, environment.TEST_DB_NAME];
14
- if (environment.DATABASE_URL) {
15
- try { protectedNames.push(decodeURIComponent(new URL(environment.DATABASE_URL).pathname.slice(1))); }
16
- catch { throw new Error("DATABASE_URL must be a valid database URL."); }
17
- }
18
- if (!knexConfig.connection.database || protectedNames.some(name => String(name || "").trim().toLowerCase() === database.toLowerCase())) {
19
- throw new Error("BROWSER_TEST_DB_NAME must differ from the application and disposable TEST_DB_NAME databases.");
20
- }
21
- const adminConnection = { ...knexConfig.connection };
22
- delete adminConnection.database;
23
- const admin = knex({ ...knexConfig, connection: adminConnection, pool: { min: 0, max: 1 } });
24
- let schemaExisted;
25
- try {
26
- const [[row]] = await admin.raw("SELECT COUNT(*) AS count FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = ?", [database]);
27
- schemaExisted = Number(row.count) === 1;
28
- await admin.raw("CREATE DATABASE IF NOT EXISTS ??", [database]);
29
- }
30
- finally { await admin.destroy(); }
31
- console.info(`Browser database ${database}: ${schemaExisted ? "existing schema" : "new schema"}.`);
32
- const db = knex({ ...knexConfig, connection: { ...knexConfig.connection, database } });
33
- try {
34
- const migrationStarted = performance.now();
35
- const [, migrations] = await db.migrate.latest();
36
- const migrationMs = Math.round(performance.now() - migrationStarted);
37
- console.info(`Browser migrations: ${migrations.length} applied in ${migrationMs}ms.`);
38
- const resetStarted = performance.now();
39
- let resetTables = 0;
40
- const connection = await db.client.acquireConnection();
41
- try {
42
- const [[actual]] = await db.raw("SELECT DATABASE() AS databaseName").connection(connection);
43
- if (actual.databaseName !== database) throw new Error("Browser reset requires the exact BROWSER_TEST_DB_NAME connection.");
44
- const [tables] = await db.raw(
45
- "SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_TYPE = 'BASE TABLE'", [database]
46
- ).connection(connection);
47
- const ledger = knexConfig.migrations?.tableName || "knex_migrations";
48
- await db.raw("SET FOREIGN_KEY_CHECKS = 0").connection(connection);
49
- try {
50
- for (const { name } of tables) {
51
- if (name === ledger || name === `${ledger}_lock`) continue;
52
- await db.raw("TRUNCATE TABLE ??", [name]).connection(connection);
53
- resetTables += 1;
54
- }
55
- } finally { await db.raw("SET FOREIGN_KEY_CHECKS = 1").connection(connection); }
56
- } finally { await db.client.releaseConnection(connection); }
57
- const resetMs = Math.round(performance.now() - resetStarted);
58
- console.info(`Browser fixture reset: ${resetTables} tables in ${resetMs}ms.`);
59
- const seedStarted = performance.now();
60
- await db.transaction(seed);
61
- const seedMs = Math.round(performance.now() - seedStarted);
62
- const totalMs = Math.round(performance.now() - started);
63
- console.info(`Browser fixture seed: ${seedMs}ms; database preparation total: ${totalMs}ms.`);
64
- return { database, migrations, schemaExisted, timings: { migrationMs, resetMs, seedMs, totalMs } };
65
- } finally { await db.destroy(); }
66
- }
67
-
68
- export { prepareBrowserTestDatabase };