@webjsdev/mcp 0.1.7 → 0.1.9

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 (37) hide show
  1. package/README.md +1 -1
  2. package/package.json +4 -1
  3. package/resources/AGENTS.md +86 -72
  4. package/resources/SKILL.md +226 -0
  5. package/resources/references/auth-and-sessions.md +220 -0
  6. package/resources/references/built-ins.md +200 -0
  7. package/resources/references/client-router-and-streaming.md +204 -0
  8. package/resources/references/components.md +167 -0
  9. package/resources/references/data-and-actions.md +187 -0
  10. package/resources/references/muscle-memory-gotchas.md +170 -0
  11. package/resources/references/optimistic-ui.md +128 -0
  12. package/resources/references/routing-and-pages.md +158 -0
  13. package/resources/references/runtime.md +80 -0
  14. package/resources/references/service-worker.md +78 -0
  15. package/resources/references/styling.md +123 -0
  16. package/resources/references/testing.md +125 -0
  17. package/resources/references/typescript.md +148 -0
  18. package/src/mcp-docs.js +27 -14
  19. package/src/mcp-source.js +1 -1
  20. package/src/mcp.js +9 -47
  21. package/src/routes-report.js +90 -0
  22. package/resources/agent-docs/advanced.md +0 -1334
  23. package/resources/agent-docs/built-ins.md +0 -389
  24. package/resources/agent-docs/components-shadowing.md +0 -87
  25. package/resources/agent-docs/components.md +0 -734
  26. package/resources/agent-docs/configuration.md +0 -284
  27. package/resources/agent-docs/framework-dev.md +0 -73
  28. package/resources/agent-docs/lit-muscle-memory-gotchas.md +0 -470
  29. package/resources/agent-docs/metadata.md +0 -334
  30. package/resources/agent-docs/orm.md +0 -198
  31. package/resources/agent-docs/recipes.md +0 -664
  32. package/resources/agent-docs/runtime.md +0 -65
  33. package/resources/agent-docs/service-worker.md +0 -104
  34. package/resources/agent-docs/styling.md +0 -279
  35. package/resources/agent-docs/testing.md +0 -479
  36. package/resources/agent-docs/types-and-mutations.md +0 -171
  37. package/resources/agent-docs/typescript.md +0 -402
@@ -1,479 +0,0 @@
1
- # Testing in webjs
2
-
3
- This doc covers two audiences:
4
-
5
- 1. **Framework agents** working inside this monorepo (`packages/*`,
6
- the root `test/`, the e2e suite, the dev server).
7
- 2. **App agents** working inside a webjs app scaffolded with
8
- `webjs create`. App-side conventions also live in the
9
- scaffold's `CONVENTIONS.md` (which is the authoritative,
10
- user-customisable copy); this section here is the
11
- framework-level reference.
12
-
13
- Both audiences follow the same shape: **feature folders are
14
- primary, test kind is a subfolder inside the feature only when
15
- that kind is actually present**. The layout mirrors webjs's own
16
- `modules/<feature>/` convention in apps (feature first, kind
17
- second), so the mental model transfers between framework work and
18
- app work.
19
-
20
- ---
21
-
22
- ## Test kinds
23
-
24
- | Kind | Location | What it does | Runner |
25
- |---|---|---|---|
26
- | **unit + integration** (node) | `test/<feature>/<name>.test.{js,ts,mjs}` | Imports modules and asserts. No spawned process, no network. Includes both true unit tests and in-process integration. | `node --test` |
27
- | **browser** | `test/<feature>/browser/<name>.test.js` | Runs in real Chromium, Firefox, AND WebKit via Playwright (#774). Real DOM, events, `adoptedStyleSheets`, `IntersectionObserver`, shadow / light DOM. WebKit is the engine behind the iOS-only repaint bugs (the #610 class), so it runs in CI now, not just by hand on-device. | web-test-runner (`wtr`) |
28
- | **e2e** | `test/<feature>/e2e/<name>.test.{ts,mjs}` | Boots a real process (dev server, CLI binary) and drives it through its public interface (HTTP, browser, stdout). Opt in with `WEBJS_E2E=1`. | `node --test` (driver) |
29
- | **smoke** | `test/<feature>/smoke/<name>.test.{js,ts}` | Fast deploy-time sanity check. A subset of e2e in spirit, kept separate so it runs on every deploy. | `node --test` |
30
-
31
- Some packages will only have one or two of these kinds (e.g.
32
- `@webjsdev/core` is a library, no `e2e/` or `smoke/`). Only create
33
- a kind subfolder when there's at least one test of that kind.
34
- Empty `e2e/` folders are anti-patterns.
35
-
36
- ### App browser tests import your REAL components (#806)
37
-
38
- In a scaffolded app, a browser test (`test/<feature>/browser/<name>.test.js`)
39
- can `import` your real `.ts` components, **including ones that call a
40
- `'use server'` action**, and they load in a real browser. `webjs test --browser`
41
- runs each test through the same pipeline as `webjs dev`: the `.ts` is type-
42
- stripped, a `.server.ts` action import is rewritten to its RPC stub, `#` aliases
43
- and `@webjsdev/core` resolve via the injected importmap. So you write:
44
-
45
- ```js
46
- // test/todos/browser/todo-list.test.js
47
- import { ssrFixture } from '@webjsdev/core/testing';
48
- import { html } from '@webjsdev/core';
49
-
50
- suite('todo-list', () => {
51
- test('adds a row', async () => {
52
- await import('../../../components/todo-list.ts'); // imports create-todo.server.ts
53
- const el = await ssrFixture(html`<todo-list></todo-list>`);
54
- el.querySelector('button')?.click(); // fires the action RPC
55
- // assert on the DOM...
56
- });
57
- });
58
- ```
59
-
60
- The wiring lives in the scaffolded `web-test-runner.config.js` (you don't set it
61
- up). Keep the browser test file itself `.js`; the components + modules it imports
62
- are `.ts` and are served transformed. This is why a component that talks to the
63
- server can be tested in a real browser, not just in a node test.
64
-
65
- ---
66
-
67
- ## Framework layout (this repo)
68
-
69
- ```
70
- packages/
71
- core/test/
72
- signals/ signal primitive, computed, watcher
73
- signal.test.js
74
- signal-spec-conformance.test.js
75
- signal-ssr.test.js
76
- browser/ SignalWatcher + DOM
77
- signal-component.test.js
78
- signal-hydration.test.js
79
- watch-directive.test.js
80
- rendering/
81
- render-server.test.js
82
- render-client.test.js
83
- browser/
84
- render-client.test.js
85
- slots/
86
- browser/
87
- slot.test.js
88
- slot-projection-cycle.test.js
89
- directives/
90
- directives.test.js
91
- browser/
92
- directives-cache.test.js
93
- directives-ref.test.js
94
- …
95
- lifecycle/, context/, suspense/, task/, …
96
- server/test/
97
- routing/ api/ actions/ auth/
98
- cache/ check/ csrf/ session/
99
- …
100
- cli/test/ scaffold validation only
101
- ui/test/ schema, resolver, project-detect, components
102
- components/browser/ real-browser tests for the kit
103
- intellisense/test/plugin/ tsserver plugin tests (.test.mjs)
104
- test/ cross-package only
105
- ssr/ SSR pipeline (core + server)
106
- actions/ server actions (core + server)
107
- serialization/ json-negotiation (core + server)
108
- scaffolds/ full app boots from CLI scaffolds
109
- blog/ examples/blog smoke + browser e2e
110
- smoke/blog-smoke.test.js
111
- browser/blog.test.js
112
- e2e/ puppeteer-driven full-app journeys
113
- docs/ docs site validation
114
- types/ type-check fixtures
115
- ```
116
-
117
- Rule of thumb for **package vs root**: if the test imports from
118
- `@webjsdev/server` (or any other package) at all, it's
119
- cross-package and stays at root. If it only imports from one
120
- package, it goes in that package's `test/`.
121
-
122
- ### Drivers
123
-
124
- - `npm test` → `scripts/run-node-tests.js`: enumerates every
125
- `*.test.{js,mjs}` under `packages/*/test/` and `test/`,
126
- excluding anything under a `browser/` or `e2e/` segment. Those
127
- run via their own scripts.
128
- - `npm run test:browser` → `wtr`: globs
129
- `packages/*/test/**/browser/**/*.test.js` and
130
- `test/**/browser/**/*.test.js`. The blog browser e2e is
131
- excluded (it needs the blog dev server up first). Runs on Chromium,
132
- Firefox, and WebKit concurrently; narrow to one engine for a fast local
133
- loop with `WEBJS_BROWSERS=chromium npx wtr` (comma-separate for a subset).
134
- - `npm run test:e2e` → `WEBJS_E2E=1 node --test test/e2e/e2e.test.mjs`. In CI
135
- this runs as two PARALLEL jobs (#774): the Node-served suite (the required
136
- merge-gate check) and a Bun-served run (`WEBJS_E2E_RUNTIME=bun`) in a
137
- separate `e2e-bun` job, so the cross-runtime e2e no longer serializes behind
138
- the Node run.
139
- - `npm run test:coverage` → `WEBJS_COVERAGE=1 npm test`: the node suite with
140
- Node's built-in coverage reporter (#774). Opt-in, kept off the default
141
- `npm test` so the common path stays fast; excludes test files and the
142
- `scripts/` harness so the numbers reflect shipped source.
143
- - `npm run test:all` runs node + browser (not e2e).
144
-
145
- The root drivers above ONLY discover the framework packages and the root
146
- cross-package suite (`packages/*/test/` + `test/`). They do NOT walk the in-repo
147
- apps' own test dirs (`website/test/`, `examples/blog/test/`), so those run from
148
- each app's OWN `webjs test` script, not the root runner.
149
-
150
- ### The Bun test matrix (#509)
151
-
152
- webjs runs on Node 24+ or Bun (#508). The **Node suite (`npm test`) is the
153
- source of truth**; a separate, additive **Bun matrix** re-runs the
154
- runtime-sensitive suite under Bun to catch the long tail of cross-runtime
155
- incompatibilities (a `node:*` API Bun implements differently, a crypto/stream
156
- edge case, an error-message-format quirk).
157
-
158
- **Bun parity is part of the definition of done, not an afterthought.** A change
159
- to a runtime-sensitive surface (the serializer, the node:http vs `Bun.serve`
160
- listener + request path, SSR / action / CSRF dispatch, streams, `node:crypto`,
161
- the TS stripper, auth / session / cors) is NOT done until you have run the Bun
162
- matrix green AND added or updated a `test/bun/<feature>.mjs` cross-runtime
163
- assertion for the surface. This is enforced at commit time by
164
- `.claude/hooks/require-bun-parity-with-runtime-src.sh`, which BLOCKS a commit
165
- that stages runtime-sensitive `packages/*/src` with no `test/bun/**` test (the
166
- escape hatch `WEBJS_BUN_VERIFIED=1` is only for when an existing `test/bun`
167
- script already covers the surface AND you ran it under Bun).
168
-
169
- - `node scripts/run-bun-tests.js` (needs `bun` on PATH; `BUN=…` overrides) runs
170
- the `node:test` files under `test/`, `packages/core/test/`, and
171
- `packages/server/test/` (excluding `browser/`, `e2e/`, the network-bound
172
- `vendor/`) file by file via `bun test <file>`, and CLASSIFIES each result:
173
- **pass**, **skip(node-only)** (a documented file that asserts Node-only
174
- behavior or trips a Bun-test-runner quirk, listed with a reason in the
175
- script's `DENYLIST`, its Bun behavior covered elsewhere), **skip(harness)**
176
- (a Bun `node:test` compat gap, auto-detected by error signature),
177
- **skip(env)** (needs Redis / a DOM), and **genuine fail** (a real Bun
178
- failure, which fails the job). Set `WEBJS_BUN_TESTS=<substr,…>` to scope a
179
- local run.
180
- - CI runs it in the `bun` job alongside `test/bun/smoke.mjs` (#508),
181
- `test/bun/listener.mjs` (#511, the listener-shell parity), and
182
- `test/bun/timeouts.mjs` (#663, the `Bun.serve` `idleTimeout` wiring; the pure
183
- ms-to-seconds mapping is unit-tested under the matrix too).
184
- - Two cross-runtime test scripts also run under BOTH runtimes: `test/bun/smoke.mjs`
185
- (boot + SSR + TS strip + a server-action RPC) and `test/bun/listener.mjs`
186
- (`startServer` over a real socket: SSR + route + SSE + WebSocket). Plain assert
187
- scripts (not `node:test`) so the same file runs identically on each runtime.
188
-
189
- **Dev hot-reload of server modules (cross-runtime, #514).** webjs's dev server
190
- re-imports a `route.ts` / `.server.ts` / page module per request with a
191
- `?t=<timestamp>` query cache-bust to pick up edits. Node honors that query and
192
- re-imports, so under `node --watch` an edit is picked up live. Bun's ESM loader
193
- IGNORES the query string and exposes no module-eviction API, so that mechanism
194
- alone leaves a server-side edit stale on Bun. The CLI closes the gap by
195
- re-execing `webjs dev` under `bun --hot` on Bun (vs `node --watch` on Node),
196
- whose file-watching cache invalidation makes the next re-import fresh with no
197
- restart (`Bun.serve` is reused across hot reloads, so the listener is not
198
- duplicated). The cross-runtime test `test/bun/dev-hot-reload.mjs` proves the
199
- edit-picked-up behaviour on BOTH runtimes (and runs as a dedicated CI step on
200
- Bun). The `api` dev-cache-bust UNIT test asserts the bare server-level `?t=`
201
- mechanism directly (no supervisor), which Bun ignores by design, so it stays on
202
- the Bun-matrix denylist; the user-facing hot reload it underpins is covered on
203
- Bun by the dev-hot-reload script instead. A `--no-hot` flag opts out of the
204
- supervisor on either runtime (run the server in-process, edits need a manual
205
- restart). Component / page / layout SOURCE edits already hot-reload on both
206
- runtimes (the served `.ts`/`.js` is read from disk per request, not imported).
207
-
208
- ### In-repo app tests in CI (#342)
209
-
210
- Each in-repo app (`website`, `examples/blog`) carries its own test suite under
211
- its `test/` dir and runs it through its own `webjs test` script (the website's
212
- `test` runs node + browser; the blog's `test` is node-only). The root runners do
213
- not discover these, so a dedicated `.github/workflows/ci.yml` job, **In-repo app
214
- tests (website + blog)**, runs `npm test --workspace=@webjsdev/website` and
215
- `npm test --workspace=@webjsdev/example-blog` (with Playwright installed for the
216
- website browser tests and the SQLite DB prepared for the blog, the same setup
217
- the `unit` + `e2e` jobs use). It is a required status check, so a regression in
218
- an app's tests gates the merge. The app test dirs are not walked by the root
219
- runner, so the framework-package tests never double-run. `docs` and the
220
- ui-website ship no test suite yet, so they are not in the job.
221
-
222
- ### Adding a new test
223
-
224
- 1. Find the feature folder that matches what you're testing
225
- (`signals`, `routing`, `cache`, `auth`, etc.).
226
- 2. If none exists, create one under the right scope (package vs
227
- root) with the feature's natural name.
228
- 3. Drop the test in directly when it's a node test; nest it
229
- inside `browser/` / `e2e/` / `smoke/` when it's that kind.
230
- 4. Use `.test.js` for ESM packages, `.test.mjs` when the
231
- surrounding package is CJS (e.g. `@webjsdev/intellisense`).
232
-
233
- ---
234
-
235
- ## Component test helpers (`@webjsdev/core/testing`)
236
-
237
- `import { fixture, ssrFixture, waitForUpdate, assertNoA11yViolations, click, shadowQuery, shadowQueryAll } from '@webjsdev/core/testing'`. The mount + hydrate + a11y helpers run in the WTR browser session (real DOM, on each of Chromium / Firefox / WebKit), thin wrappers over the browser already running.
238
-
239
- ### `fixture()` vs `ssrFixture()`
240
-
241
- Both server-render an `html\`…\`` template (via `renderToString`, with DSD) and set the markup into a container so the browser upgrades the custom element. The difference is how they wait:
242
-
243
- - **`fixture(template)`** waits two macrotasks. Use it for a quick mount where the SSR-then-hydrate distinction does not matter.
244
- - **`ssrFixture(template)`** awaits the element's NATIVE `updateComplete` promise (the real render-cycle resolution), not a timer, so the post-hydration DOM is observable deterministically. It is the documented SSR + hydrate entry. Its contract: the SSR'd markup and the post-hydration DOM agree, so a hydration mismatch (server renders one thing, client another) is observable by comparing the SSR'd inner HTML against `el.innerHTML` / `el.shadowRoot.innerHTML` after it resolves. The component class must already be registered (the test imports its module, same as `fixture()`).
245
-
246
- `waitForUpdate(el)` now also awaits the native `updateComplete` when present (falling back to a macrotask flush for a plain element), so a re-render after a property assignment or signal `set()` settles deterministically.
247
-
248
- ```js
249
- import { html } from '@webjsdev/core';
250
- import { ssrFixture, waitForUpdate } from '@webjsdev/core/testing';
251
-
252
- const el = await ssrFixture(html`<my-counter count="5"></my-counter>`);
253
- assert.ok(el.innerHTML.includes('5')); // post-hydration DOM
254
-
255
- el.count = 10;
256
- await waitForUpdate(el); // awaits the real cycle
257
- assert.ok(el.innerHTML.includes('10'));
258
- ```
259
-
260
- **Hydration-mismatch pattern.** To assert SSR and the hydrated DOM agree, normalise the SSR string (strip the `<!--webjs-hydrate-->` marker, `data-webjs-prop-*` attributes, part comments) and compare against the live `el.innerHTML`. The counterfactual is a component whose `render()` is non-deterministic across the SSR call and the hydration render; `ssrFixture` returns the live hydrated element, so the divergence is detectable. The worked tests live in `packages/core/test/testing/browser/ssr-fixture.test.js`, alongside the broader SSR-vs-client parity corpus in `packages/core/test/rendering/browser/ssr-client-parity.test.js`.
261
-
262
- **Testing async render + streaming + error isolation (#469 / #471 / #473).** Split by layer:
263
- - **SSR async render + error isolation** (unit, node, `renderToString`): assert an `async render()` bakes the resolved DATA into the HTML with no fallback markup, and that a throwing component renders a component-scoped error state while siblings render (worked in `packages/core/test/suspense/async-render-ssr.test.js`).
264
- - **`<webjs-suspense>` streaming** (unit, node, `renderToStream` + a `suspenseCtx`): collect the stream and assert the fallback flushes first, then the `<template data-webjs-resolve>` boundaries, and that multiple boundaries fetch concurrently via a timing assertion that fails if serial (worked in `packages/core/test/suspense/webjs-suspense-ssr.test.js`).
265
- - **Client async render** (browser, WTR): mount a component and `await el.updateComplete`, then assert stale-while-revalidate keeps prior content during a gated re-fetch, `renderFallback()` shows only on a re-fetch (never first paint), a rejected render commits `renderError()`, and the race guard drops a superseded resolution. Use a gate promise inside `render()` to control timing (worked in `packages/core/test/suspense/browser/async-render-client.test.js`).
266
- - **Progressive soft-nav streaming** (e2e, `WEBJS_E2E=1`): soft-navigate to a streamed page and assert the fallback is live in the DOM at the moment the URL advances (a buffered swap would only advance the URL once the boundary already resolved), then that the boundary streams in. The DOM-free reader helpers are unit-tested in `packages/core/test/routing/progressive-stream.test.js`.
267
-
268
- ### `assertNoA11yViolations(el, opts?)` (opt-in)
269
-
270
- An OPT-IN accessibility assertion that runs the standard axe-core engine against an element's subtree in the WTR Chromium session. Nothing calls it for you, it is never a forced gate.
271
-
272
- axe-core is a TEST-ONLY peer, imported dynamically by the helper, so it is NOT a hard dependency of `@webjsdev/core`. Install it where you run the test (`npm install -D axe-core`; the scaffold and this repo already ship it). If it is missing, the helper throws a clear message: `assertNoA11yViolations needs axe-core. Install it: npm install -D axe-core`.
273
-
274
- On zero violations it resolves; on a violation it throws an Error whose message lists each violation's id, impact, a short help string, and the failing nodes' selectors, so the failure is actionable. `opts` passes through to `axe.run` (e.g. `{ rules: { 'color-contrast': { enabled: false } } }`).
275
-
276
- ```js
277
- import { ssrFixture, assertNoA11yViolations } from '@webjsdev/core/testing';
278
-
279
- const el = await ssrFixture(html`<my-form></my-form>`);
280
- await assertNoA11yViolations(el); // passes a clean subtree
281
-
282
- // a <button> with no accessible name, an <input> with no label, an <img>
283
- // with no alt: each throws a named violation. Worked both-direction tests
284
- // live in packages/core/test/testing/browser/a11y.test.js.
285
- ```
286
-
287
- ---
288
-
289
- ## The handle() test harness (`@webjsdev/server/testing`)
290
-
291
- `createRequestHandler({ appDir }).handle(request)` drives the FULL request
292
- pipeline (middleware, routing, SSR, page actions, server-action RPC, auth +
293
- CSRF) and returns a native `Response`. It is the same entry the framework's own
294
- suite uses, so the most realistic way to test an app is to fire a `Request`
295
- through it and assert on the `Response`, no spawned process and no network.
296
-
297
- `@webjsdev/server/testing` ships THIN builders over that `handle()`. They are
298
- not a test framework: each is a few lines over native `Request` / `Response`,
299
- and they reuse the REAL cookie / header names and the REAL wire serializer (so a
300
- test exercises the production contract, never a parallel fake).
301
-
302
- ```js
303
- import { createRequestHandler } from '@webjsdev/server';
304
- import { testRequest, invokeActionForTest, rawActionRequest, loginAndGetCookies, withSessionCookie }
305
- from '@webjsdev/server/testing';
306
-
307
- const app = await createRequestHandler({ appDir: process.cwd(), dev: true });
308
- ```
309
-
310
- ### testRequest: fire a request, get the Response
311
-
312
- ```js
313
- const res = await testRequest(app.handle, '/about');
314
- assert.equal(res.status, 200);
315
- assert.match(await res.text(), /About/);
316
- ```
317
-
318
- A bare path (`/about`) is prefixed with a dummy origin (the pipeline only reads
319
- `pathname` + `search`); a full URL string or a pre-built `Request` works too.
320
- The optional third arg is a standard `RequestInit` (method, headers, body).
321
-
322
- ### The auth/session helpers
323
-
324
- Server-action CSRF is an Origin / `Sec-Fetch-Site` check, so a test needs no
325
- CSRF setup: `invokeActionForTest` models a same-origin browser POST and passes
326
- the check automatically, and `rawActionRequest(app, file, fn, args, {
327
- crossOrigin: true })` models a cross-site request to assert the 403.
328
- `loginAndGetCookies(handle, { email, password })` drives the
329
- REAL credentials login through `handle()` (the `createAuth` route handler) and
330
- captures the genuine signed session `Set-Cookie`, so a follow-up request can hit
331
- a protected route as the logged-in user:
332
-
333
- ```js
334
- // unauthenticated protected route is gated
335
- const gated = await testRequest(app.handle, '/dashboard');
336
- assert.equal(gated.status, 302); // -> /login
337
-
338
- // real login, then reuse the captured cookie
339
- const { cookies } = await loginAndGetCookies(app.handle, { email, password });
340
- const dash = await testRequest(app.handle, '/dashboard', withSessionCookie({}, cookies));
341
- assert.equal(dash.status, 200);
342
- ```
343
-
344
- The session cookie is the production cookie, captured from a real login, never a
345
- hand-built shape. (The default login path is `/api/auth/signin/credentials`, the
346
- route `createAuth`'s handler routes a credentials login through; override
347
- `opts.loginPath` / `opts.body` for a different wiring.)
348
-
349
- ### invokeActionForTest: round-trip an action through the REAL endpoint
350
-
351
- ```js
352
- // modules/posts/actions/create.server.ts exports createPost
353
- const out = await invokeActionForTest(app, 'modules/posts/actions/create.server.ts', 'createPost', [input]);
354
- ```
355
-
356
- `invokeActionForTest` serializes `args` with the webjs serializer (exactly as
357
- the generated client stub does), POSTs them to the REAL
358
- `/__webjs/action/<hash>/<fn>` endpoint as a same-origin request (so it passes
359
- the Origin / Sec-Fetch-Site CSRF check), and parses the response with the
360
- serializer. The action is addressed by the SHA-256
361
- hash of its `.server.{js,ts}` file path (absolute or appDir-relative) plus the
362
- function name, the same scheme the stub uses (`actionEndpoint(appDir, file, fn)`
363
- returns that path if you need it directly).
364
-
365
- **Prefer this over a direct import of the action.** A direct import calls the
366
- function in-process and bypasses three production concerns the endpoint
367
- enforces:
368
-
369
- - **the wire serializer** (a `Date` / `Map` / `BigInt` arg or return is
370
- genuinely encoded + decoded, not passed by reference),
371
- - **CSRF** (a missing token is a 403),
372
- - **prod error sanitization** (#749: a thrown error surfaces as a GENERIC
373
- message plus a correlation `digest`, never the raw message, stack, or extra
374
- error fields; the thrown error carries `.digest`).
375
-
376
- So `invokeActionForTest` catches a serializer / CSRF / error-sanitization
377
- regression a direct import cannot see. For the negative cases (assert a 403 on
378
- missing CSRF, or inspect a sanitized 500 body), `rawActionRequest(...)` returns
379
- the raw `Response` and never throws on a non-2xx; pass `{ omitCsrf: true }` to
380
- deliberately drop the CSRF pair.
381
-
382
- The saas scaffold's `test/auth/auth.test.ts` is a worked example: it drives the
383
- unauthenticated-redirect gate, then a real signup -> login -> dashboard flow
384
- through `handle()` using these helpers.
385
-
386
- ---
387
-
388
- ## App layout (what users get)
389
-
390
- A scaffolded webjs app has one `test/` directory at its root,
391
- shaped the same way:
392
-
393
- ```
394
- test/
395
- auth/
396
- auth.test.ts # signup / login / currentUser
397
- password.test.ts # scrypt hash + verify
398
- browser/login-form.test.js # only if exercising DOM
399
- posts/
400
- posts.test.ts
401
- browser/post-editor.test.js
402
- hello/
403
- hello.test.ts # the scaffold's starter test
404
- browser/hello.test.js
405
- e2e/hello.test.ts
406
- ```
407
-
408
- App-side runners:
409
-
410
- - `webjs test` → the runtime's test runner over everything not under `browser/` or `e2e/` (#570): `node --test` on Node, `bun test` on Bun (dispatched on `process.versions.bun`, since `bun --test` is invalid).
411
- - `webjs test --browser` → web-test-runner against `test/**/browser/**` (the app's resolved `wtr` bin, spawned with the current runtime, no `npx`).
412
- - `WEBJS_E2E=1 webjs test` adds e2e.
413
-
414
- App AI agents read this convention through the scaffold's
415
- `AGENTS.md` and `CONVENTIONS.md`. The scaffold's
416
- `web-test-runner.config.js` globs `test/**/browser/**/*.test.js`.
417
-
418
- ---
419
-
420
- ## Choosing where a test goes
421
-
422
- A short decision flow:
423
-
424
- 1. **Does it boot more than one webjs package?**
425
- - Yes → root `test/<feature>/`.
426
- - No → `packages/<the-one-package>/test/<feature>/`.
427
- 2. **Does it need a browser?**
428
- - Yes → `…/<feature>/browser/<name>.test.js`.
429
- - No → `…/<feature>/<name>.test.{js,ts,mjs}`.
430
- 3. **Does it spawn a real process (server, CLI subprocess)?**
431
- - Yes → `…/<feature>/e2e/<name>.test.{ts,mjs}` (and gate
432
- behind `WEBJS_E2E=1` if it's slow).
433
- 4. **Is it a fast post-deploy "does the surface still work" check?**
434
- - Yes → `…/<feature>/smoke/<name>.test.{js,ts}`.
435
-
436
- If the answer to "what feature is this?" is "framework
437
- internals" or "misc", pick the user-facing concern instead
438
- (`routing`, `serializer`, `slots`). If you genuinely cannot pick
439
- a feature, the test is probably testing too many things at once.
440
-
441
- ---
442
-
443
- ## What NOT to do
444
-
445
- - **Don't recreate the old `test/{unit,browser,e2e}/` shape.**
446
- Kind is a child of feature, not the other way around.
447
- - **Don't create empty kind folders.** If `e2e/` has no tests
448
- yet, leave it absent.
449
- - **Don't attempt to run full browser-based tests (WTR/Playwright) inside headless VM sandboxes** if they lack compiler transformation plugins (e.g. for TypeScript components or RPC action stubbing) or have missing native browser OS libraries. Instead, extract components' reconciliation or optimistic UI update logic into pure, browser-safe utilities and cover them using server-side Node.js unit tests.
450
- - **Don't put package-only tests under root `test/`.** It hurts
451
- the per-package `npm test --workspace=…` workflow.
452
- - **Don't import from another package's `test/` directory.**
453
- Test code is not a public surface.
454
- - **Don't add `.unit` / `.integration` filename suffixes.** The
455
- folder tells you the kind; the filename should match what it
456
- tests.
457
-
458
- ---
459
-
460
- ## Verifying UI and theming changes
461
-
462
- For anything visual (layout, components, themes), a passing unit test or a
463
- clean-looking code diff is not enough. Render it in a real browser and look.
464
-
465
- - **Test both light AND dark mode.** Light mode passing proves nothing about
466
- dark mode. The scaffold drives dark mode through two signals (a `data-theme`
467
- attribute for the editorial chrome and a `.dark` class for the ui-* kit, see
468
- `agent-docs/styling.md`), and when neither is set both default to a
469
- coincidentally matching light, so a desync only shows once dark is active.
470
- Emulate dark (Playwright `newContext({ colorScheme: 'dark' })` or flip the
471
- theme toggle) and inspect a component's **computed** `background-color` /
472
- `color`, not just the page chrome.
473
- - **Read the screenshot.** Capture `page.screenshot({ fullPage: true })` and
474
- open the PNG; white-on-white or a stray light box is obvious visually and
475
- invisible in the markup.
476
- - **Guard the wiring in a fast test where you can.** A runtime cascade bug
477
- needs a browser, but the mechanism that triggers it (e.g. the theme toggle
478
- setting `.dark`) can be asserted cheaply. See the dark-mode assertions in
479
- `test/scaffolds/scaffold-integration.test.js`.
@@ -1,171 +0,0 @@
1
- # Types and mutations: sharing server-derived shapes with the browser
2
-
3
- An interactive component that ships to the browser almost always needs the
4
- shape of the data it renders (`Todo`, `Post`, the `ActionResult<T>` envelope a
5
- mutation returns). Those shapes are DERIVED from server-only modules (the
6
- Drizzle schema, the action files), so the reflex is to import them straight from
7
- `db/schema.server.ts` or a `*.server.ts` action. That reflex pins a server
8
- module to a browser closure and breaks the build. This page codifies where the
9
- types live so a shipping component consumes them safely.
10
-
11
- The one rule. **A type crossing to the browser is a TYPE, never a runtime
12
- value.** Keep the shape in a browser-safe `modules/<feature>/types.ts` with no
13
- runtime import from a `.server.ts` file or from `db/`. A `import type { ... }`
14
- is erased by the TypeScript stripper before the module reaches the browser, so
15
- it is safe. A plain `import { ... }` (a value import) is NOT erased, so it pins
16
- the server module and trips the `no-server-import-in-browser-module` check.
17
-
18
- ---
19
-
20
- ## Why a value import breaks and a type import does not
21
-
22
- webjs strips TypeScript at runtime with no bundler. The stripper erases every
23
- `import type` and every type-only specifier, so those imports never exist in the
24
- served JavaScript. A value import survives stripping and becomes a real browser
25
- `import`. When the target is a `.server.ts` module, its browser stub throws at
26
- module load (invariant 1), and the `no-server-import-in-browser-module` check
27
- flags it statically for any module the framework determines actually ships.
28
-
29
- ```ts
30
- // SAFE: type-only, erased before it reaches the browser.
31
- import type { Post } from '#db/schema.server.ts';
32
-
33
- // UNSAFE: a runtime value import of the server schema. Survives stripping,
34
- // pins db/schema.server.ts into the browser closure, throws at load.
35
- import { posts } from '#db/schema.server.ts';
36
- ```
37
-
38
- The subtle trap is deriving a type from a value with a value import. `typeof
39
- posts.$inferSelect` needs the `posts` binding, and `import { posts }` is a value
40
- import even though you only use it at the type level. Write `import type` (rc.3
41
- schemas already `export type Post = typeof posts.$inferSelect`, so import the
42
- ready-made type), or hand-write the DTO in `types.ts`.
43
-
44
- ---
45
-
46
- ## The wrong chain: a types module re-exporting a runtime value
47
-
48
- The classic failure is a `types.ts` that looks type-only but carries a runtime
49
- re-export. A browser-shipped page or component imports it, and the whole server
50
- schema is dragged along.
51
-
52
- ```ts
53
- // modules/posts/types.ts WRONG
54
- // A star or value re-export carries the runtime `posts`, `users` table
55
- // bindings, not just their types.
56
- export * from '#db/schema.server.ts'; // re-exports VALUES
57
- export { posts } from '#db/schema.server.ts'; // re-exports a VALUE
58
- ```
59
-
60
- ```ts
61
- // components/post-card.ts (an interactive component, ships to the browser)
62
- import type { Post } from '#modules/posts/types.ts';
63
- // Because types.ts value-re-exports the schema, the browser module graph now
64
- // reaches db/schema.server.ts. no-server-import-in-browser-module fails, and
65
- // at runtime the server stub throws the moment post-card.ts loads.
66
- ```
67
-
68
- The check fires on the component (a shipping browser module), even though the
69
- mistake is in `types.ts`. The re-export made a server value reachable from a
70
- browser closure.
71
-
72
- ---
73
-
74
- ## The right chain: a browser-safe DTO in `types.ts`
75
-
76
- Define the wire shape as a plain interface (or a `type`) in `types.ts` with no
77
- runtime import. This is the DTO the query / action returns and the component
78
- consumes. It has zero server dependency, so it crosses the boundary freely.
79
-
80
- ```ts
81
- // modules/posts/types.ts RIGHT
82
- // A hand-written DTO. No import from db/ or any *.server.ts. Pure types.
83
- export type PostFormatted = {
84
- id: number;
85
- slug: string;
86
- title: string;
87
- body: string;
88
- authorId: number;
89
- authorName: string | null;
90
- createdAt: string; // serialized for the wire
91
- };
92
-
93
- export type CreatePostInput = { title: string; body: string };
94
-
95
- // The mutation envelope every action returns. Browser-safe, no server import.
96
- export type ActionResult<T> =
97
- | { success: true; data?: T; redirect?: string }
98
- | { success: false; error?: string; fieldErrors?: Record<string, string>;
99
- values?: Record<string, string>; status?: number };
100
- ```
101
-
102
- ```ts
103
- // modules/posts/queries/list-posts.server.ts (server: may touch db)
104
- import { db } from '#db/connection.server.ts';
105
- import type { PostFormatted } from '#modules/posts/types.ts';
106
- export async function listPosts(): Promise<PostFormatted[]> {
107
- const rows = await db.query.posts.findMany({ orderBy: { createdAt: 'desc' } });
108
- return rows.map(formatPost); // maps the row to the DTO
109
- }
110
- ```
111
-
112
- ```ts
113
- // components/post-card.ts (browser: consumes the DTO type only)
114
- import type { PostFormatted } from '#modules/posts/types.ts';
115
- ```
116
-
117
- If you want the DTO to track the schema automatically rather than restating it,
118
- use a **type-only** derivation and keep it in `types.ts`.
119
-
120
- ```ts
121
- // modules/posts/types.ts RIGHT (type-only derivation, still erased)
122
- import type { Post } from '#db/schema.server.ts'; // type-only, erased
123
- export type PostFormatted = Omit<Post, 'createdAt'> & { createdAt: string };
124
- ```
125
-
126
- Either form ships nothing server-side to the browser. The `import type` is the
127
- seam. A value import is the leak.
128
-
129
- ---
130
-
131
- ## Carrier choice: register an interactive component in `layout.ts`
132
-
133
- An interactive component reaches the browser because SOME module imports it and
134
- that module ships. A `page.ts` and a `layout.ts` never hydrate, but they DO load
135
- in the browser to register the components they import. When a page's ONLY reason
136
- to ship is registering a component, prefer registering that component in
137
- `app/layout.ts` (or a section layout), not `app/page.ts`.
138
-
139
- The reason is elision economics. A `page.ts` whose sole client job is
140
- registering a component is dropped by the framework, and the browser fetches the
141
- component leaf directly (the import-only page optimization). But when you have a
142
- component shared across a section (a header search box, a theme toggle, a cart
143
- button), the layout is the natural single carrier. Register it once there and
144
- every page under it gets the upgraded element with no per-page import.
145
-
146
- ```ts
147
- // app/layout.ts RIGHT: the section carrier registers shared interactive UI
148
- import '#components/theme-toggle.ts'; // registers <theme-toggle> for every page
149
- import '#components/ui/dialog.ts'; // registers the Tier-2 <ui-dialog> element
150
-
151
- export default function RootLayout({ children }) {
152
- return html`... <theme-toggle></theme-toggle> ${children} ...`;
153
- }
154
- ```
155
-
156
- ```ts
157
- // app/page.ts RIGHT: page stays a pure server function, no client import
158
- export default function Home() {
159
- return html`<h1>Home</h1><theme-toggle></theme-toggle>`; // tag already registered by the layout
160
- }
161
- ```
162
-
163
- Register in `page.ts` only when the component is page-specific (used on that one
164
- route). A shared element registered per page duplicates the import intent across
165
- routes and muddies which module is the carrier. Keep pages pure server functions
166
- where you can, and let the nearest layout carry the shared interactive tags.
167
-
168
- Cross-references. The server-only boundary and the type-only-import exception
169
- are invariant 1 in the root `AGENTS.md`. The Drizzle query surface that produces
170
- these rows is in `agent-docs/orm.md`. The optimistic mutation flow that consumes
171
- `ActionResult<T>` on the client is in `agent-docs/advanced.md`.