@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.
- package/CLAUDE.md +25 -8
- package/README.md +28 -5
- package/package.json +29 -28
- package/src/admin-catalog-keys.ts +16 -0
- package/src/admin-registration.ts +192 -0
- package/src/api-registration.ts +36 -11
- package/src/app-boundaries.ts +6 -20
- package/src/app-load.ts +55 -11
- package/src/app-manifest.ts +14 -0
- package/src/app-permissions.ts +0 -0
- package/src/app-reload-graph.ts +7 -30
- package/src/app-transport.ts +219 -0
- package/src/boundary-findings.ts +16 -4
- package/src/browser-transport.ts +160 -0
- package/src/budgets.ts +35 -3
- package/src/cmd-build-spec.ts +10 -3
- package/src/cmd-build.ts +56 -9
- package/src/cmd-db.ts +60 -11
- package/src/cmd-dev.ts +2 -2
- package/src/cmd-doctor.ts +35 -11
- package/src/cmd-generate.ts +118 -17
- package/src/cmd-jobs-spec.ts +19 -3
- package/src/cmd-jobs.ts +120 -3
- package/src/cmd-new.ts +15 -1
- package/src/cmd-routes.ts +15 -1
- package/src/cmd-secrets-ring.ts +76 -0
- package/src/cmd-secrets-spec.ts +10 -2
- package/src/cmd-secrets.ts +48 -12
- package/src/cmd-tasks.ts +84 -15
- package/src/cmd-verify.ts +10 -2
- package/src/comment-safe.ts +16 -0
- package/src/coverage-floor.ts +334 -0
- package/src/coverage-lcov.ts +193 -0
- package/src/coverage-source.ts +136 -0
- package/src/db-object-drift.ts +30 -0
- package/src/db-replay-engine.ts +115 -0
- package/src/db-schema-dump.ts +150 -0
- package/src/db-schema-refresh.ts +122 -0
- package/src/dev-dashboard.ts +2 -2
- package/src/dev-route-table.ts +30 -6
- package/src/doctor-coverage.ts +39 -0
- package/src/doctor-guards.ts +54 -0
- package/src/doctor-sealed.ts +132 -0
- package/src/document-graph.ts +82 -0
- package/src/document-options.ts +64 -0
- package/src/error-codes.ts +32 -0
- package/src/framework-schema.ts +15 -0
- package/src/generate-files.ts +76 -10
- package/src/generate-grant-findings.ts +76 -0
- package/src/generate-grants.ts +77 -3
- package/src/generate-kinds.ts +19 -0
- package/src/generate-write.ts +15 -27
- package/src/generated-imports.ts +59 -0
- package/src/guard-sources.ts +104 -0
- package/src/guards.ts +23 -7
- package/src/handle-registration.ts +217 -0
- package/src/i18n-registration.ts +36 -0
- package/src/image-prepare.ts +51 -8
- package/src/import-closure.ts +165 -0
- package/src/index.ts +11 -2
- package/src/island-store.ts +24 -16
- package/src/jobs-drain.ts +14 -7
- package/src/jobs-json.ts +50 -0
- package/src/jobs-report.ts +3 -0
- package/src/json-merge.ts +46 -0
- package/src/mcp-errors.ts +29 -1
- package/src/messages.ts +33 -0
- package/src/metrics-endpoint.ts +71 -24
- package/src/migration-extensions.ts +29 -0
- package/src/module-imports.ts +47 -0
- package/src/output.ts +18 -9
- package/src/page-speculation.ts +124 -0
- package/src/permission-grants.ts +41 -9
- package/src/prerender.ts +9 -5
- package/src/publish-closure.ts +101 -0
- package/src/role-load.ts +143 -0
- package/src/role-start.ts +18 -1
- package/src/role-wake.ts +45 -0
- package/src/runtime-admin.ts +141 -0
- package/src/runtime-hooks.ts +5 -2
- package/src/runtime-queue.ts +8 -1
- package/src/runtime-render.ts +36 -60
- package/src/runtime-services.ts +2 -1
- package/src/scaffold-typecheck.ts +1 -0
- package/src/schema-dump-drift.ts +64 -0
- package/src/serve-boot.ts +85 -147
- package/src/serve-prebuilt-paths.ts +19 -0
- package/src/serve-prebuilt.ts +84 -0
- package/src/serve-web.ts +164 -0
- package/src/serve.ts +6 -9
- package/src/server-barrels.ts +54 -0
- package/src/source-edit.ts +49 -0
- package/src/static-document.ts +108 -0
- package/src/tasks-facts.ts +4 -1
- package/src/templates/action.ts +144 -11
- package/src/templates/admin-catalog.ts +58 -0
- package/src/templates/admin-page.ts +36 -8
- package/src/templates/admin.ts +57 -17
- package/src/templates/backfill.ts +121 -87
- package/src/templates/entity.ts +140 -44
- package/src/templates/guard-animated-layout-property.ts +4 -11
- package/src/templates/guard-bare-error.ts +9 -13
- package/src/templates/guard-focus-visible.ts +4 -11
- package/src/templates/guard-image-dimensions.ts +6 -6
- package/src/templates/guard-island-without-states.ts +2 -4
- package/src/templates/guard-raw-breakpoint.ts +210 -0
- package/src/templates/guard-raw-colour.ts +4 -11
- package/src/templates/guard-raw-length.ts +375 -0
- package/src/templates/guard-raw-motion.ts +213 -0
- package/src/templates/guard-raw-shadow.ts +187 -0
- package/src/templates/guard-raw-z-index.ts +166 -0
- package/src/templates/guard-repo-raw-sql.ts +345 -0
- package/src/templates/guard-semantic-interactive.ts +6 -6
- package/src/templates/guard-undeclared-custom-property.ts +217 -0
- package/src/templates/guard-undefined-style-class.ts +215 -0
- package/src/templates/guard-untranslated-string.ts +6 -6
- package/src/templates/guard-unzoned-date.ts +9 -13
- package/src/templates/index.ts +3 -2
- package/src/templates/island.ts +34 -51
- package/src/templates/job-body-test.ts +121 -0
- package/src/templates/job.ts +23 -8
- package/src/templates/policy.ts +16 -9
- package/src/templates/query.ts +242 -26
- package/src/templates/resource-create.ts +62 -18
- package/src/templates/resource-form-island.ts +58 -80
- package/src/templates/resource-page.ts +269 -0
- package/src/templates/resource-service.ts +96 -0
- package/src/templates/resource.ts +86 -61
- package/src/templates/route.ts +38 -29
- package/src/templates/scaffold-api.ts +55 -1
- package/src/templates/scaffold-app.ts +171 -51
- package/src/templates/scaffold-auth.ts +83 -19
- package/src/templates/scaffold-browser-client.ts +90 -0
- package/src/templates/scaffold-container.ts +16 -0
- package/src/templates/scaffold-dashboard-bare.ts +64 -2
- package/src/templates/scaffold-dashboard-example.ts +124 -31
- package/src/templates/scaffold-dashboard-shared.ts +1 -20
- package/src/templates/scaffold-dashboard.ts +1 -2
- package/src/templates/scaffold-db-client.ts +142 -0
- package/src/templates/scaffold-db-package.ts +90 -8
- package/src/templates/scaffold-docs.ts +50 -5
- package/src/templates/scaffold-guards.ts +54 -11
- package/src/templates/scaffold-http.ts +9 -7
- package/src/templates/scaffold-repo.ts +52 -2
- package/src/templates/scaffold-roles.ts +56 -9
- package/src/templates/scaffold-shell.ts +36 -70
- package/src/templates/scaffold-site.ts +45 -19
- package/src/templates/scaffold-ui-package.ts +18 -0
- package/src/templates/slice-foundation.ts +46 -8
- package/src/test-counts.ts +18 -2
- package/src/transport-calls.ts +105 -0
- package/src/unscanned-admin.ts +54 -0
- package/src/verify-checks.ts +26 -7
- package/src/verify-coverage-run.ts +190 -0
- package/src/verify-deadline.ts +256 -0
- package/src/verify-errors.ts +15 -6
- package/src/verify-floor.ts +36 -9
- package/src/verify-merge.ts +117 -24
- package/src/verify-progress.ts +25 -0
- package/src/verify-role-load-probe.ts +54 -0
- package/src/verify-role-load.ts +148 -0
- package/src/verify-run.ts +93 -35
- package/src/verify-shard.ts +20 -9
- package/src/verify-step.ts +42 -1
- package/src/verify-tests.ts +78 -6
- package/src/workspace-checks.ts +50 -8
- package/src/workspace-dep-edit.ts +127 -0
- package/src/cmd-dev-fixture.ts +0 -204
- package/src/dev-live-fixture.ts +0 -122
- package/src/policy-fixture.ts +0 -78
- package/src/realtime-browser-probe-fixture.ts +0 -9
- package/src/role-start-fixture.ts +0 -78
- package/src/scaffold-fixture.ts +0 -160
- 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
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
210
|
-
//
|
|
211
|
-
// `
|
|
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
|
];
|
package/src/templates/route.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
180
|
-
//
|
|
181
|
-
|
|
182
|
-
|
|
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('
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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('
|
|
194
|
-
|
|
195
|
-
expect(
|
|
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('
|
|
202
|
-
|
|
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
|
-
{
|
|
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
|
-
/**
|
|
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
|
}
|