gemi 0.59.0 → 0.60.0

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 (63) hide show
  1. package/dist/bin/gemi.js +500 -16
  2. package/dist/bin/gemi.js.map +10 -5
  3. package/dist/{chunk-n412aa9s.js → chunk-khf9xda6.js} +1 -1
  4. package/dist/{chunk-gfma8e03.js → chunk-x8beq9c4.js} +1 -1
  5. package/dist/chunk-y3zz410b.js +6 -0
  6. package/dist/{chunk-txhcx69q.js.map → chunk-y3zz410b.js.map} +2 -2
  7. package/dist/config/index.d.ts +2 -0
  8. package/dist/config/index.d.ts.map +1 -1
  9. package/dist/config/index.js +2 -2
  10. package/dist/config/index.js.map +3 -3
  11. package/dist/server/index.js +1 -1
  12. package/package.json +3 -2
  13. package/skills/gemi-react-best-practices/SKILL.md +231 -0
  14. package/skills/gemi-react-best-practices/rules/_sections.md +56 -0
  15. package/skills/gemi-react-best-practices/rules/_template.md +28 -0
  16. package/skills/gemi-react-best-practices/rules/bundle-deep-imports.md +48 -0
  17. package/skills/gemi-react-best-practices/rules/bundle-mount-gate-heavy-panels.md +63 -0
  18. package/skills/gemi-react-best-practices/rules/client-form-vs-mutation-hooks.md +57 -0
  19. package/skills/gemi-react-best-practices/rules/client-loading-error-exports.md +54 -0
  20. package/skills/gemi-react-best-practices/rules/client-no-effect-data-flow.md +68 -0
  21. package/skills/gemi-react-best-practices/rules/client-typed-links.md +51 -0
  22. package/skills/gemi-react-best-practices/rules/controller-authorize-every-tenant-read.md +65 -0
  23. package/skills/gemi-react-best-practices/rules/controller-parse-request-at-the-boundary.md +54 -0
  24. package/skills/gemi-react-best-practices/rules/controller-redirect-facade-throws.md +68 -0
  25. package/skills/gemi-react-best-practices/rules/controller-request-schema.md +61 -0
  26. package/skills/gemi-react-best-practices/rules/controller-throw-framework-errors.md +57 -0
  27. package/skills/gemi-react-best-practices/rules/i18n-define-dictionary-inline.md +58 -0
  28. package/skills/gemi-react-best-practices/rules/orm-analytics-connection.md +53 -0
  29. package/skills/gemi-react-best-practices/rules/orm-include-not-n-plus-one.md +56 -0
  30. package/skills/gemi-react-best-practices/rules/orm-paginate-helper.md +69 -0
  31. package/skills/gemi-react-best-practices/rules/orm-plain-rows-by-default.md +55 -0
  32. package/skills/gemi-react-best-practices/rules/orm-select-narrow.md +58 -0
  33. package/skills/gemi-react-best-practices/rules/orm-transaction-no-io.md +54 -0
  34. package/skills/gemi-react-best-practices/rules/orm-transaction-sequential.md +64 -0
  35. package/skills/gemi-react-best-practices/rules/payload-dont-overprefetch.md +54 -0
  36. package/skills/gemi-react-best-practices/rules/payload-instant-vs-prefetch.md +58 -0
  37. package/skills/gemi-react-best-practices/rules/payload-minimal-view-props.md +51 -0
  38. package/skills/gemi-react-best-practices/rules/payload-parallel-controller-work.md +56 -0
  39. package/skills/gemi-react-best-practices/rules/payload-prefetch-late-queries.md +58 -0
  40. package/skills/gemi-react-best-practices/rules/payload-prefetch-mirrors-usequery.md +52 -0
  41. package/skills/gemi-react-best-practices/rules/query-debounce-search-variant.md +52 -0
  42. package/skills/gemi-react-best-practices/rules/query-keep-previous-data.md +40 -0
  43. package/skills/gemi-react-best-practices/rules/query-lazy-vs-mount-gate.md +54 -0
  44. package/skills/gemi-react-best-practices/rules/query-mutate-over-refetch.md +55 -0
  45. package/skills/gemi-react-best-practices/rules/query-no-hand-rolled-fetch.md +60 -0
  46. package/skills/gemi-react-best-practices/rules/query-revalidate-on-focus.md +44 -0
  47. package/skills/gemi-react-best-practices/rules/query-share-cache-key.md +51 -0
  48. package/skills/gemi-react-best-practices/rules/query-suspense-default.md +52 -0
  49. package/skills/gemi-react-best-practices/rules/routing-cache-policy-constants.md +53 -0
  50. package/skills/gemi-react-best-practices/rules/routing-middleware-dsl.md +60 -0
  51. package/skills/gemi-react-best-practices/rules/routing-resource-routes.md +59 -0
  52. package/skills/gemi-react-best-practices/rules/routing-routers-are-classes.md +55 -0
  53. package/skills/gemi-react-best-practices/rules/service-lazy-not-module-scope.md +63 -0
  54. package/skills/gemi-react-best-practices/rules/service-queue-is-in-memory.md +52 -0
  55. package/skills/gemi-react-best-practices/rules/service-static-token-and-name.md +52 -0
  56. package/skills/gemi-react-best-practices/rules/structure-discovered-vs-registered.md +71 -0
  57. package/skills/gemi-react-best-practices/rules/structure-do-not-reinvent-the-framework.md +58 -0
  58. package/skills/gemi-react-best-practices/rules/testing-assert-behaviour-over-markup.md +54 -0
  59. package/skills/gemi-react-best-practices/rules/testing-match-the-suite.md +57 -0
  60. package/skills/gemi-react-best-practices/rules/testing-page-seeds-real-inputs.md +65 -0
  61. package/dist/chunk-txhcx69q.js +0 -6
  62. /package/dist/{chunk-n412aa9s.js.map → chunk-khf9xda6.js.map} +0 -0
  63. /package/dist/{chunk-gfma8e03.js.map → chunk-x8beq9c4.js.map} +0 -0
@@ -0,0 +1,55 @@
1
+ ---
2
+ title: Routes Are Declared on Router Classes, Not by File Location
3
+ impact: HIGH
4
+ impactDescription: the only place a URL is defined
5
+ tags: routing, structure, views
6
+ ---
7
+
8
+ ## Routes Are Declared on Router Classes, Not by File Location
9
+
10
+ Routing is **class-based**. A `ViewRouter` or `ApiRouter` subclass carries a `routes`
11
+ object mapping a path to a handler, and routers nest by assigning one as a route
12
+ value. Putting a file in `app/views` registers nothing — **the view file name has no
13
+ relation to the URL**, and the mapping in `app/http/routes/view.ts` is the only thing
14
+ that makes a page reachable.
15
+
16
+ **Incorrect (creating the file and expecting a URL):**
17
+
18
+ ```tsx
19
+ // app/views/customer/Billing.tsx — reachable at… nothing.
20
+ export default function Billing() { /* … */ }
21
+ ```
22
+
23
+ **Correct (register it, and bind its server data):**
24
+
25
+ ```ts
26
+ // app/http/routes/view.ts
27
+ class CustomerRouter extends ViewRouter {
28
+ middlewares = ["cache:private,0,no-store", "auth"];
29
+ routes = {
30
+ "/billing": this.view("customer/Billing", [BillingController, "view"]),
31
+ };
32
+ }
33
+ ```
34
+
35
+ The pieces worth knowing:
36
+
37
+ - **`this.view(name, handler?)`** — the handler is an inline callback or a
38
+ `[Controller, "method"]` tuple; its return value becomes the component's props.
39
+ - **`this.layout(name, handler?, routes)`** — nests a layout around child routes.
40
+ A layout handler **does not re-run** while navigating within the same layout unless
41
+ it is marked `.alwaysRun()`.
42
+ - **`:param`** dynamic, **`:param?`** optional, **`(group)/`** groups routes for
43
+ shared middleware or a layout **without adding a URL segment**.
44
+ - **`this.redirect(() => ({ destination }))`** for a static redirect.
45
+
46
+ On the API side: `this.get/post/put/patch/delete(Controller, "method")`,
47
+ `this.file(...)`, `this.stream(...)` (handles 206/416 and `Content-Range` for
48
+ range requests), `this.proxy(...)`, and an object of lowercase method keys to bind
49
+ several verbs to one path.
50
+
51
+ **A view component must be a default export; a controller must be a named export.**
52
+ The router imports each by that convention.
53
+
54
+ Reference: `app/http/routes/view.ts`, `app/http/routes/api.ts`
55
+ <https://nstfkc.github.io/gemi/routing.md>
@@ -0,0 +1,63 @@
1
+ ---
2
+ title: Construct Clients Lazily, Never at Module Scope
3
+ impact: HIGH
4
+ impactDescription: keeps boot fast and command discovery working
5
+ tags: service, boot, module-scope, commands
6
+ ---
7
+
8
+ ## Construct Clients Lazily, Never at Module Scope
9
+
10
+ Work at module scope runs whenever the module is *imported*, which is not the same as
11
+ when it is *used*. Three places in a gemi app punish that:
12
+
13
+ 1. **Command discovery imports every file under `app/commands` just to list them.**
14
+ A `new Stripe(key)` beside the handler throws on an empty key during
15
+ `gemi run` — with no command actually invoked.
16
+ 2. **Service `boot()` runs on every application start**, including per-test and
17
+ per-CLI-command. Validate settings there; open connections lazily.
18
+ 3. **Ports are installed at boot, after every module in the graph has evaluated**, so
19
+ reading one at module scope gets `undefined`.
20
+
21
+ **Incorrect (constructed on import; `boot()` opens a connection):**
22
+
23
+ ```ts
24
+ const stripe = new Stripe(process.env.STRIPE_KEY!); // throws at import time
25
+
26
+ export class SearchIndex extends Service {
27
+ static token = "searchIndex";
28
+ async boot() {
29
+ this.client = await Client.connect(process.env.SEARCH_URL!); // every start
30
+ }
31
+ }
32
+ ```
33
+
34
+ **Correct (validate at boot, connect on first use, construct in the handler):**
35
+
36
+ ```ts
37
+ export class SearchIndex extends Service {
38
+ static token = "searchIndex";
39
+ url = process.env.SEARCH_URL;
40
+ private ready?: Promise<Client>;
41
+
42
+ async boot() {
43
+ if (!this.url) throw new Error("SEARCH_URL is not set");
44
+ }
45
+
46
+ private connect() {
47
+ return (this.ready ??= Client.connect(this.url!));
48
+ }
49
+ }
50
+
51
+ export default defineCommand("sync-prices").handle(async ({ line }) => {
52
+ const stripe = new Stripe(process.env.STRIPE_KEY!); // inside the handler
53
+ });
54
+ ```
55
+
56
+ **`Service.inject()` must not be called at module top level either** — it throws
57
+ because the kernel has not booted. Inject as a constructor default
58
+ (`constructor(private billing = Billing.inject())`), which resolves per request and
59
+ lets a test pass a double without touching the container.
60
+
61
+ **Read a runtime port inside a function or behind a getter**, never at module scope.
62
+
63
+ Reference: <https://nstfkc.github.io/gemi/services.md>
@@ -0,0 +1,52 @@
1
+ ---
2
+ title: The Queue Is In-Process and In-Memory — Do Not Trust It With Durable Work
3
+ impact: HIGH
4
+ impactDescription: enqueued work is lost on restart
5
+ tags: service, jobs, queue, durability
6
+ ---
7
+
8
+ ## The Queue Is In-Process and In-Memory — Do Not Trust It With Durable Work
9
+
10
+ Jobs live in the server process's memory. **Enqueued jobs do not survive a restart**,
11
+ and there is no cross-machine queue — a job dispatched on one instance runs on that
12
+ instance or not at all. Use jobs for best-effort work: warming a cache, sending a
13
+ non-critical email, kicking off media processing that the user can retry.
14
+
15
+ Anything that must not be lost needs a durable record: write the row first, then let
16
+ the job (or a cron sweep) act on it, so a restart leaves work to pick up rather than
17
+ a gap.
18
+
19
+ **Incorrect (the only record of the charge lives in the queue):**
20
+
21
+ ```ts
22
+ async store(req: HttpRequest) {
23
+ const order = await Order.create({ data });
24
+ SettleOrderJob.dispatch({ orderId: order.publicId }); // lost on deploy
25
+ return { order };
26
+ }
27
+ ```
28
+
29
+ **Correct (durable state first; the job is an accelerator):**
30
+
31
+ ```ts
32
+ async store(req: HttpRequest) {
33
+ const order = await Order.create({ data: { ...data, settlementStatus: "pending" } });
34
+ SettleOrderJob.dispatch({ orderId: order.publicId });
35
+ return { order };
36
+ }
37
+ // A cron sweeps `settlementStatus: "pending"` rows, so a lost dispatch self-heals.
38
+ ```
39
+
40
+ **Two deployment-shaped constraints that follow:**
41
+
42
+ - **The `queue` and `schedule` config slices declare their `jobs` lists
43
+ explicitly, and must keep doing so.** Discovery is a *runtime* filesystem walk, and
44
+ the release image ships only `dist/` — there is no `app/jobs` or `app/cron` on disk
45
+ in production. Discovery there warns once and registers nothing, so every job and
46
+ cron would stop running while dev and CI stayed green. Note `jobs: []` is
47
+ *present* and disables everything; omitting the key is what enables discovery.
48
+ - **A command dispatching a Job may exit before it runs.** The queue runs in-process
49
+ and the cron scheduler does not start under `gemi run` — do that work inline in the
50
+ command instead.
51
+
52
+ Reference: <https://nstfkc.github.io/gemi/jobs-and-queues.md>
@@ -0,0 +1,52 @@
1
+ ---
2
+ title: Services and Jobs Need a Static String Identifier
3
+ impact: HIGH
4
+ impactDescription: works in dev, silently breaks in the production build
5
+ tags: service, jobs, minification, production
6
+ ---
7
+
8
+ ## Services and Jobs Need a Static String Identifier
9
+
10
+ A `Service` needs `static token`; a `Job` needs `static name`. Both are string
11
+ literals because **minification renames classes in the production build** — the
12
+ container key and the queue's dispatch name must survive that rename. This is a
13
+ class of bug that cannot reproduce locally: dev is unminified, so everything works
14
+ until it is deployed.
15
+
16
+ **Incorrect (relies on the class name surviving the build):**
17
+
18
+ ```ts
19
+ export class ProcessVideoJob extends Job {
20
+ async run(params: Params) { /* … */ }
21
+ }
22
+ ```
23
+
24
+ **Correct:**
25
+
26
+ ```ts
27
+ export class ProcessVideoJob extends Job {
28
+ static name = "ProcessVideoJob";
29
+ maxAttempts = 3;
30
+
31
+ async run(params: Params) { /* … */ }
32
+ onFail(error: Error, params: Params) { /* … */ }
33
+ onDeadletter(error: Error, params: Params) { /* … */ }
34
+ }
35
+
36
+ export class Billing extends Service {
37
+ static token = "Billing";
38
+ }
39
+ ```
40
+
41
+ - **Tokens must be unique.** Two jobs claiming one name: the framework refuses the
42
+ second with a line on stderr rather than silently dropping it — rename one.
43
+ - **`maxAttempts` defaults to 3.** `run` throwing calls `onFail` and requeues;
44
+ the final failure calls `onDeadletter` and drops the job.
45
+ - **`worker: true`** runs the job in a separate Worker thread, for CPU-bound work.
46
+ - **Dispatch is fire-and-forget** — `Job.dispatch(payload)` returns `void`. Pass only
47
+ serializable data, never a class instance or a function.
48
+
49
+ **Register services explicitly on `Kernel.services`, and note that boot order is
50
+ load-bearing** — a service listed first boots before those after it.
51
+
52
+ <https://nstfkc.github.io/gemi/jobs-and-queues.md>
@@ -0,0 +1,71 @@
1
+ ---
2
+ title: Know Which Directories Are Discovered and Which Need Declaring
3
+ impact: HIGH
4
+ impactDescription: the difference between code that runs and code that silently never does
5
+ tags: structure, discovery, kernel, registration
6
+ ---
7
+
8
+ ## Know Which Directories Are Discovered and Which Need Declaring
9
+
10
+ `app/` has two kinds of directory, and confusing them produces the same symptom
11
+ either way: code that looks correct, imports cleanly, typechecks, and never runs.
12
+
13
+ **Discovered — writing the file *is* the registration:**
14
+
15
+ | Directory | Base class | Read at |
16
+ |---|---|---|
17
+ | `app/cron/` | `CronJob` | boot |
18
+ | `app/jobs/` | `Job` | boot |
19
+ | `app/listeners/` | `Listener` | boot |
20
+ | `app/commands/` | `defineCommand` chains | `gemi run` only |
21
+
22
+ Nothing anywhere names these. Adding a `Job` subclass under `app/jobs/` registers
23
+ it; there is no list to append to, and appending to one you invented does nothing.
24
+
25
+ `app/listeners/` is the one the scaffold does not create — its absence is not an
26
+ error, it means the app has no listeners yet.
27
+
28
+ **Declared — the file is not enough:**
29
+
30
+ - **Models** are declared on the Kernel. A model class under `app/models/` that
31
+ the Kernel's `models` does not reach is not registered, and `gemi check models`
32
+ exists to report exactly that gap for policied models.
33
+ - **Routers** are registered explicitly — `app/http/routes/view.ts` and `api.ts`
34
+ are the roots, and a new router is reached by being mounted from one of them.
35
+ - **Config slices** under `app/config/` are named by the Kernel's `config`.
36
+ - **Service providers** under `app/providers/` are named by the Kernel's
37
+ `providers`.
38
+
39
+ **Incorrect (a job that never runs, because a hand-rolled list is not the mechanism):**
40
+
41
+ ```ts
42
+ // app/jobs/index.ts — invented; nothing reads this
43
+ export const jobs = [SendWelcomeEmail, ReindexProducts];
44
+ ```
45
+
46
+ **Correct (the file is the registration):**
47
+
48
+ ```ts
49
+ // app/jobs/SendWelcomeEmail.ts
50
+ export class SendWelcomeEmail extends Job {
51
+ static token = "SendWelcomeEmail";
52
+ // …
53
+ }
54
+ ```
55
+
56
+ **Incorrect (a model that typechecks and is never registered):**
57
+
58
+ ```ts
59
+ // app/models/Invoice.ts — written, but not reachable from the Kernel's `models`
60
+ export class Invoice extends Model {}
61
+ ```
62
+
63
+ The four discovered directories have a cost worth knowing: **discovery imports
64
+ every file it walks**, because a class does not exist until its module has run. A
65
+ file in one of them that does work at import time does that work at boot. Keep
66
+ them to declarations — see `service-lazy-not-module-scope`.
67
+
68
+ The discovered directories can also be declared explicitly in their config slice
69
+ (`schedule`, `queue`, `events`, `command`), which turns the walk off and uses the
70
+ list verbatim. An empty array is a declaration too, and means "this app has none"
71
+ — not "fall back to walking".
@@ -0,0 +1,58 @@
1
+ ---
2
+ title: Do Not Build Structure the Framework Already Has
3
+ impact: HIGH
4
+ impactDescription: a parallel structure is one the framework's own tooling cannot see
5
+ tags: structure, container, services, conventions
6
+ ---
7
+
8
+ ## Do Not Build Structure the Framework Already Has
9
+
10
+ The reflex when a codebase needs somewhere to put shared code is to add `lib/`,
11
+ `utils/`, or a hand-rolled registry. In a gemi app most of those already exist
12
+ with a name, and the cost of the parallel one is not duplication — it is that the
13
+ framework's own machinery cannot see it.
14
+
15
+ | The reflex | What gemi already has |
16
+ |---|---|
17
+ | A `lib/` of shared classes with I/O | `app/services/`, classes extending `Service`, resolved from the container |
18
+ | A module-scope singleton client | A `Service` with a `static token`, injected where needed |
19
+ | A hand-rolled service locator or DI map | The container; `app/providers/` binds into it |
20
+ | A custom router, or routing by file path | Router classes under `app/http/routes/` |
21
+ | Ad-hoc `if (!req.body.name)` checks | An `HttpRequest` subclass under `app/http/requests/` |
22
+ | A `constants.ts` of env reads | A config slice under `app/config/` |
23
+ | A `scripts/` folder run with `bun x.ts` | `app/commands/`, run by `gemi run` |
24
+ | A barrel re-exporting a package's modules | Deep imports — see `bundle-deep-imports` |
25
+
26
+ **Incorrect (a registry the framework cannot see):**
27
+
28
+ ```ts
29
+ // app/lib/services.ts — invented
30
+ export const billing = new Billing(process.env.STRIPE_KEY!);
31
+ ```
32
+
33
+ Constructed at module scope, so it runs whenever anything imports the file —
34
+ including a discovery walk that imports every file in a directory. Nothing can
35
+ rebind it in a test, and nothing resolves it by token.
36
+
37
+ **Correct:**
38
+
39
+ ```ts
40
+ // app/services/Billing.ts
41
+ export class Billing extends Service {
42
+ static token = "Billing";
43
+ // Constructed lazily by the container, rebindable in a test.
44
+ }
45
+ ```
46
+
47
+ Two things follow from this that are easy to get backwards:
48
+
49
+ - **A view's file path is not its URL.** `app/views/` is organised however you
50
+ like; routing is declared on router classes. Moving a view does not change a
51
+ route, and creating one under a path that "looks like" a URL registers nothing.
52
+ See `routing-routers-are-classes`.
53
+ - **`app/models/generated/` is output.** Editing it is editing a build artifact —
54
+ the change disappears on the next generate.
55
+
56
+ When a rule here and an existing pattern in your app disagree, check the app's
57
+ `CLAUDE.md` and git history before "fixing" it: the app may have a reason, and
58
+ ground rule 2 says the app wins.
@@ -0,0 +1,54 @@
1
+ ---
2
+ title: Mount and Query the DOM — Do Not Scrape Rendered Markup
3
+ impact: MEDIUM-HIGH
4
+ impactDescription: catches real regressions instead of string drift
5
+ tags: testing, testing-library, house-style
6
+ ---
7
+
8
+ ## Mount and Query the DOM — Do Not Scrape Rendered Markup
9
+
10
+ Asserting on `renderToStaticMarkup` / `renderToString` output — substring matches,
11
+ regex scrapes for `href=`, index arithmetic to prove ordering — tests the markup
12
+ rather than the component. It passes when the component is broken and fails when a
13
+ class name changes.
14
+
15
+ **Incorrect:**
16
+
17
+ ```tsx
18
+ const html = renderToStaticMarkup(<ProductList products={products} />);
19
+ expect(html).toContain("Chair");
20
+ expect(html.indexOf("Chair")).toBeLessThan(html.indexOf("Desk"));
21
+ expect(html).toMatch(/href="\/app\/abc\/products\/1"/);
22
+ ```
23
+
24
+ **Correct:**
25
+
26
+ ```tsx
27
+ render(<Page {...}><ProductList /></Page>);
28
+
29
+ expect(screen.getByRole("link", { name: "Chair" })).toHaveAttribute(
30
+ "href", "/app/abc/products/1",
31
+ );
32
+ expect(
33
+ screen.getByText("Chair").compareDocumentPosition(screen.getByText("Desk")),
34
+ ).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
35
+ ```
36
+
37
+ Use `getByRole` / `getByLabelText`, `compareDocumentPosition` for order, and
38
+ `label[for]` for input association. **The one exception is a test whose SUBJECT is
39
+ the server payload** — e.g. guarding that a virtualised grid does not ship an empty
40
+ shell. Say so in the file when you take it.
41
+
42
+ **Assert what goes over the wire, not how a query was configured.** Checking that
43
+ `lazy` or `refreshInterval` was passed tests the arguments; it cannot tell a working
44
+ pager from one that never fetched. A non-lazy query is proved by a request arriving
45
+ on mount, a lazy one by no request arriving until something asks.
46
+
47
+ **Mock HTTP with MSW, not by replacing `globalThis.fetch`.** `setupServer` +
48
+ `server.listen({ onUnhandledRequest: "error" })` makes an undeclared request fail
49
+ instead of silently resolving, and lets a case assert against the real `Request` —
50
+ resolved URL, method, headers, parsed body.
51
+
52
+ **Make a stub load-bearing.** A test that removes `crypto.randomUUID` to reach a
53
+ fallback, then asserts a shape both branches satisfy, passes either way. Assert the
54
+ exact value.
@@ -0,0 +1,57 @@
1
+ ---
2
+ title: Match the Suite You Are In
3
+ impact: MEDIUM
4
+ impactDescription: a test the runner never selects is a test that does not exist
5
+ tags: testing, runner, conventions
6
+ ---
7
+
8
+ ## Match the Suite You Are In
9
+
10
+ Before adding a test, read the neighbours and the runner config. Copy the runner, the
11
+ import source, and the file-naming convention already in use in that directory. A
12
+ test written for the wrong runner does not fail loudly — the runner simply never
13
+ selects the file, and it sits green forever without executing.
14
+
15
+ **This is the whole rule for the common case.** Everything below is the gemi-specific
16
+ part: the two things that trip up a component test in a gemi app regardless of which
17
+ runner you picked.
18
+
19
+ **A component test needs a DOM.** gemi renders on the server, so a suite configured
20
+ as a plain Node project has no `window`. Mounting a view there fails on the first
21
+ DOM call, not with a useful message. Either point that suite at a DOM environment
22
+ (`jsdom`, `happy-dom`) or put component tests in the half of the suite that has one.
23
+
24
+ **A test of the server dictionary readers must drop `window` first.**
25
+ `Dictionary.render` and `Dictionary.reference` throw in a browser-shaped environment,
26
+ so a DOM-providing preload breaks them for the whole run. Reach for the server
27
+ globals helper at module scope — a dictionary is often read while the test module
28
+ evaluates, before any `beforeEach` has run.
29
+
30
+ **Incorrect (a component test in the Node-only half of the suite):**
31
+
32
+ ```ts
33
+ // runs, finds no document, fails on render
34
+ import { render } from "@testing-library/react";
35
+ import { describe, it } from "vitest";
36
+ ```
37
+
38
+ **Correct (the same test where a DOM exists):**
39
+
40
+ ```ts
41
+ import { render } from "@testing-library/react";
42
+ import { describe, it } from "vitest"; // or "bun:test" — whichever this suite uses
43
+
44
+ // vitest.config.ts: environment: "jsdom"
45
+ ```
46
+
47
+ Two notes if your repo runs more than one runner:
48
+
49
+ - **The file name is usually the selection mechanism.** `bun test` has no
50
+ include-pattern config, so a repo running both typically selects the bun half by a
51
+ path fragment (`<name>.bun.test.ts`) and excludes that same fragment from the other
52
+ runner's config. Renaming a file is what moves it between halves.
53
+ - **`vi.hoisted` / `vi.mock` do not port.** They rely on Vitest's AST hoisting, which
54
+ `bun:test` has no equivalent for. A file using only `describe`/`it`/`expect` is an
55
+ import swap; a file using those needs rewriting.
56
+
57
+ See also `testing-page-seeds-real-inputs` for how to mount a gemi view under test.
@@ -0,0 +1,65 @@
1
+ ---
2
+ title: Mount With Page From gemi/testing, Never Mock gemi/client
3
+ impact: HIGH
4
+ impactDescription: tests the component instead of the mock
5
+ tags: testing, gemi-testing, house-style
6
+ ---
7
+
8
+ ## Mount With Page From gemi/testing, Never Mock gemi/client
9
+
10
+ `<Page>` supplies the inputs the framework normally provides — route params, search,
11
+ locale, user, and the query cache — so a view mounts for real. **Mocking
12
+ `gemi/client` is prohibited, and the rule is not scoped to component tests**: it
13
+ binds hook and model tests the same way, and spreading the real module through
14
+ (`...(await import("gemi/client"))`) is still mocking it.
15
+
16
+ **Incorrect (asserts the mock, not the component):**
17
+
18
+ ```tsx
19
+ mock.module("gemi/client", () => ({
20
+ useQuery: () => ({ data: [{ id: 1, name: "Chair" }] }),
21
+ useParams: () => ({ orgId: "abc" }),
22
+ }));
23
+ ```
24
+
25
+ **Correct (real hooks, seeded inputs):**
26
+
27
+ ```tsx
28
+ import { Page } from "gemi/testing";
29
+
30
+ render(
31
+ <Page
32
+ pathname="/app/:orgId/products"
33
+ params={{ orgId: "abc" }}
34
+ searchParams="?tab=recent"
35
+ locale="en-US"
36
+ user={{ id: 1 }}
37
+ queryData={{ "/app/:orgId/products": [{ id: 1, name: "Chair" }] }}
38
+ fallback={<Skeleton />}
39
+ errorFallback={<Failed />}
40
+ onNavigate={onNavigate}
41
+ >
42
+ <ProductsRoute />
43
+ </Page>,
44
+ );
45
+ ```
46
+
47
+ - **`queryData` keys are the path as `useQuery` writes it**, minus `/api`, with
48
+ `:params` resolved against the page's `params`. A seeded query renders on the first
49
+ pass and issues no request.
50
+ - **Navigation is reported, not performed** — `onNavigate` receives
51
+ `(resolvedHref, "push" | "replace")`. To test the destination, mount a second
52
+ `<Page>`.
53
+ - **Seed `fallback` and `errorFallback` and assert those** for a non-lazy query:
54
+ it suspends while in flight and throws on failure, so `loading`/`error` are not
55
+ what that path returns.
56
+
57
+ **If an assertion can only be made by mocking `gemi/client`** — `prefetch` leaves no
58
+ DOM trace, a synchronous `useMutate` throw — **drop the assertion, not the rule**, and
59
+ say in the file why it went.
60
+
61
+ **Do not mock anything else either** unless it is genuinely impossible otherwise. A
62
+ Radix dropdown rendering nothing until it opens is an argument for opening it with
63
+ `userEvent`, not for stubbing it.
64
+
65
+ Reference: <https://nstfkc.github.io/gemi/testing.md>
@@ -1,6 +0,0 @@
1
- // @bun
2
- import{pa as h}from"./chunk-tkdxv6cq.js";import{createElement as x}from"react";function j(i,n){let s=Object.keys(n).sort((u,b)=>b.length-u.length).map((u)=>u.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")),r=new RegExp(s.join("|"),"g");return i.replace(r,(u)=>n[u])}async function J(i,n,s=[]){let r=[...s.map((t)=>`${i}/views/${t}.tsx`),`${i}/views/RootLayout.tsx`],u=new Set;for(let t of r){let e=n.moduleGraph.getModulesByFile(t);if(e)for(let d of e)u.add(d)}let b=[],p=[],c={};for(let t of u)if(t)for(let e of t.importedModules){if(e.file.includes("module.css"))c[e.file]=e.ssrTransformResult.map.sourcesContent.join("");if(e.file.includes(".css"))p.push(e.file)}for(let t of p){let e=await n.transformRequest(t+"?direct"),d=t.includes("module.css"),o="";if(d)o=j(c[t],e.default);b.push({isDev:!0,id:t,content:d?o:e.code})}return b.map((t,e)=>{return x("style",{key:e,type:"text/css","data-vite-dev-id":t.id,dangerouslySetInnerHTML:{__html:t.content}})})}async function L(i=[]){return i.map((n,s)=>{return x("style",{key:s,id:n?.id,type:"text/css",dangerouslySetInnerHTML:{__html:n.content}})})}import{existsSync as f}from"fs";import{basename as v}from"path";import{networkInterfaces as k}from"os";var y={name:"gemi",version:"0.59.0",private:!1,license:"MIT",author:"Enes Tufekci <enes@gemijs.dev>",repository:{type:"git",url:"git+https://github.com/nstfkc/gemi.git",directory:"packages/gemi"},bin:{gemi:"./dist/bin/gemi.js","gemi-orm-generator":"./dist/bin/orm-generator.js"},files:["dist/**/*","ide/typescript-plugin/package.json"],module:!0,exports:{"./http":"./http/index.ts","./client":"./client/index.ts","./testing":"./testing/index.ts","./app":"./app/index.ts","./facades":"./facades/index.ts","./email":"./email/index.ts","./vite":"./dist/vite/index.mjs","./kernel":"./kernel/index.ts","./services":"./services/index.ts","./broadcasting":"./broadcasting/index.ts","./i18n":"./i18n/index.ts","./dictionary":"./i18n/dictionaryRuntime.ts","./server":"./server/index.ts","./config":"./config/index.ts","./container":"./container/index.ts","./foundation":"./foundation/index.ts","./database":"./database/index.ts","./orm":"./orm/index.ts","./support":"./support/index.ts","./console/run":"./console/run.ts","./ide/typescript-plugin":"./ide/typescript-plugin/index.ts","./bun/preload":"./bun/preload.ts","./bun/plugin":"./bun/plugin.ts"},scripts:{lint:"oxlint",typecheck:"tsc --noEmit -p tsconfig.json && tsc --noEmit -p ide/typescript-plugin/tsconfig.json",build:"NODE_ENV=production bun run build:core && bun run build:bin && bun run build:types && bun run build:client && bun run build:plugin && bun run build:ts-plugin && bun run build:client-types && bun run build:augmentation","build:core":"bun ./scripts/build.ts","build:bin":"bun build --outdir=./dist/bin --target=bun --external=vite --external=react-dom --external=react/jsx-runtime --external=bun --external=sharp --external=@azure/storage-blob --sourcemap ./bin/gemi.ts ./bin/orm-generator.ts && bun ./scripts/prepare-bin.ts","build:client":"rm -rf dist/client dist/testing dist/chunks && vite build -c vite.client.config.mts","build:plugin":"vite build -c vite.plugin.config.mts","build:ts-plugin":"bun ./scripts/build-ts-plugin.ts","build:types":"tsc","build:client-types":"tsc -p tsconfig.browser.json",test:"bun --bun vitest","test:types":"vitest run --typecheck.only","test:packaging":"bun --bun vitest run --config vitest.packaging.config.ts","build:publish":"bun scripts/build-publish.ts",prepublishOnly:"echo 'Do not publish from this directory \u2014 its exports point at TS source. Run: bun run build:publish, then publish from .publish/' && exit 1","build:augmentation":"bun ./scripts/wire-augmentation.ts"},dependencies:{"@aws-sdk/client-s3":"^3.629.0","@aws-sdk/s3-request-presigner":"^3.629.0","ast-types":"^0.14.2",commander:"^12.1.0",history:"^5.3.0","jsx-email":"2.0.0-rc2.1","magic-string":"^0.30.17",open:"^10.1.0","react-error-boundary":"^6.0.0",recast:"^0.23.9",resend:"^6.12.3",satori:"^0.12.2","temporal-polyfill":"^0.2.5","twitter-api-v2":"^1.20.2","urlpattern-polyfill":"^10.0.0",uuid:"^9.0.1"},devDependencies:{"@babel/parser":"^7.25.4","@prisma/generator-helper":"^6.5.0","@repo/typescript-config":"*","@testing-library/react":"^16.3.2","@types/bun":"^1.3.14","@types/eslint":"^8.56.5","@types/node":"^20.11.24","@types/react":"^19.0.2","@types/react-dom":"^19.0.2",eslint:"^8.57.0",jsdom:"^30.0.1",oxlint:"^1.39.0",react:"19.2.3","react-dom":"19.2.3",typescript:"^5.7.3",vitest:"^4.0.0"},peerDependencies:{"@azure/storage-blob":"^12.28.0",react:">=19","react-dom":">=19",sharp:"^0.34.2",vite:"^8.0.0"},peerDependenciesMeta:{"@azure/storage-blob":{optional:!0}}};var z=[" \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2557\u2588\u2588\u2557","\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D \u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2551","\u2588\u2588\u2551 \u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2554\u2588\u2588\u2588\u2588\u2554\u2588\u2588\u2551\u2588\u2588\u2551","\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u255D \u2588\u2588\u2551\u255A\u2588\u2588\u2554\u255D\u2588\u2588\u2551\u2588\u2588\u2551","\u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2551 \u255A\u2550\u255D \u2588\u2588\u2551\u2588\u2588\u2551"," \u255A\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u255D\u255A\u2550\u255D"],O=Boolean(process.stdout.isTTY)&&!process.env.NO_COLOR,g=(i,n)=>O?`\x1B[${i}m${n}\x1B[0m`:n,T=(i)=>g("1",i),l=(i)=>g("2",i),a=(i)=>g("36",i),q=(i)=>g("32",i),E=(i)=>g("35",i);function R(){return y.version??"?"}function _(i){for(let n of Object.values(k()))for(let s of n??[])if((s.family==="IPv4"||s.family===4)&&!s.internal)return`http://${s.address}:${i}`;return null}function G(i){return h(i).filter((n)=>f(n)).map((n)=>v(n))}function A(i){let{port:n,rootDir:s}=i,r=`http://localhost:${n}`,u=_(n),b=G(s),p=(t,e)=>` ${q("\u279C")} ${T(t.padEnd(9))}${e}`,c=["",...z.map((t)=>E(t)),"",` ${l("gemi")} ${a(`v${R()}`)}`,"",p("Local:",a(r)),p("Network:",u?a(u):l("unavailable")),p("Env:",b.length?b.join(", "):l("none")),""];console.log(c.join(`
3
- `))}
4
- export{J as Z,L as _,A as $};
5
-
6
- //# debugId=729A5B226DC8C37F64756E2164756E21