@ultimat3/cli 1.1.0 → 2.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 +724 -0
- package/README.md +41 -9
- package/package.json +25 -23
- package/src/api-routes.ts +16 -0
- package/src/app-auth.ts +32 -0
- package/src/app-entities.ts +18 -0
- package/src/app-env.ts +103 -0
- package/src/app-load.ts +20 -3
- package/src/bin.ts +4 -3
- package/src/budgets.ts +114 -9
- package/src/cmd-build.ts +69 -21
- package/src/cmd-db-branch.ts +215 -0
- package/src/cmd-db.ts +332 -155
- package/src/cmd-deploy.ts +59 -6
- package/src/cmd-dev.ts +87 -17
- package/src/cmd-docs.ts +167 -0
- package/src/cmd-doctor.ts +64 -9
- package/src/cmd-env.ts +95 -0
- package/src/cmd-errors.ts +33 -13
- package/src/cmd-fix.ts +5 -1
- package/src/cmd-generate.ts +146 -111
- package/src/cmd-help.ts +16 -5
- package/src/cmd-i18n.ts +2 -0
- package/src/cmd-jobs.ts +47 -33
- package/src/cmd-mcp.ts +11 -2
- package/src/cmd-new.ts +13 -7
- package/src/cmd-planned.ts +55 -10
- package/src/cmd-policy.ts +1 -0
- package/src/cmd-registries.ts +3 -0
- package/src/cmd-secrets.ts +368 -0
- package/src/cmd-tasks.ts +1 -0
- package/src/cmd-test.ts +17 -23
- package/src/cmd-verify.ts +177 -23
- package/src/db-backfill.ts +401 -0
- package/src/db-branch.ts +251 -0
- package/src/db-destructive.ts +29 -0
- package/src/db-finding.ts +28 -0
- package/src/db-generate.ts +112 -0
- package/src/db-snapshot.ts +24 -0
- package/src/dev-assets.ts +86 -20
- package/src/dev-cache.ts +122 -0
- package/src/dev-dashboard.ts +19 -4
- package/src/dev-hooks.ts +27 -2
- package/src/dev-n-plus-one.ts +191 -0
- package/src/dev-queue.ts +105 -19
- package/src/dev-render.ts +158 -26
- package/src/dev-roles-fixture.ts +67 -0
- package/src/dev-roles.ts +186 -78
- package/src/dev-runtime.ts +117 -40
- package/src/dev-services.ts +15 -0
- package/src/dev-storage.ts +245 -0
- package/src/dev-sync.ts +107 -0
- package/src/dev-traces.ts +11 -3
- package/src/dispatch.ts +4 -2
- package/src/document-styles.ts +54 -0
- package/src/drift.ts +37 -9
- package/src/error-catalog.ts +7 -18
- package/src/error-codes.ts +186 -0
- package/src/error-contract.ts +29 -7
- package/src/error-fixes.ts +114 -0
- package/src/errors.ts +205 -140
- package/src/fix-command.ts +268 -0
- package/src/flag-number.ts +56 -0
- package/src/framework-scope.ts +49 -0
- package/src/generate-kinds.ts +97 -0
- package/src/guards.ts +186 -0
- package/src/index.ts +87 -14
- package/src/island-bundle.ts +166 -0
- package/src/island-routes.ts +50 -0
- package/src/jobs-driver.ts +33 -0
- package/src/jobs-json.ts +24 -0
- package/src/jobs-report.ts +17 -4
- package/src/mcp-db-target.ts +52 -27
- package/src/mcp-errors.ts +120 -19
- package/src/mcp-host.ts +44 -25
- package/src/messages.ts +81 -2
- package/src/metrics-endpoint.ts +73 -0
- package/src/migrations.ts +37 -4
- package/src/otlp-export.ts +64 -0
- package/src/output.ts +46 -16
- package/src/parse.ts +41 -3
- package/src/policy-facts.ts +38 -6
- package/src/policy-fixture.ts +14 -7
- package/src/prerender.ts +111 -2
- package/src/registry.ts +21 -3
- package/src/runtime-overrides.ts +66 -0
- package/src/safe-url-label.ts +24 -0
- package/src/scaffold-fixture.ts +10 -0
- package/src/scaffold-typecheck.ts +16 -38
- package/src/serve.ts +202 -18
- package/src/source-files.ts +4 -0
- package/src/statement-loop.ts +74 -0
- package/src/style-csp.ts +18 -0
- package/src/sync-authenticator.ts +59 -0
- package/src/templates/action.ts +15 -30
- package/src/templates/admin-page.ts +103 -0
- package/src/templates/admin.ts +11 -7
- package/src/templates/backfill.ts +212 -0
- package/src/templates/entity.ts +72 -31
- package/src/templates/guard.ts +143 -0
- package/src/templates/index.ts +12 -1
- package/src/templates/island.ts +67 -0
- package/src/templates/job.ts +53 -13
- package/src/templates/naming.ts +17 -1
- package/src/templates/policy.ts +35 -28
- package/src/templates/query.ts +24 -5
- package/src/templates/resource.ts +19 -11
- package/src/templates/route.ts +90 -15
- package/src/templates/scaffold-app.ts +142 -45
- package/src/templates/scaffold-claude-agents.ts +149 -0
- package/src/templates/scaffold-claude-commands.ts +221 -0
- package/src/templates/scaffold-claude.ts +134 -0
- package/src/templates/scaffold-container.ts +46 -2
- package/src/templates/scaffold-db-package.ts +91 -0
- package/src/templates/scaffold-docs.ts +24 -5
- package/src/templates/scaffold-domain-package.ts +90 -0
- package/src/templates/scaffold-env.ts +87 -0
- package/src/templates/scaffold-i18n.ts +4 -1
- package/src/templates/scaffold-mcp-package.ts +49 -0
- package/src/templates/scaffold-package-shape.ts +25 -4
- package/src/templates/scaffold-repo.ts +116 -257
- package/src/templates/scaffold-roles.ts +68 -0
- package/src/templates/scaffold-ui-package.ts +56 -0
- package/src/templates/slice-foundation.ts +88 -0
- package/src/templates/wrap.ts +95 -0
- package/src/test-counts.ts +35 -0
- package/src/test-select.ts +30 -15
- package/src/test-shards.ts +21 -3
- package/src/test-workers.ts +47 -0
- package/src/ts-scan.ts +271 -13
- package/src/tsconfig-references.ts +78 -0
- package/src/verify-floor.ts +133 -0
- package/src/verify-step.ts +19 -0
- package/src/verify-test-run.ts +72 -0
- package/src/verify-tests.ts +160 -71
- package/src/version-loader.ts +20 -3
- package/src/workspace-checks.ts +87 -16
- package/src/write-line.ts +34 -0
package/src/templates/policy.ts
CHANGED
|
@@ -4,16 +4,16 @@
|
|
|
4
4
|
|
|
5
5
|
import type { GeneratedFile, NameSet } from './naming';
|
|
6
6
|
import { names } from './naming';
|
|
7
|
+
import { wrapList } from './wrap';
|
|
7
8
|
|
|
8
9
|
/** Biome would rewrap this itself, so the generator emits the already-formatted form. */
|
|
9
|
-
const permissionSet = (feature: NameSet): string =>
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
};
|
|
10
|
+
const permissionSet = (feature: NameSet): string =>
|
|
11
|
+
wrapList(
|
|
12
|
+
'',
|
|
13
|
+
`export const ${feature.camel}Permissions = definePermissions([`,
|
|
14
|
+
[`'${feature.kebab}:read'`, `'${feature.kebab}:write'`],
|
|
15
|
+
']);',
|
|
16
|
+
);
|
|
17
17
|
|
|
18
18
|
const policySource = (
|
|
19
19
|
feature: NameSet,
|
|
@@ -67,38 +67,45 @@ export const can${feature.pascal}Write = can<${feature.pascal}Scope>(
|
|
|
67
67
|
);
|
|
68
68
|
`;
|
|
69
69
|
|
|
70
|
-
const policyTest = (
|
|
70
|
+
const policyTest = (
|
|
71
|
+
feature: NameSet,
|
|
72
|
+
): string => `// The ${feature.kebab} rules, from the DENIAL side: anonymous, cross-org, and the actor holding
|
|
73
|
+
// only read. A policy whose tests all pass is a policy nobody has tried to get past.
|
|
74
|
+
import { testActor } from '@ultimat3/policy';
|
|
71
75
|
import { expect, unitTest } from '@ultimat3/testing';
|
|
72
76
|
import { can${feature.pascal}Read, can${feature.pascal}Write } from './policy';
|
|
73
77
|
|
|
74
78
|
const org = '00000000-0000-4000-8000-000000000002';
|
|
75
79
|
const otherOrg = '00000000-0000-4000-8000-000000000009';
|
|
76
80
|
|
|
77
|
-
// Direct grants rather than roles:
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
81
|
+
// Direct grants rather than roles: defineRoles() MERGES into one app-global map that outlives this
|
|
82
|
+
// file, so a role installed here would still be granting permissions to every later test in the
|
|
83
|
+
// process — and a second test declaring the same name differently is X_ROLE_REDEFINED rather than
|
|
84
|
+
// an override. The app's roles live in apps/web/shared/roles.ts, once. \`permissions\` is the same
|
|
85
|
+
// check one layer down.
|
|
86
|
+
// The two grants and the org, each named once. Not decoration: every assertion below then carries
|
|
87
|
+
// the feature's own name and still fits the formatter's width, whatever that name is — a generated
|
|
88
|
+
// file the app's \`lint\` step rewrites is a red gate over code nobody typed.
|
|
89
|
+
const read = '${feature.kebab}:read';
|
|
90
|
+
const write = '${feature.kebab}:write';
|
|
91
|
+
const input = { orgId: org };
|
|
92
|
+
|
|
93
|
+
const reader = testActor('reader', { orgId: org, permissions: [read] }).actor;
|
|
94
|
+
const writer = testActor('writer', { orgId: org, permissions: [read, write] }).actor;
|
|
95
|
+
const outsider = testActor('outsider', { orgId: otherOrg, permissions: [read, write] }).actor;
|
|
89
96
|
|
|
90
97
|
unitTest('${feature.camel} read denies anonymous and cross-org actors', async () => {
|
|
91
|
-
await expect(can${feature.pascal}Read).toDenyPolicy({ actor: null, input
|
|
92
|
-
await expect(can${feature.pascal}Read).toDenyPolicy({ actor: outsider, input
|
|
93
|
-
await expect(can${feature.pascal}Read).not.toDenyPolicy({ actor: reader, input
|
|
98
|
+
await expect(can${feature.pascal}Read).toDenyPolicy({ actor: null, input });
|
|
99
|
+
await expect(can${feature.pascal}Read).toDenyPolicy({ actor: outsider, input });
|
|
100
|
+
await expect(can${feature.pascal}Read).not.toDenyPolicy({ actor: reader, input });
|
|
94
101
|
});
|
|
95
102
|
|
|
96
|
-
unitTest('${feature.camel} write denies
|
|
103
|
+
unitTest('${feature.camel} write denies the read-only actor', async () => {
|
|
97
104
|
// The outsider holds the grant and is still denied: the predicate is a second, independent
|
|
98
105
|
// gate, and this is the assertion that fails if someone deletes it.
|
|
99
|
-
await expect(can${feature.pascal}Write).toDenyPolicy({ actor: reader, input
|
|
100
|
-
await expect(can${feature.pascal}Write).toDenyPolicy({ actor: outsider, input
|
|
101
|
-
await expect(can${feature.pascal}Write).not.toDenyPolicy({ actor: writer, input
|
|
106
|
+
await expect(can${feature.pascal}Write).toDenyPolicy({ actor: reader, input });
|
|
107
|
+
await expect(can${feature.pascal}Write).toDenyPolicy({ actor: outsider, input });
|
|
108
|
+
await expect(can${feature.pascal}Write).not.toDenyPolicy({ actor: writer, input });
|
|
102
109
|
});
|
|
103
110
|
|
|
104
111
|
unitTest('${feature.camel} rules name the permission they require', () => {
|
package/src/templates/query.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
import type { FeatureTarget } from './entity';
|
|
6
6
|
import type { GeneratedFile, NameSet } from './naming';
|
|
7
7
|
import { names } from './naming';
|
|
8
|
+
import { sliceFoundation } from './slice-foundation';
|
|
9
|
+
import { wrapImport } from './wrap';
|
|
8
10
|
|
|
9
11
|
/** A live read always fans out fresh, so a TTL on it would only ever be dead configuration. */
|
|
10
12
|
const cacheLine = (feature: NameSet, live: boolean): string =>
|
|
@@ -20,7 +22,10 @@ const querySource = (
|
|
|
20
22
|
|
|
21
23
|
import { from, query, t } from '@ultimat3/query';
|
|
22
24
|
import type { ${feature.pascal} } from '../entity';
|
|
23
|
-
|
|
25
|
+
${wrapImport(
|
|
26
|
+
live ? [`can${feature.pascal}Read`] : [`can${feature.pascal}Read`, `${feature.camel}Tag`],
|
|
27
|
+
'../policy',
|
|
28
|
+
)}
|
|
24
29
|
import * as repo from '../repo';
|
|
25
30
|
|
|
26
31
|
export const ${name.camel} = query({
|
|
@@ -28,7 +33,7 @@ export const ${name.camel} = query({
|
|
|
28
33
|
policy: can${feature.pascal}Read,
|
|
29
34
|
live: ${String(live)},${cacheLine(feature, live)}
|
|
30
35
|
// Opt-in, unlike an action's tool: a read hands rows to an agent, so silence exposes nothing.
|
|
31
|
-
mcp: { expose: true, description: '${name.raw} —
|
|
36
|
+
mcp: { expose: true, description: '${name.raw} — edit this description' },
|
|
32
37
|
sql: ({ orgId, limit }) =>
|
|
33
38
|
// \`feature.table\`, not the kebab plural: \`from()\` quotes the identifier into the SQL text,
|
|
34
39
|
// and the entity created the table as snake_case.
|
|
@@ -45,7 +50,9 @@ export const ${name.camel} = query({
|
|
|
45
50
|
|
|
46
51
|
const queryTest = (name: NameSet, feature: NameSet, live: boolean): string => {
|
|
47
52
|
const wrapper = live ? 'liveTest' : 'unitTest';
|
|
48
|
-
return
|
|
53
|
+
return `// ${name.camel}: the shape it reads, the policy it asserts, and the actor it refuses. The read is
|
|
54
|
+
// declarative, so what a test can get wrong is the authz, and that is what this pins.
|
|
55
|
+
import { testActor } from '@ultimat3/policy';
|
|
49
56
|
import { sourceFor } from '@ultimat3/query';
|
|
50
57
|
import { expect, ${wrapper} } from '@ultimat3/testing';
|
|
51
58
|
import { ${name.camel} } from './${name.kebab}';
|
|
@@ -74,8 +81,16 @@ ${wrapper}('${name.camel} is bounded and TOTALLY ordered', async () => {
|
|
|
74
81
|
// The SQL text is the contract an agent reads to self-correct, so assert on it, not on a
|
|
75
82
|
// shape. \`sourceFor\` is the one read path — it parses the input and builds the source exactly
|
|
76
83
|
// as a request does. \`actor: null\` gives the call a context of its own rather than borrowing
|
|
77
|
-
// an ambient one, and \`
|
|
78
|
-
|
|
84
|
+
// an ambient one, and \`unenforced\` states WHY the policy is skipped: the escape hatch takes a
|
|
85
|
+
// written reason, never a boolean, because a boolean reads exactly like forgetting the policy.
|
|
86
|
+
const source = await sourceFor(
|
|
87
|
+
target,
|
|
88
|
+
{ orgId, limit: 50 },
|
|
89
|
+
{
|
|
90
|
+
actor: null,
|
|
91
|
+
unenforced: 'a scaffolded test asserts the SQL text; the policy is asserted separately',
|
|
92
|
+
},
|
|
93
|
+
);
|
|
79
94
|
const { sql } = source.toSQL();
|
|
80
95
|
const text = sql.toLowerCase();
|
|
81
96
|
expect(text).toContain('order by');
|
|
@@ -110,6 +125,10 @@ export function queryFiles(rawName: string, target: QueryOptions): readonly Gene
|
|
|
110
125
|
const live = target.live === true;
|
|
111
126
|
const dir = `${target.surfaceDir}/${target.feature}/${live ? 'live' : 'queries'}`;
|
|
112
127
|
return [
|
|
128
|
+
// A read declares the row type it returns (`../entity`), the rule that admits it (`../policy`)
|
|
129
|
+
// and the one module allowed to query the table (`../repo`) — all three are the slice's, and
|
|
130
|
+
// `--live` changes only which directory this file lands in.
|
|
131
|
+
...sliceFoundation(target, ['entity', 'policy']),
|
|
113
132
|
{ path: `${dir}/${name.kebab}.ts`, contents: querySource(name, feature, live) },
|
|
114
133
|
{ path: `${dir}/${name.kebab}.test.ts`, contents: queryTest(name, feature, live) },
|
|
115
134
|
];
|
|
@@ -27,11 +27,16 @@ import * as repo from './repo';
|
|
|
27
27
|
/** Derived from the row, never restated: a new column reaches this input without an edit here. */
|
|
28
28
|
export type Create${feature.pascal}Input = Omit<${feature.pascal}, 'id' | 'createdAt'>;
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
/** The row, aliased once, so every signature below reads at one width whatever the feature is
|
|
31
|
+
* called — a generated file the app's own formatter rewrites is a red \`lint\` over code nobody
|
|
32
|
+
* typed. */
|
|
33
|
+
type Row = ${feature.pascal};
|
|
34
|
+
|
|
35
|
+
export async function create(input: Create${feature.pascal}Input): Promise<Row> {
|
|
31
36
|
return repo.insert(input);
|
|
32
37
|
}
|
|
33
38
|
|
|
34
|
-
export async function require${feature.pascal}(id: string): Promise
|
|
39
|
+
export async function require${feature.pascal}(id: string): Promise<Row> {
|
|
35
40
|
const row = await repo.byId(id);
|
|
36
41
|
if (row === undefined) throw new ${feature.pascal}NotFoundError({ id });
|
|
37
42
|
return row;
|
|
@@ -40,7 +45,9 @@ export async function require${feature.pascal}(id: string): Promise<${feature.pa
|
|
|
40
45
|
|
|
41
46
|
const serviceTest = (
|
|
42
47
|
feature: NameSet,
|
|
43
|
-
): string =>
|
|
48
|
+
): string => `// The ${feature.kebab} feature's failure, pinned: a code an agent can match on, a cause naming
|
|
49
|
+
// the row, and a fix that is an instruction. A bare Error would satisfy none of the three.
|
|
50
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
44
51
|
import { ${feature.pascal}NotFoundError } from './errors';
|
|
45
52
|
|
|
46
53
|
unitTest('${feature.pascal}NotFoundError carries a code, a cause and a fix', () => {
|
|
@@ -69,9 +76,10 @@ export function ${feature.pascal}List(props: ${feature.pascal}ListProps) {
|
|
|
69
76
|
return (
|
|
70
77
|
<ul class={styles.list}>
|
|
71
78
|
<For each={props.rows} fallback={<li>{t('app.${feature.kebab}.empty')}</li>}>
|
|
72
|
-
{/*
|
|
73
|
-
|
|
74
|
-
|
|
79
|
+
{/* <For> is keyed by value identity, so the item arrives directly and a row is only
|
|
80
|
+
re-created when its value changes. <Index> is the accessor-shaped one — reach for it
|
|
81
|
+
when the list is a fixed set of slots whose contents mutate. */}
|
|
82
|
+
{(row) => <li class={styles.item}>{row.title}</li>}
|
|
75
83
|
</For>
|
|
76
84
|
</ul>
|
|
77
85
|
);
|
|
@@ -82,14 +90,14 @@ const uiStyle = (): string => `@use '@ultimat3/ui/tokens' as tokens;
|
|
|
82
90
|
|
|
83
91
|
.list {
|
|
84
92
|
display: grid;
|
|
85
|
-
gap: tokens
|
|
93
|
+
gap: tokens.space(2);
|
|
86
94
|
}
|
|
87
95
|
|
|
88
96
|
.item {
|
|
89
|
-
padding: tokens
|
|
90
|
-
border-radius: tokens
|
|
91
|
-
background: tokens
|
|
92
|
-
color: tokens
|
|
97
|
+
padding: tokens.space(2);
|
|
98
|
+
border-radius: tokens.radius('sm');
|
|
99
|
+
background: tokens.role('surface-raised');
|
|
100
|
+
color: tokens.role('fg');
|
|
93
101
|
}
|
|
94
102
|
`;
|
|
95
103
|
|
package/src/templates/route.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// `x g route <path>` — a URL, its render mode, its metadata, its offline strategy and its budget.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
2
|
+
// Two generated tests, because the gate types a test by its filename: `page.test.ts` pins metadata
|
|
3
|
+
// presence and the budget declaration on the `unit` step, `page.e2e.test.ts` pins the offline
|
|
4
|
+
// fallback on the `e2e` step. A route with no title is an SEO regression and a route with no
|
|
5
|
+
// fallback is a blank screen on a train.
|
|
4
6
|
|
|
5
7
|
import { catalogJson } from './catalog-json';
|
|
6
8
|
import { catalogPath, resolveLocales } from './locales';
|
|
@@ -9,7 +11,14 @@ import { kebab, pascal, titleKey } from './naming';
|
|
|
9
11
|
|
|
10
12
|
export type Surface = 'site' | 'app';
|
|
11
13
|
|
|
12
|
-
|
|
14
|
+
/**
|
|
15
|
+
* `ssr` on `app/`, not `stream`, for the reason `scaffold-app.ts`'s dashboard already states:
|
|
16
|
+
* `stream` sets `needsSuspense`, the framework ships no hole marker, and `defineRoute` therefore
|
|
17
|
+
* throws `X_ROUTE_MODE_INVALID` at import. That is not a failing page — it is a page that registers
|
|
18
|
+
* NO route at all, so every `x g route --surface app` and every `x g resource` scaffolded a URL
|
|
19
|
+
* that was absent from `x routes`, from the manifest and from `budgets`. Ship the mode that works.
|
|
20
|
+
*/
|
|
21
|
+
const RENDER: Record<Surface, string> = { site: 'isr', app: 'ssr' };
|
|
13
22
|
const HYDRATE: Record<Surface, string> = { site: 'never', app: 'visible' };
|
|
14
23
|
const OFFLINE: Record<Surface, string> = { site: 'precache', app: 'runtime' };
|
|
15
24
|
/** Structured, not a literal string: the route and the test that pins it read the same fact. */
|
|
@@ -22,12 +31,35 @@ const budgetLiteral = (surface: Surface): string =>
|
|
|
22
31
|
/** `isr` without a trigger is `static` wearing a costume — @ultimat3/render rejects it at boot. */
|
|
23
32
|
const REVALIDATE: Record<Surface, string> = { site: "\n revalidate: { ttl: '1h' },", app: '' };
|
|
24
33
|
|
|
25
|
-
|
|
26
|
-
|
|
34
|
+
/** `[slug]`, `[...rest]` — the repo's own convention (`app/posts/[id]`, `site/blog/[slug]`). */
|
|
35
|
+
const DYNAMIC_SEGMENT = /^\[(?<spread>\.\.\.)?(?<name>[^\]]+)\]$/;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* One URL segment. `kebab()` strips `[` and `]` along with every other non-alphanumeric, so
|
|
39
|
+
* `x g route "posts/[slug]"` scaffolded `apps/web/app/posts/slug/page.tsx` — a different, STATIC
|
|
40
|
+
* route — and reported `ok:true` with no warning. Only the parameter NAME is kebabed now.
|
|
41
|
+
*/
|
|
42
|
+
export const routeSegment = (part: string): string => {
|
|
43
|
+
const match = DYNAMIC_SEGMENT.exec(part);
|
|
44
|
+
if (match === null) return kebab(part);
|
|
45
|
+
return `[${match.groups?.['spread'] ?? ''}${kebab(match.groups?.['name'] ?? '')}]`;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const segmentsOf = (path: string): readonly string[] =>
|
|
49
|
+
path
|
|
27
50
|
.split('/')
|
|
28
51
|
.filter((part) => part.length > 0)
|
|
29
|
-
.map(
|
|
30
|
-
|
|
52
|
+
.map(routeSegment);
|
|
53
|
+
|
|
54
|
+
/** Every `[param]` the URL declares, in order — what a render actually hands the page. */
|
|
55
|
+
export const routeParams = (path: string): readonly string[] =>
|
|
56
|
+
segmentsOf(path).flatMap((part) => {
|
|
57
|
+
const name = DYNAMIC_SEGMENT.exec(part)?.groups?.['name'];
|
|
58
|
+
return name === undefined ? [] : [name];
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const routeDir = (surface: Surface, path: string): string =>
|
|
62
|
+
`apps/web/${surface}/${segmentsOf(path).join('/')}`;
|
|
31
63
|
|
|
32
64
|
const pageSource = (surface: Surface, path: string): string => {
|
|
33
65
|
const name = pascal(
|
|
@@ -69,28 +101,55 @@ const styleSource =
|
|
|
69
101
|
@use '@ultimat3/ui/tokens' as tokens;
|
|
70
102
|
|
|
71
103
|
.page {
|
|
72
|
-
padding: tokens
|
|
73
|
-
background: tokens
|
|
74
|
-
color: tokens
|
|
104
|
+
padding: tokens.space(6);
|
|
105
|
+
background: tokens.role('bg');
|
|
106
|
+
color: tokens.role('fg');
|
|
75
107
|
}
|
|
76
108
|
`;
|
|
77
109
|
|
|
110
|
+
/** A value for a `[param]`, distinct per parameter so a two-param URL reads unambiguously. */
|
|
111
|
+
const sampleValue = (name: string): string => `${name}-1`;
|
|
112
|
+
|
|
113
|
+
/** The URL a render would actually match, with every `[param]` filled in. */
|
|
114
|
+
const sampleUrl = (path: string): string =>
|
|
115
|
+
segmentsOf(path)
|
|
116
|
+
.map((part) => {
|
|
117
|
+
const name = DYNAMIC_SEGMENT.exec(part)?.groups?.['name'];
|
|
118
|
+
return name === undefined ? part : sampleValue(name);
|
|
119
|
+
})
|
|
120
|
+
.join('/');
|
|
121
|
+
|
|
122
|
+
/** The params object that URL implies. `{}` was a lie for every dynamic route. */
|
|
123
|
+
const paramsLiteral = (path: string): string => {
|
|
124
|
+
const params = routeParams(path);
|
|
125
|
+
if (params.length === 0) return '{}';
|
|
126
|
+
return `{ ${params.map((name) => `${name}: '${sampleValue(name)}'`).join(', ')} }`;
|
|
127
|
+
};
|
|
128
|
+
|
|
78
129
|
const routeTest = (
|
|
79
130
|
surface: Surface,
|
|
80
131
|
path: string,
|
|
81
|
-
): string =>
|
|
132
|
+
): string => `// /${path}'s declaration, on the \`unit\` step: metadata that renders, and the render mode,
|
|
133
|
+
// offline strategy and budget it promises. A route with no title is an SEO regression.
|
|
134
|
+
import { metaContextFor, routeDataFor } from '@ultimat3/render';
|
|
135
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
82
136
|
import { config } from './page';
|
|
83
137
|
|
|
138
|
+
// What a render gives this route: the URL it matched and the params in it. \`routeDataFor\` is the
|
|
139
|
+
// one resolver — with no \`load\` the context IS the data, and with one the loader decides — so a
|
|
140
|
+
// test asserts on the same object the page component and \`meta\` are handed in production.
|
|
141
|
+
const ctx = { params: ${paramsLiteral(path)}, url: 'https://example.test/${sampleUrl(path)}' };
|
|
142
|
+
|
|
84
143
|
unitTest('/${path} declares metadata', async () => {
|
|
85
144
|
expect(config.kind).toBe('route');
|
|
86
145
|
// meta() takes the route's data and always resolves — awaiting is the one shape, whether the
|
|
87
146
|
// declaration was written sync or async.
|
|
88
|
-
const meta = await config.meta(
|
|
147
|
+
const meta = await config.meta(metaContextFor(ctx, await routeDataFor(config, ctx)));
|
|
89
148
|
expect(meta.title ?? '').not.toBe('');
|
|
90
149
|
expect(meta.description ?? '').not.toBe('');
|
|
91
150
|
});
|
|
92
151
|
|
|
93
|
-
unitTest('/${path} declares
|
|
152
|
+
unitTest('/${path} declares its render, offline and budget', () => {
|
|
94
153
|
expect(config.render).toBe('${RENDER[surface]}');
|
|
95
154
|
expect(config.offline).toBe('${OFFLINE[surface]}');
|
|
96
155
|
// budget is always on the descriptor, so pin the number: presence cannot fail.
|
|
@@ -100,9 +159,24 @@ unitTest('/${path} declares a render mode, an offline strategy and a budget', ()
|
|
|
100
159
|
unitTest('/${path} stays inside its byte budget declaration', () => {
|
|
101
160
|
expect(config.budget.js).toBe('${BUDGET[surface].js}');
|
|
102
161
|
});
|
|
162
|
+
`;
|
|
103
163
|
|
|
104
|
-
|
|
105
|
-
|
|
164
|
+
/**
|
|
165
|
+
* The offline assertion, in its own file — and that is the whole point of the split. The gate
|
|
166
|
+
* types a test by its FILENAME, so an `e2eTest` living in `page.test.ts` was classified as a UNIT
|
|
167
|
+
* test and could never reach the `e2e` step no matter what drove it. `page.e2e.test.ts` is the
|
|
168
|
+
* name the `e2e` step selects on.
|
|
169
|
+
*/
|
|
170
|
+
const routeE2eTest = (
|
|
171
|
+
path: string,
|
|
172
|
+
): string => `// /${path} on a train: the offline fallback, asserted against the BUILT output. Its own file
|
|
173
|
+
// because the gate types a test by its FILENAME — an e2eTest in page.test.ts is a unit test.
|
|
174
|
+
import { e2eTest, expect } from '@ultimat3/testing';
|
|
175
|
+
|
|
176
|
+
// Runs under the \`e2e\` step, against the BUILT output. Without a registered browser driver
|
|
177
|
+
// \`e2eTest\` reports itself skipped, naming the command that builds what it would drive.
|
|
178
|
+
e2eTest('/${path} renders offline', async ({ page, offline }) => {
|
|
179
|
+
await page.goto('/${sampleUrl(path)}');
|
|
106
180
|
await offline();
|
|
107
181
|
await page.reload();
|
|
108
182
|
expect(await page.title()).not.toBe('');
|
|
@@ -129,6 +203,7 @@ export function routeFiles(rawPath: string, options: RouteOptions): readonly Gen
|
|
|
129
203
|
{ path: `${dir}/page.tsx`, contents: pageSource(options.surface, path) },
|
|
130
204
|
{ path: `${dir}/page.module.scss`, contents: styleSource() },
|
|
131
205
|
{ path: `${dir}/page.test.ts`, contents: routeTest(options.surface, path) },
|
|
206
|
+
{ path: `${dir}/page.e2e.test.ts`, contents: routeE2eTest(path) },
|
|
132
207
|
...locales.map((locale) => ({
|
|
133
208
|
path: catalogPath(locale),
|
|
134
209
|
contents: catalogSource(path),
|