@ultimat3/cli 22.14.0 → 23.0.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 (174) hide show
  1. package/CLAUDE.md +25 -8
  2. package/README.md +28 -5
  3. package/package.json +29 -28
  4. package/src/admin-catalog-keys.ts +16 -0
  5. package/src/admin-registration.ts +192 -0
  6. package/src/api-registration.ts +36 -11
  7. package/src/app-boundaries.ts +6 -20
  8. package/src/app-load.ts +55 -11
  9. package/src/app-manifest.ts +14 -0
  10. package/src/app-permissions.ts +0 -0
  11. package/src/app-reload-graph.ts +7 -30
  12. package/src/app-transport.ts +219 -0
  13. package/src/boundary-findings.ts +16 -4
  14. package/src/browser-transport.ts +160 -0
  15. package/src/budgets.ts +35 -3
  16. package/src/cmd-build-spec.ts +10 -3
  17. package/src/cmd-build.ts +56 -9
  18. package/src/cmd-db.ts +60 -11
  19. package/src/cmd-dev.ts +2 -2
  20. package/src/cmd-doctor.ts +35 -11
  21. package/src/cmd-generate.ts +118 -17
  22. package/src/cmd-jobs-spec.ts +19 -3
  23. package/src/cmd-jobs.ts +120 -3
  24. package/src/cmd-new.ts +15 -1
  25. package/src/cmd-routes.ts +15 -1
  26. package/src/cmd-secrets-ring.ts +76 -0
  27. package/src/cmd-secrets-spec.ts +10 -2
  28. package/src/cmd-secrets.ts +48 -12
  29. package/src/cmd-tasks.ts +84 -15
  30. package/src/cmd-verify.ts +10 -2
  31. package/src/comment-safe.ts +16 -0
  32. package/src/coverage-floor.ts +334 -0
  33. package/src/coverage-lcov.ts +193 -0
  34. package/src/coverage-source.ts +136 -0
  35. package/src/db-object-drift.ts +30 -0
  36. package/src/db-replay-engine.ts +115 -0
  37. package/src/db-schema-dump.ts +150 -0
  38. package/src/db-schema-refresh.ts +122 -0
  39. package/src/dev-dashboard.ts +2 -2
  40. package/src/dev-route-table.ts +30 -6
  41. package/src/doctor-coverage.ts +39 -0
  42. package/src/doctor-guards.ts +54 -0
  43. package/src/doctor-sealed.ts +132 -0
  44. package/src/document-graph.ts +82 -0
  45. package/src/document-options.ts +64 -0
  46. package/src/error-codes.ts +32 -0
  47. package/src/framework-schema.ts +15 -0
  48. package/src/generate-files.ts +76 -10
  49. package/src/generate-grant-findings.ts +76 -0
  50. package/src/generate-grants.ts +77 -3
  51. package/src/generate-kinds.ts +19 -0
  52. package/src/generate-write.ts +15 -27
  53. package/src/generated-imports.ts +59 -0
  54. package/src/guard-sources.ts +104 -0
  55. package/src/guards.ts +23 -7
  56. package/src/handle-registration.ts +217 -0
  57. package/src/i18n-registration.ts +36 -0
  58. package/src/image-prepare.ts +51 -8
  59. package/src/import-closure.ts +165 -0
  60. package/src/index.ts +11 -2
  61. package/src/island-store.ts +24 -16
  62. package/src/jobs-drain.ts +14 -7
  63. package/src/jobs-json.ts +50 -0
  64. package/src/jobs-report.ts +3 -0
  65. package/src/json-merge.ts +46 -0
  66. package/src/mcp-errors.ts +29 -1
  67. package/src/messages.ts +33 -0
  68. package/src/metrics-endpoint.ts +71 -24
  69. package/src/migration-extensions.ts +29 -0
  70. package/src/module-imports.ts +47 -0
  71. package/src/output.ts +18 -9
  72. package/src/page-speculation.ts +124 -0
  73. package/src/permission-grants.ts +41 -9
  74. package/src/prerender.ts +9 -5
  75. package/src/publish-closure.ts +101 -0
  76. package/src/role-load.ts +143 -0
  77. package/src/role-start.ts +18 -1
  78. package/src/role-wake.ts +45 -0
  79. package/src/runtime-admin.ts +141 -0
  80. package/src/runtime-hooks.ts +5 -2
  81. package/src/runtime-queue.ts +8 -1
  82. package/src/runtime-render.ts +36 -60
  83. package/src/runtime-services.ts +2 -1
  84. package/src/scaffold-typecheck.ts +1 -0
  85. package/src/schema-dump-drift.ts +64 -0
  86. package/src/serve-boot.ts +85 -147
  87. package/src/serve-prebuilt-paths.ts +19 -0
  88. package/src/serve-prebuilt.ts +84 -0
  89. package/src/serve-web.ts +164 -0
  90. package/src/serve.ts +6 -9
  91. package/src/server-barrels.ts +54 -0
  92. package/src/source-edit.ts +49 -0
  93. package/src/static-document.ts +108 -0
  94. package/src/tasks-facts.ts +4 -1
  95. package/src/templates/action.ts +144 -11
  96. package/src/templates/admin-catalog.ts +58 -0
  97. package/src/templates/admin-page.ts +36 -8
  98. package/src/templates/admin.ts +57 -17
  99. package/src/templates/backfill.ts +121 -87
  100. package/src/templates/entity.ts +140 -44
  101. package/src/templates/guard-animated-layout-property.ts +4 -11
  102. package/src/templates/guard-bare-error.ts +9 -13
  103. package/src/templates/guard-focus-visible.ts +4 -11
  104. package/src/templates/guard-image-dimensions.ts +6 -6
  105. package/src/templates/guard-island-without-states.ts +2 -4
  106. package/src/templates/guard-raw-breakpoint.ts +210 -0
  107. package/src/templates/guard-raw-colour.ts +4 -11
  108. package/src/templates/guard-raw-length.ts +375 -0
  109. package/src/templates/guard-raw-motion.ts +213 -0
  110. package/src/templates/guard-raw-shadow.ts +187 -0
  111. package/src/templates/guard-raw-z-index.ts +166 -0
  112. package/src/templates/guard-repo-raw-sql.ts +345 -0
  113. package/src/templates/guard-semantic-interactive.ts +6 -6
  114. package/src/templates/guard-undeclared-custom-property.ts +217 -0
  115. package/src/templates/guard-undefined-style-class.ts +215 -0
  116. package/src/templates/guard-untranslated-string.ts +6 -6
  117. package/src/templates/guard-unzoned-date.ts +9 -13
  118. package/src/templates/index.ts +3 -2
  119. package/src/templates/island.ts +34 -51
  120. package/src/templates/job-body-test.ts +121 -0
  121. package/src/templates/job.ts +23 -8
  122. package/src/templates/policy.ts +16 -9
  123. package/src/templates/query.ts +242 -26
  124. package/src/templates/resource-create.ts +62 -18
  125. package/src/templates/resource-form-island.ts +58 -80
  126. package/src/templates/resource-page.ts +269 -0
  127. package/src/templates/resource-service.ts +96 -0
  128. package/src/templates/resource.ts +86 -61
  129. package/src/templates/route.ts +38 -29
  130. package/src/templates/scaffold-api.ts +55 -1
  131. package/src/templates/scaffold-app.ts +171 -51
  132. package/src/templates/scaffold-auth.ts +83 -19
  133. package/src/templates/scaffold-browser-client.ts +90 -0
  134. package/src/templates/scaffold-container.ts +16 -0
  135. package/src/templates/scaffold-dashboard-bare.ts +64 -2
  136. package/src/templates/scaffold-dashboard-example.ts +124 -31
  137. package/src/templates/scaffold-dashboard-shared.ts +1 -20
  138. package/src/templates/scaffold-dashboard.ts +1 -2
  139. package/src/templates/scaffold-db-client.ts +142 -0
  140. package/src/templates/scaffold-db-package.ts +90 -8
  141. package/src/templates/scaffold-docs.ts +50 -5
  142. package/src/templates/scaffold-guards.ts +54 -11
  143. package/src/templates/scaffold-http.ts +9 -7
  144. package/src/templates/scaffold-repo.ts +52 -2
  145. package/src/templates/scaffold-roles.ts +56 -9
  146. package/src/templates/scaffold-shell.ts +36 -70
  147. package/src/templates/scaffold-site.ts +45 -19
  148. package/src/templates/scaffold-ui-package.ts +18 -0
  149. package/src/templates/slice-foundation.ts +46 -8
  150. package/src/test-counts.ts +18 -2
  151. package/src/transport-calls.ts +105 -0
  152. package/src/unscanned-admin.ts +54 -0
  153. package/src/verify-checks.ts +26 -7
  154. package/src/verify-coverage-run.ts +190 -0
  155. package/src/verify-deadline.ts +256 -0
  156. package/src/verify-errors.ts +15 -6
  157. package/src/verify-floor.ts +36 -9
  158. package/src/verify-merge.ts +117 -24
  159. package/src/verify-progress.ts +25 -0
  160. package/src/verify-role-load-probe.ts +54 -0
  161. package/src/verify-role-load.ts +148 -0
  162. package/src/verify-run.ts +93 -35
  163. package/src/verify-shard.ts +20 -9
  164. package/src/verify-step.ts +42 -1
  165. package/src/verify-tests.ts +78 -6
  166. package/src/workspace-checks.ts +50 -8
  167. package/src/workspace-dep-edit.ts +127 -0
  168. package/src/cmd-dev-fixture.ts +0 -204
  169. package/src/dev-live-fixture.ts +0 -122
  170. package/src/policy-fixture.ts +0 -78
  171. package/src/realtime-browser-probe-fixture.ts +0 -9
  172. package/src/role-start-fixture.ts +0 -78
  173. package/src/scaffold-fixture.ts +0 -160
  174. package/src/templates/island-fixture.ts +0 -76
@@ -4,61 +4,23 @@
4
4
 
5
5
  import { actionFiles } from './action';
6
6
  import { adminFiles } from './admin';
7
+ import { adminCatalogEntries } from './admin-catalog';
7
8
  import { catalogJson } from './catalog-json';
8
9
  import type { FeatureTarget } from './entity';
9
10
  import { entityFiles } from './entity';
11
+ import { sortedImports } from './imports';
10
12
  import { jobFiles } from './job';
11
13
  import { catalogPath, resolveLocales } from './locales';
12
14
  import type { GeneratedFile, NameSet } from './naming';
13
- import { names, pascal } from './naming';
15
+ import { names } from './naming';
14
16
  import { policyFiles } from './policy';
15
17
  import { queryFiles } from './query';
16
18
  import { resourceCreateFiles } from './resource-create';
17
19
  import { formIslandFiles } from './resource-form-island';
20
+ import { resourcePageFiles } from './resource-page';
21
+ import { serviceFiles } from './resource-service';
18
22
  import { routeDir, routeFiles } from './route';
19
-
20
- const serviceSource = (
21
- feature: NameSet,
22
- ): string => `// Business logic for ${feature.pluralKebab}. Knows nothing about HTTP or requests, so a job and an
23
- // action can both call it. Takes values, not a request.
24
-
25
- import type { ${feature.pascal} } from './entity';
26
- import { ${feature.pascal}NotFoundError } from './errors';
27
- import * as repo from './repo';
28
-
29
- /** Derived from the row, never restated: a new column reaches this input without an edit here. */
30
- export type Create${feature.pascal}Input = Omit<${feature.pascal}, 'id' | 'createdAt'>;
31
-
32
- /** The row, aliased once, so every signature below reads at one width whatever the feature is
33
- * called — a generated file the app's own formatter rewrites is a red \`lint\` over code nobody
34
- * typed. */
35
- type Row = ${feature.pascal};
36
-
37
- export async function create(input: Create${feature.pascal}Input): Promise<Row> {
38
- return repo.insert(input);
39
- }
40
-
41
- export async function require${feature.pascal}(id: string): Promise<Row> {
42
- const row = await repo.byId(id);
43
- if (row === undefined) throw new ${feature.pascal}NotFoundError({ id });
44
- return row;
45
- }
46
- `;
47
-
48
- const serviceTest = (
49
- feature: NameSet,
50
- ): string => `// The ${feature.kebab} feature's failure, pinned: a code an agent can match on, a cause naming
51
- // the row, and a fix that is an instruction. A bare Error would satisfy none of the three.
52
- import { expect, unitTest } from '@ultimat3/testing';
53
- import { ${feature.pascal}NotFoundError } from './errors';
54
-
55
- unitTest('${feature.pascal}NotFoundError carries a code, a cause and a fix', () => {
56
- const error = new ${feature.pascal}NotFoundError({ id: 'missing' });
57
- expect(error).toBeUltimateError('X_${feature.kebab.toUpperCase().split('-').join('_')}_NOT_FOUND');
58
- expect(error.cause).toContain('missing');
59
- expect(error.fix.length).toBeGreaterThan(0);
60
- });
61
- `;
23
+ import { PLACEHOLDER_DB_MODULE } from './scaffold-db-client';
62
24
 
63
25
  /**
64
26
  * The generated component reaches strings through the APP's catalog module — the one that calls
@@ -144,18 +106,58 @@ export function ${feature.pascal}Card(props: ${feature.pascal}CardProps) {${tran
144
106
  }
145
107
  `;
146
108
 
147
- // `admin.<feature>.title` is always here, `--admin` or not: `defineAdmin()` resolves that key the
148
- // moment anyone writes the override, and a missing key renders ⟦key⟧ and fails the i18n gate,
149
- // while an unused key is only ever reported (`auditCatalogs` fails on `missing`, never `unused`).
150
- const catalogSource = (feature: NameSet): string =>
109
+ const uiTest = (
110
+ feature: NameSet,
111
+ module: string | undefined,
112
+ ): string => `// The ${feature.kebab} list and card, rendered the way a page renders them. What a typecheck
113
+ // cannot see is which branch a prop picks, and whether the words on screen are the catalog's.
114
+ ${sortedImports([catalogImport(module), "import { expect, renderView, unitTest } from '@ultimat3/testing';"])}
115
+ import type { ${feature.pascal} } from './entity';
116
+ import { ${feature.pascal}List } from './ui';
117
+ import { ${feature.pascal}Card } from './ui/${feature.kebab}-card';
118
+
119
+ const titled = (title: string): ${feature.pascal} => ({
120
+ id: '00000000-0000-4000-8000-000000000001',
121
+ orgId: '00000000-0000-4000-8000-000000000002',
122
+ title,
123
+ price: { minor: 1200, currency: 'USD' },
124
+ createdAt: new Date(0),
125
+ });
126
+
127
+ unitTest('the list renders one item per row, in the order it was given', async () => {
128
+ const rows = [titled('first'), titled('second')];
129
+ const view = await renderView(${feature.pascal}List, { rows });
130
+ expect(view.html.match(/<li\\b/g)).toHaveLength(2);
131
+ expect(view.text).toBe('first second');
132
+ });
133
+
134
+ unitTest('with no rows the list says so, in one item', async () => {${module === undefined ? '' : '\n const t = useT();'}
135
+ const view = await renderView(${feature.pascal}List, { rows: [] });
136
+ expect(view.html.match(/<li\\b/g)).toHaveLength(1);
137
+ expect(view.text).toBe(t('app.${feature.kebab}.empty'));
138
+ });
139
+
140
+ unitTest('the card renders the row it is given, under its own heading', async () => {${module === undefined ? '' : '\n const t = useT();'}
141
+ const view = await renderView(${feature.pascal}Card, { row: titled('on a card') });
142
+ expect(view.html).toContain('<h3>on a card</h3>');
143
+ expect(view.text).toContain(t('app.${feature.kebab}.updated'));
144
+ });
145
+ `;
146
+
147
+ // The admin's keys are always here, `--admin` or not: the entity is an admin screen the moment it
148
+ // joins the handle, and that screen reads `admin.<table>.title` and a label per column
149
+ // (`admin-catalog.ts`). A missing key renders ⟦key⟧ in the list's own header.
150
+ const catalogSource = (feature: NameSet, admin: Readonly<Record<string, string>>): string =>
151
151
  catalogJson({
152
152
  [`app.${feature.kebab}.empty`]: `No ${feature.pluralKebab} yet.`,
153
153
  [`app.${feature.kebab}.updated`]: 'Last updated',
154
154
  [`app.${feature.kebab}.titleLabel`]: 'Title',
155
+ [`app.${feature.kebab}.priceLabel`]: 'Price',
156
+ [`app.${feature.kebab}.new`]: `New ${feature.kebab.replaceAll('-', ' ')}`,
155
157
  [`app.${feature.kebab}.submit`]: 'Save',
156
158
  [`app.${feature.kebab}.saved`]: 'Saved',
157
159
  [`app.${feature.kebab}.retry`]: 'Try again',
158
- [`admin.${feature.kebab}.title`]: pascal(feature.plural),
160
+ ...admin,
159
161
  });
160
162
 
161
163
  export interface ResourceOptions extends FeatureTarget {
@@ -168,15 +170,28 @@ export interface ResourceOptions extends FeatureTarget {
168
170
  * `resolveCatalogModule`. Absent only for an app that ships no such package.
169
171
  */
170
172
  readonly catalogModule?: string;
173
+ /**
174
+ * The app has `apps/web/shared/shell.tsx`, the frame `x new` writes, for the page to sit in —
175
+ * read off the disk by `x g`. Absent is no: a generation imports only what it writes or what the
176
+ * caller said is there (`slice-foundation.test.ts`), and the page is then its own `<main>`.
177
+ */
178
+ readonly shell?: boolean;
171
179
  }
172
180
 
173
181
  export function resourceFiles(rawName: string, target: ResourceOptions): readonly GeneratedFile[] {
174
182
  const feature = names(rawName);
175
- const slice: FeatureTarget = { surfaceDir: target.surfaceDir, feature: feature.kebab };
183
+ // `dbModule` travels with the slice: every generator composed below may write the slice's
184
+ // `repo.ts`, and one that lost the app's name would import the handle from a placeholder.
185
+ const slice: FeatureTarget = {
186
+ surfaceDir: target.surfaceDir,
187
+ feature: feature.kebab,
188
+ ...(target.dbModule === undefined ? {} : { dbModule: target.dbModule }),
189
+ };
176
190
  const dir = `${slice.surfaceDir}/${slice.feature}`;
177
191
  // The page this same call writes, from the function that decides where a route goes — the island
178
192
  // specifier is resolved against it, so re-deriving the path here would be two answers to one
179
193
  // question and only one of them reaches `routeFiles`.
194
+ const dbModule = target.dbModule ?? PLACEHOLDER_DB_MODULE;
180
195
  const pageDir = routeDir('app', feature.pluralKebab);
181
196
  const locales = resolveLocales(target.locales);
182
197
  const entity = entityFiles(rawName, slice);
@@ -187,13 +202,18 @@ export function resourceFiles(rawName: string, target: ResourceOptions): readonl
187
202
  ...entity,
188
203
  ...policyFiles(rawName, slice),
189
204
  // Not `x g action`'s body: a resource's create INSERTS (`resource-create.ts`).
190
- ...resourceCreateFiles(rawName, dir),
205
+ ...resourceCreateFiles(rawName, dir, dbModule),
206
+ // `sliceEntity` is this run's own, so the action's test stores a row and runs its handler
207
+ // against the repo beside it.
191
208
  ...actionFiles(`archive-${feature.kebab}`, { ...slice, sliceEntity }),
192
- ...queryFiles(`${feature.camel}List`, { ...slice, live: true }),
209
+ // And the read's test stores rows through that same repo, then reads them back in order.
210
+ ...queryFiles(`${feature.camel}List`, { ...slice, live: true, sliceEntity }),
193
211
  ...jobFiles(`reindex-${feature.kebab}`, { ...slice, sliceEntity }),
194
- { path: `${dir}/service.ts`, contents: serviceSource(feature) },
195
- { path: `${dir}/service.test.ts`, contents: serviceTest(feature) },
212
+ ...serviceFiles(feature, dir, dbModule),
196
213
  { path: `${dir}/ui.tsx`, contents: uiSource(feature, target.catalogModule) },
214
+ // Beside the two components it renders: a generated module with no test is uncovered source
215
+ // in an app whose gate holds a coverage floor.
216
+ { path: `${dir}/ui.test.ts`, contents: uiTest(feature, target.catalogModule) },
197
217
  { path: `${dir}/ui.module.scss`, contents: uiStyle() },
198
218
  {
199
219
  path: `${dir}/ui/${feature.kebab}-card.tsx`,
@@ -202,20 +222,25 @@ export function resourceFiles(rawName: string, target: ResourceOptions): readonl
202
222
  ...formIslandFiles(feature, dir, pageDir),
203
223
  ...locales.map((locale) => ({
204
224
  path: catalogPath(locale),
205
- contents: catalogSource(feature),
225
+ contents: catalogSource(feature, adminCatalogEntries(rawName, slice)),
206
226
  merge: 'json' as const,
207
227
  })),
208
228
  // Always an app route: a slice ships a live query, a form island and actions, and `generate()`
209
- // refuses `--surface site` for a resource rather than emit them behind a 0kb budget.
210
- //
211
- // `catalogModule` travels with it. It did not, so the page a RESOURCE writes imported `t` from
212
- // `@ultimat3/i18n` while the components beside it imported `useT` from the app's own catalog
213
- // module — one command, two idioms, and the framework-import one is issue #249's: it renders
214
- // strings while depending on nothing that registers them.
229
+ // refuses `--surface site` for a resource. The stylesheet, the offline e2e test and the
230
+ // title/description keys are `x g route`'s; the page and its unit test are the resource's
231
+ // own (`resource-page.ts`) — they read the rows and mount the form, which a route cannot.
215
232
  ...routeFiles(feature.pluralKebab, {
216
233
  surface: 'app',
217
234
  locales,
218
235
  ...(target.catalogModule === undefined ? {} : { catalogModule: target.catalogModule }),
236
+ }).filter((file) => !/\/page\.(tsx|test\.ts)$/.test(file.path)),
237
+ ...resourcePageFiles({
238
+ feature,
239
+ dir,
240
+ pageDir,
241
+ shell: target.shell === true,
242
+ dbModule,
243
+ ...(target.catalogModule === undefined ? {} : { catalogModule: target.catalogModule }),
219
244
  }),
220
245
  ...(target.admin === true ? adminFiles(rawName, slice) : []),
221
246
  ];
@@ -103,13 +103,10 @@ const catalogImport = (module: string | undefined): string =>
103
103
  const translatorBinding = (module: string | undefined): string =>
104
104
  module === undefined ? '' : '\n const t = useT();\n';
105
105
 
106
+ // The component is `Page` in every route file: the name the router looks for first
107
+ // (`pageComponentOf`) and the one both tracked apps use. It was `<Name>Page` — `PostsPage`,
108
+ // `PricingPage` — found only by the router's fallback, and a second spelling of one thing.
106
109
  const pageSource = (surface: Surface, path: string, module: string | undefined): string => {
107
- const name = pascal(
108
- path
109
- .split('/')
110
- .filter((part) => part.length > 0)
111
- .at(-1) ?? 'page',
112
- );
113
110
  return `// Route: /${path} on the ${surface} surface. Config first: render mode, offline
114
111
  // strategy and budget are declarations, not runtime choices.
115
112
 
@@ -127,7 +124,7 @@ export const config = defineRoute({
127
124
  }),
128
125
  });
129
126
 
130
- export function ${name}Page() {${translatorBinding(module)}
127
+ export function Page() {${translatorBinding(module)}
131
128
  return (
132
129
  <main class={styles.page}>
133
130
  <h1>{t('${titleKey(path)}')}</h1>
@@ -170,36 +167,45 @@ const paramsLiteral = (path: string): string => {
170
167
  const routeTest = (
171
168
  surface: Surface,
172
169
  path: string,
173
- ): string => `// /${path}'s declaration, on the \`unit\` step: metadata that renders, and the render mode,
174
- // offline strategy and budget it promises. A route with no title is an SEO regression.
175
- import { metaContextFor, routeDataFor } from '@ultimat3/render';
176
- import { expect, unitTest } from '@ultimat3/testing';
177
- import { config } from './page';
170
+ module: string | undefined,
171
+ ): string => `// /${path}, rendered: the heading a visitor reads, the metadata a search result renders, and the
172
+ // render mode, offline strategy and budget the route promises. A route with no title is an SEO
173
+ // regression; one whose page throws is a 500 the config alone never shows.
174
+ ${sortedImports([
175
+ catalogImport(module),
176
+ "import { expect, renderRoute, unitTest } from '@ultimat3/testing';",
177
+ ])}
178
+ import * as page from './page';
178
179
 
179
- // What a render gives this route: the URL it matched and the params in it. \`routeDataFor\` is the
180
- // one resolver — with no \`load\` the context IS the data, and with one the loader decides — so a
181
- // test asserts on the same object the page component and \`meta\` are handed in production.
182
- const ctx = { params: ${paramsLiteral(path)}, url: 'https://example.test/${sampleUrl(path)}' };
180
+ // What a request gives this route: the URL it matched and the params in it. \`renderRoute\` resolves
181
+ // the route's data once and hands \`meta\` and the page that one object, as every render mode does.
182
+ const request = {
183
+ url: 'https://example.test/${sampleUrl(path)}',
184
+ params: ${paramsLiteral(path)},
185
+ };
183
186
 
184
- unitTest('/${path} declares metadata', async () => {
185
- expect(config.kind).toBe('route');
186
- // meta() takes the route's data and always resolves — awaiting is the one shape, whether the
187
- // declaration was written sync or async.
188
- const meta = await config.meta(metaContextFor(ctx, await routeDataFor(config, ctx)));
189
- expect(meta.title ?? '').not.toBe('');
190
- expect(meta.description ?? '').not.toBe('');
187
+ unitTest('the page renders its title as its one heading', async () => {${module === undefined ? '' : '\n const t = useT();'}
188
+ const view = await renderRoute(page, request);
189
+ expect(view.html.match(/<h1\\b/g)).toHaveLength(1);
190
+ expect(view.text).toBe(t('${titleKey(path)}'));
191
191
  });
192
192
 
193
- unitTest('/${path} declares its render and offline strategy', () => {
194
- expect(config.render).toBe('${RENDER[surface]}');
195
- expect(config.offline).toBe('${OFFLINE[surface]}');
193
+ unitTest('it declares the metadata a search result renders', async () => {${module === undefined ? '' : '\n const t = useT();'}
194
+ const view = await renderRoute(page, request);
195
+ expect(view.meta.title).toBe(t('${titleKey(path)}'));
196
+ expect(view.meta.description).toBe(t('${titleKey(path).replace('.title', '.description')}'));
196
197
  });
197
198
 
198
199
  // The only budget this route declares, because it is the only one the build can weigh: nothing
199
200
  // in the framework observes a paint, so an lcp budget would be a number pinned here and never
200
201
  // compared against anything.
201
- unitTest('/${path} stays inside its byte budget declaration', () => {
202
- expect(config.budget.js).toBe('${BUDGET[surface].js}');
202
+ unitTest('it declares its render mode, offline strategy and byte budget', async () => {
203
+ const view = await renderRoute(page, request);
204
+ expect(page.config.render).toBe('${RENDER[surface]}');
205
+ expect(page.config.offline).toBe('${OFFLINE[surface]}');
206
+ expect(page.config.budget.js).toBe('${BUDGET[surface].js}');
207
+ // No island yet: what the page ships is what the config says, until the first \`island()\`.
208
+ expect(view.islands).toEqual([]);
203
209
  });
204
210
  `;
205
211
 
@@ -253,7 +259,10 @@ export function routeFiles(rawPath: string, options: RouteOptions): readonly Gen
253
259
  return [
254
260
  { path: `${dir}/page.tsx`, contents: pageSource(options.surface, path, options.catalogModule) },
255
261
  { path: `${dir}/page.module.scss`, contents: styleSource() },
256
- { path: `${dir}/page.test.ts`, contents: routeTest(options.surface, path) },
262
+ {
263
+ path: `${dir}/page.test.ts`,
264
+ contents: routeTest(options.surface, path, options.catalogModule),
265
+ },
257
266
  { path: `${dir}/page.e2e.test.ts`, contents: routeE2eTest(path) },
258
267
  ...locales.map((locale) => ({
259
268
  path: catalogPath(locale),
@@ -4,6 +4,7 @@
4
4
  // because `index.ts` has to know the example slice's own file layout.
5
5
 
6
6
  import type { GeneratedFile } from './naming';
7
+ import { browserClientFiles } from './scaffold-browser-client';
7
8
 
8
9
  const healthAction =
9
10
  (): string => `// api/ holds actions only: no rendering, no components. This one is the readiness probe every
@@ -47,6 +48,50 @@ contractTest('health projects one MCP tool and one OpenAPI operation', () => {
47
48
  });
48
49
  `;
49
50
 
51
+ const healthUnitTest =
52
+ (): string => `// health, called: what a probe gets back. Its projections — the OpenAPI operation and the MCP
53
+ // tool — are the contract suite's, next door.
54
+ import { anonymousActor, resolveRole } from '@ultimat3/core';
55
+ import { expect, unitTest } from '@ultimat3/testing';
56
+ import { health } from './health';
57
+
58
+ const target = health.named('health');
59
+
60
+ unitTest('health answers ok and the role of this process, with no session', async () => {
61
+ // Anonymous on purpose: a load balancer has no session, and \`allow('public')\` is what lets it
62
+ // in. The role is the process's own (\`ROLE\`), which is what tells a web probe from a worker's.
63
+ expect(await target.as(anonymousActor(), {})).toEqual({ ok: true, role: resolveRole() });
64
+ });
65
+ `;
66
+
67
+ const apiIndexTest = (
68
+ example: boolean,
69
+ ): string => `// The registration, read back. \`defineApi\` names every primitive by its EXPORT, so what this
70
+ // pins is that a declaration reached the call at all — an action left out of the list is a route
71
+ // that answers 404 with every test of the action itself still green.
72
+ import { expect, unitTest } from '@ultimat3/testing';
73
+ import { health } from './health';
74
+ import { api } from './index';
75
+
76
+ unitTest('health is registered, as the object its module exports', () => {
77
+ expect(api.actions.health).toBe(health);
78
+ expect(api.actions.health.describe().name).toBe('health');
79
+ });
80
+ ${
81
+ example
82
+ ? `
83
+ unitTest('the post slice is registered: two actions, a live query and a job', () => {
84
+ expect(api.actions.createPost.describe().name).toBe('createPost');
85
+ expect(api.actions.archivePost.describe().name).toBe('archivePost');
86
+ expect(api.queries.postList.isLive).toBe(true);
87
+ // The half an app cannot skip: a job nothing lists keeps \`anonymous-job-<n>\` on the queue row
88
+ // and in every dead-letter trace.
89
+ expect(api.jobs.reindexPost.name).toBe('reindexPost');
90
+ });
91
+ `
92
+ : ''
93
+ }`;
94
+
50
95
  /** The imports and the lists that differ between `x new` and `x new --no-example`. */
51
96
  const exampleSlice = {
52
97
  imports: `import * as archivePost from '../app/post/actions/archive-post';
@@ -87,14 +132,23 @@ ${slice.imports}import * as health from './health';
87
132
  export const api = defineApi({
88
133
  actions: [health${slice.actions}],${slice.extra}
89
134
  });
135
+
136
+ /** What the typed client is shaped from — imported as a TYPE only by \`shared/browser-client.ts\`. */
137
+ export type Api = typeof api;
90
138
  `;
91
139
  };
92
140
 
93
- /** `apps/web/api` for a new app: the readiness action, its contract test, and the registration. */
141
+ /**
142
+ * `apps/web/api` for a new app: the readiness action, its contract test, and the registration —
143
+ * plus the browser's typed clients over it (`scaffold-browser-client.ts`), which read its `Api` type.
144
+ */
94
145
  export function apiFiles(example: boolean): readonly GeneratedFile[] {
95
146
  return [
96
147
  { path: 'apps/web/api/health.ts', contents: healthAction() },
148
+ { path: 'apps/web/api/health.test.ts', contents: healthUnitTest() },
97
149
  { path: 'apps/web/api/health.contract.test.ts', contents: healthTest() },
98
150
  { path: 'apps/web/api/index.ts', contents: apiIndex(example) },
151
+ { path: 'apps/web/api/index.test.ts', contents: apiIndexTest(example) },
152
+ ...browserClientFiles(example),
99
153
  ];
100
154
  }