@ultimat3/cli 22.15.0 → 24.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 +36 -13
- package/README.md +31 -8
- package/package.json +29 -28
- package/src/admin-catalog-keys.ts +16 -0
- package/src/admin-registration.ts +192 -0
- package/src/affected.ts +27 -35
- package/src/api-registration.ts +125 -11
- package/src/app-artifacts.ts +1 -1
- package/src/app-auth.ts +4 -27
- package/src/app-boundaries.ts +6 -20
- package/src/app-config-load.ts +51 -0
- package/src/app-env.ts +5 -11
- package/src/app-load.ts +55 -11
- package/src/app-manifest.ts +16 -8
- package/src/app-mcp.ts +10 -23
- package/src/app-permissions-borrowed.ts +129 -0
- package/src/app-permissions-site.ts +24 -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 +27 -34
- package/src/cdp-shot-driver.ts +3 -1
- package/src/cmd-auth-spec.ts +16 -0
- package/src/cmd-auth.ts +65 -0
- package/src/cmd-build-spec.ts +10 -3
- package/src/cmd-build.ts +107 -11
- package/src/cmd-db-backfill.ts +25 -12
- package/src/cmd-db.ts +66 -16
- package/src/cmd-deploy-helm.ts +7 -6
- package/src/cmd-deploy.ts +4 -4
- package/src/cmd-dev.ts +9 -8
- package/src/cmd-doctor-spec.ts +2 -9
- package/src/cmd-doctor.ts +75 -24
- package/src/cmd-env.ts +26 -2
- package/src/cmd-generate.ts +195 -47
- package/src/cmd-help.ts +19 -6
- package/src/cmd-i18n.ts +10 -9
- package/src/cmd-jobs-spec.ts +19 -3
- package/src/cmd-jobs.ts +136 -7
- package/src/cmd-manifest-spec.ts +1 -1
- package/src/cmd-manifest.ts +6 -25
- package/src/cmd-mcp.ts +46 -15
- package/src/cmd-new.ts +15 -1
- package/src/cmd-planned.ts +15 -8
- package/src/cmd-routes.ts +27 -3
- package/src/cmd-secrets-ring.ts +76 -0
- package/src/cmd-secrets-spec.ts +10 -2
- package/src/cmd-secrets.ts +95 -18
- package/src/cmd-shot-matrix.ts +1 -3
- package/src/cmd-shot.ts +12 -3
- package/src/cmd-tasks.ts +84 -15
- package/src/cmd-test-spec.ts +1 -7
- package/src/cmd-test.ts +13 -29
- package/src/cmd-verify.ts +13 -5
- 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/db-subscribes.ts +12 -4
- package/src/dev-dashboard-guard.ts +76 -0
- package/src/dev-dashboard.ts +51 -29
- package/src/dev-lock.ts +4 -7
- package/src/dev-route-table.ts +9 -0
- package/src/dispatch.ts +3 -6
- package/src/doctor-app-url.ts +39 -0
- package/src/doctor-auth.ts +95 -0
- 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/drift-replica-identity.ts +42 -0
- package/src/error-catalog.ts +6 -9
- package/src/error-codes.ts +55 -3
- package/src/error-unthrown.ts +29 -11
- package/src/errors.ts +54 -83
- package/src/framework-schema-apply.ts +78 -0
- package/src/framework-schema-stamp.ts +96 -0
- package/src/framework-schema.ts +28 -2
- package/src/generate-files.ts +79 -12
- package/src/generate-grant-findings.ts +76 -0
- package/src/generate-grants.ts +77 -3
- package/src/generate-kinds.ts +52 -1
- package/src/generate-shadow.ts +46 -0
- package/src/generate-write.ts +62 -40
- package/src/generated-imports.ts +59 -0
- package/src/gh-target.ts +12 -7
- package/src/guard-sources.ts +104 -0
- package/src/guards.ts +23 -7
- package/src/handle-registration.ts +217 -0
- package/src/i18n-audit.ts +4 -1
- 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 +12 -5
- package/src/island-bundle.ts +3 -15
- package/src/island-store.ts +24 -16
- package/src/jobs-drain.ts +95 -21
- package/src/jobs-json.ts +50 -0
- package/src/jobs-report.ts +7 -4
- package/src/json-merge.ts +46 -0
- package/src/loopback-host.ts +13 -0
- package/src/manifest-staleness.ts +62 -0
- package/src/mcp-errors.ts +41 -4
- package/src/mcp-host.ts +16 -9
- package/src/mcp-test-run.ts +38 -0
- package/src/messages.ts +35 -0
- package/src/metrics-endpoint.ts +95 -24
- package/src/migration-extensions.ts +29 -0
- package/src/module-imports.ts +47 -0
- package/src/output.ts +24 -9
- package/src/page-navigation.ts +7 -23
- package/src/page-speculation.ts +5 -20
- package/src/permission-grants.ts +41 -9
- package/src/prerender-out.ts +34 -7
- package/src/prerender.ts +8 -5
- package/src/publish-closure.ts +101 -0
- package/src/pwa-artifacts.ts +26 -94
- package/src/registry.ts +2 -0
- package/src/revalidated-response.ts +4 -3
- package/src/role-load.ts +143 -0
- package/src/role-replicator.ts +16 -0
- package/src/role-start.ts +27 -5
- package/src/role-sync.ts +55 -20
- package/src/role-wake.ts +45 -0
- package/src/runtime-admin.ts +141 -0
- package/src/runtime-assets.ts +11 -7
- package/src/runtime-bindings.ts +4 -4
- package/src/runtime-cache.ts +38 -47
- package/src/runtime-hooks.ts +5 -2
- package/src/runtime-idempotency-scope.ts +23 -0
- package/src/runtime-isr.ts +36 -0
- package/src/runtime-jobs.ts +8 -33
- package/src/runtime-mfa-warning.ts +29 -0
- package/src/runtime-notify-retention.ts +10 -46
- package/src/runtime-overrides.ts +1 -1
- package/src/runtime-purge.ts +38 -16
- package/src/runtime-queue.ts +39 -11
- package/src/runtime-realtime.ts +9 -45
- package/src/runtime-render.ts +68 -35
- package/src/runtime-services.ts +22 -18
- package/src/runtime-storage.ts +10 -5
- package/src/scaffold-typecheck.ts +1 -0
- package/src/schema-diff.ts +16 -0
- package/src/schema-drift.ts +45 -15
- package/src/schema-dump-drift.ts +64 -0
- package/src/seo-routes.ts +80 -9
- package/src/serve-boot.ts +114 -158
- package/src/serve-drain.ts +9 -33
- package/src/serve-env.ts +9 -6
- package/src/serve-prebuilt-paths.ts +19 -0
- package/src/serve-prebuilt.ts +84 -0
- package/src/serve-web.ts +169 -0
- package/src/serve.ts +6 -9
- package/src/server-barrels.ts +54 -0
- package/src/shot-locale.ts +5 -23
- package/src/site-assets.ts +3 -5
- package/src/site-config.ts +9 -48
- package/src/site-seo.ts +1 -1
- package/src/sitemap-lastmod.ts +5 -0
- package/src/solid-loader.ts +12 -3
- package/src/source-edit.ts +49 -0
- package/src/source-files.ts +6 -0
- package/src/stored-object-headers.ts +43 -0
- package/src/sync-url.ts +2 -2
- package/src/tasks-facts.ts +3 -26
- package/src/templates/action.ts +147 -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/naming.ts +28 -0
- 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 +41 -35
- package/src/templates/scaffold-api.ts +55 -1
- package/src/templates/scaffold-app.ts +177 -53
- package/src/templates/scaffold-auth.ts +83 -19
- package/src/templates/scaffold-browser-client.ts +90 -0
- package/src/templates/scaffold-container-compose.ts +10 -1
- package/src/templates/scaffold-container.ts +20 -2
- 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 +144 -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-helm-fences.ts +76 -0
- package/src/templates/scaffold-helm-templates.ts +32 -7
- package/src/templates/scaffold-helm.ts +31 -1
- package/src/templates/scaffold-http.ts +9 -7
- package/src/templates/scaffold-repo.ts +61 -3
- package/src/templates/scaffold-roles.ts +56 -9
- package/src/templates/scaffold-shell.ts +74 -105
- 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 +14 -2
- package/src/test-ignores.ts +25 -0
- package/src/test-passes.ts +0 -15
- package/src/test-select.ts +5 -14
- package/src/test-shards.ts +41 -93
- package/src/test-workers.ts +26 -4
- package/src/theme-boot.ts +6 -31
- package/src/transport-calls.ts +105 -0
- package/src/trusted-hops-error.ts +17 -0
- package/src/unscanned-admin.ts +54 -0
- package/src/unthrown-codes.ts +26 -0
- package/src/verify-checks.ts +43 -53
- package/src/verify-coverage-run.ts +190 -0
- package/src/verify-deadline.ts +353 -0
- package/src/verify-floor.ts +36 -9
- package/src/verify-merge.ts +171 -45
- package/src/verify-part-step.ts +90 -0
- 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 +116 -37
- package/src/verify-shard.ts +33 -11
- package/src/verify-stalled.ts +152 -0
- package/src/verify-step.ts +42 -1
- package/src/verify-tests.ts +83 -8
- package/src/workspace-checks.ts +50 -8
- package/src/workspace-dep-edit.ts +127 -0
- package/src/build-errors.ts +0 -54
- 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
- package/src/verify-errors.ts +0 -49
package/src/solid-loader.ts
CHANGED
|
@@ -73,6 +73,10 @@ interface CacheEntry {
|
|
|
73
73
|
*/
|
|
74
74
|
const cache = new Map<string, CacheEntry>();
|
|
75
75
|
|
|
76
|
+
/** Transforms running now, and the `prepareStackTrace` the first of them found (see below). */
|
|
77
|
+
let inFlight = 0;
|
|
78
|
+
let foundStackTrace: typeof Error.prepareStackTrace;
|
|
79
|
+
|
|
76
80
|
/** Test seam: the cache is process-global because the dev server it serves is too. */
|
|
77
81
|
export function clearIslandTransformCache(): void {
|
|
78
82
|
cache.clear();
|
|
@@ -92,8 +96,12 @@ export async function transformIslandTsx(source: string, path: string): Promise<
|
|
|
92
96
|
|
|
93
97
|
// Babel installs its own `prepareStackTrace` on the first TRANSFORM — not on import, which is
|
|
94
98
|
// where this guard was first put — and leaving it installed makes `Error.captureStackTrace`
|
|
95
|
-
// strict for every unrelated module loaded later in the same process
|
|
96
|
-
|
|
99
|
+
// strict for every unrelated module loaded later in the same process, and names every error in
|
|
100
|
+
// it `Error` (a `TypeError`'s stack then opens `Error: …`). Saved by the FIRST transform in
|
|
101
|
+
// flight and restored by the LAST: `Bun.build` loads a second `.tsx` while the first compiles,
|
|
102
|
+
// and a per-call save taken inside that window saved Babel's own rewriter and restored it last.
|
|
103
|
+
if (inFlight === 0) foundStackTrace = Error.prepareStackTrace;
|
|
104
|
+
inFlight += 1;
|
|
97
105
|
let code: string | null | undefined;
|
|
98
106
|
try {
|
|
99
107
|
// `transformAsync` and never `transformFileAsync`: the latter is gated behind `@babel/core`'s
|
|
@@ -111,7 +119,8 @@ export async function transformIslandTsx(source: string, path: string): Promise<
|
|
|
111
119
|
});
|
|
112
120
|
code = result?.code;
|
|
113
121
|
} finally {
|
|
114
|
-
|
|
122
|
+
inFlight -= 1;
|
|
123
|
+
if (inFlight === 0) Error.prepareStackTrace = foundStackTrace;
|
|
115
124
|
}
|
|
116
125
|
// A parse error is NOT caught here: Babel's own message already names the file, the line and the
|
|
117
126
|
// column ("a.island.tsx: Unexpected token (2:23)"), and `buildOne` wraps whatever escapes in
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// The two text edits a generator makes to a file the app owns: one keyed line added to an object
|
|
2
|
+
// literal, and one import added where the formatter's sorted block would have it. Shared by the
|
|
3
|
+
// registrars (`handle-registration.ts`, `admin-registration.ts`) so "in key order, expanded" means
|
|
4
|
+
// one thing in both.
|
|
5
|
+
|
|
6
|
+
const IDENTIFIER = '[A-Za-z_$][\\w$]*';
|
|
7
|
+
|
|
8
|
+
const keyOf = (item: string): string => item.split(':')[0]?.trim() ?? '';
|
|
9
|
+
|
|
10
|
+
/** Whether the object body already holds `key` — as `key: value` or as a shorthand. */
|
|
11
|
+
export const holdsKey = (body: string, key: string): boolean =>
|
|
12
|
+
new RegExp(`(^|[\\s,{])${key}\\s*[:,]`).test(`${body},`);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* An object literal's body with `line` on its own row, in key order. A body the author keeps on
|
|
16
|
+
* one line is expanded — an expanded literal is the shape a formatter leaves alone at any length.
|
|
17
|
+
* `indent` is what an entry of this literal is indented by.
|
|
18
|
+
*/
|
|
19
|
+
export function withKeyedLine(body: string, key: string, line: string, indent: string): string {
|
|
20
|
+
if (!body.includes('\n')) {
|
|
21
|
+
const items = body
|
|
22
|
+
.split(',')
|
|
23
|
+
.map((item) => item.trim())
|
|
24
|
+
.filter((item) => item !== '');
|
|
25
|
+
const lines = [...items.map((item) => `${indent}${item},`), line].sort((a, b) =>
|
|
26
|
+
keyOf(a) < keyOf(b) ? -1 : 1,
|
|
27
|
+
);
|
|
28
|
+
return `\n${lines.join('\n')}\n${indent.slice(2)}`;
|
|
29
|
+
}
|
|
30
|
+
const lines = body.split('\n');
|
|
31
|
+
const isEntry = (text: string): boolean => new RegExp(`^\\s*${IDENTIFIER}\\s*[:,]`).test(text);
|
|
32
|
+
const after = lines.findIndex((text) => isEntry(text) && keyOf(text) > key);
|
|
33
|
+
// Past the last entry when none sorts later: the row before the closing brace's own indent.
|
|
34
|
+
lines.splice(after === -1 ? lines.length - 1 : after, 0, line);
|
|
35
|
+
return lines.join('\n');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** `source` with `line` imported, placed where a sorted import block would have it. */
|
|
39
|
+
export function withSortedImport(source: string, line: string, specifier: string): string {
|
|
40
|
+
if (source.includes(`from '${specifier}'`)) return source;
|
|
41
|
+
const imports = [...source.matchAll(/^import\b[^;]*?from\s+'([^']+)';\n/gm)];
|
|
42
|
+
// Packages only: a relative import is a LATER group, so it is never what this line sorts against.
|
|
43
|
+
const packages = imports.filter((match) => !(match[1] ?? '').startsWith('.'));
|
|
44
|
+
const later = packages.find((match) => (match[1] ?? '') > specifier);
|
|
45
|
+
const last = packages.at(-1);
|
|
46
|
+
const first = imports[0]?.index ?? 0;
|
|
47
|
+
const at = later?.index ?? (last === undefined ? first : last.index + last[0].length);
|
|
48
|
+
return `${source.slice(0, at)}${line}\n${source.slice(at)}`;
|
|
49
|
+
}
|
package/src/source-files.ts
CHANGED
|
@@ -16,6 +16,12 @@ export const SOURCE_GLOBS = [
|
|
|
16
16
|
'api/**/*.{ts,tsx}',
|
|
17
17
|
'shared/**/*.{ts,tsx}',
|
|
18
18
|
'apps/*/{app,site,api,shared}/**/*.{ts,tsx}',
|
|
19
|
+
// An app's own entry files: authored, shipped, and outside every surface glob above — a 900-line
|
|
20
|
+
// `server.ts` or an unrunnable `fix:` in it was invisible to `filesize` and `errors`.
|
|
21
|
+
// And its guards: run on the gate, held to the error contract, and invisible to `filesize`.
|
|
22
|
+
'guards/*.ts',
|
|
23
|
+
'apps/*/server.ts',
|
|
24
|
+
'apps/*/prerender.ts',
|
|
19
25
|
] as const;
|
|
20
26
|
|
|
21
27
|
/**
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// How a stored object may be presented when the app's own origin serves it (plan 101, s1-sec L6):
|
|
2
|
+
// inline only for a type a browser renders without running anything, a sandboxed download otherwise. Read by
|
|
3
|
+
// both surfaces that serve stored bytes — `/_storage` (`runtime-storage.ts`) and `/media`.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Raster images, audio and video: what an `<img>`, `<audio>` or `<video>` presents and nothing
|
|
7
|
+
* executes. NOT `image/svg+xml` (it carries `<script>`), `text/html`, `application/xml` or PDF —
|
|
8
|
+
* any of them opened from the app's origin runs with the app's cookies and its `'self'` sources.
|
|
9
|
+
*/
|
|
10
|
+
const INLINE_TYPES: ReadonlySet<string> = new Set([
|
|
11
|
+
'image/png',
|
|
12
|
+
'image/jpeg',
|
|
13
|
+
'image/gif',
|
|
14
|
+
'image/webp',
|
|
15
|
+
'image/avif',
|
|
16
|
+
'audio/mpeg',
|
|
17
|
+
'audio/ogg',
|
|
18
|
+
'audio/wav',
|
|
19
|
+
'audio/webm',
|
|
20
|
+
'video/mp4',
|
|
21
|
+
'video/webm',
|
|
22
|
+
'video/ogg',
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
/** `image/PNG; charset=…` is `image/png`: parameters and case never widen the list. */
|
|
26
|
+
const essenceOf = (contentType: string): string =>
|
|
27
|
+
(contentType.split(';')[0] ?? '').trim().toLowerCase();
|
|
28
|
+
|
|
29
|
+
export const isInlineSafe = (contentType: string): boolean =>
|
|
30
|
+
INLINE_TYPES.has(essenceOf(contentType));
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The headers a stored object is served with beyond its type. `attachment` makes a navigation to an
|
|
34
|
+
* uploaded `text/html` a download instead of a page on the app's origin; an embedding element
|
|
35
|
+
* (`<img>`, `<video>`) ignores it, so nothing that displays an object inline stops working. The
|
|
36
|
+
* `sandbox` policy is for the browser that renders it anyway: an opaque origin, no script, no
|
|
37
|
+
* cookies. `@ultimat3/http`'s security stage keeps it and adds the app's policy beside it.
|
|
38
|
+
*/
|
|
39
|
+
export function storedObjectHeaders(contentType: string): Readonly<Record<string, string>> {
|
|
40
|
+
return isInlineSafe(contentType)
|
|
41
|
+
? {}
|
|
42
|
+
: { 'content-disposition': 'attachment', 'content-security-policy': 'sandbox' };
|
|
43
|
+
}
|
package/src/sync-url.ts
CHANGED
|
@@ -43,8 +43,8 @@ export function syncConnectSources(env: Readonly<Record<string, string | undefin
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
|
-
* The page origins a sync node admits besides its own
|
|
47
|
-
*
|
|
46
|
+
* The page origins a sync node admits besides its own origin: `APP_URL`'s, when set. A deployment
|
|
47
|
+
* that serves the page on another port or host than the node sees needs it; an unparsable value is
|
|
48
48
|
* refused rather than read as "no page origin", which would refuse every socket in silence.
|
|
49
49
|
*/
|
|
50
50
|
export function syncOriginsFrom(env: Readonly<Record<string, string | undefined>>): string[] {
|
package/src/tasks-facts.ts
CHANGED
|
@@ -5,35 +5,12 @@
|
|
|
5
5
|
import type { TaskDescriptor, TaskHandle } from '@ultimat3/jobs';
|
|
6
6
|
import { getTask, registeredTasks } from '@ultimat3/jobs';
|
|
7
7
|
import type { CronPhrases } from '@ultimat3/time';
|
|
8
|
-
import {
|
|
9
|
-
describeCron,
|
|
10
|
-
fromEpochMs,
|
|
11
|
-
nextCronOccurrenceMs,
|
|
12
|
-
offsetLabel,
|
|
13
|
-
toZoned,
|
|
14
|
-
} from '@ultimat3/time';
|
|
8
|
+
import { describeCron, fromEpochMs, isoInZone, nextCronOccurrenceMs } from '@ultimat3/time';
|
|
15
9
|
import { BadFlagError } from './errors';
|
|
16
10
|
|
|
17
11
|
const DEFAULT_COUNT = 5;
|
|
18
12
|
const MAX_COUNT = 50;
|
|
19
13
|
|
|
20
|
-
const pad2 = (value: number): string => String(value).padStart(2, '0');
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* ISO-8601 rendered with the ZONE's OWN offset (`2026-03-08T03:00:00-04:00`), never collapsed to
|
|
24
|
-
* UTC `Z` — an ambient zone is exactly the bug a task's `tz` exists to prevent. No single
|
|
25
|
-
* "instant in a zone, as ISO" export exists in `@ultimat3/time` to call instead: `toIso` is
|
|
26
|
-
* UTC-`Z` only, `isoDateInZone` is date-only, and `formatWithOffset` renders locale prose, not
|
|
27
|
-
* ISO. So this composes the package's own instant→zoned-wall-clock conversion (`toZoned`) and
|
|
28
|
-
* zone-label helper (`offsetLabel`) rather than a fresh `Intl.DateTimeFormat` call here.
|
|
29
|
-
*/
|
|
30
|
-
function isoInZone(ms: number, zone: string): string {
|
|
31
|
-
const zoned = toZoned(fromEpochMs(ms), zone);
|
|
32
|
-
const date = `${String(zoned.year).padStart(4, '0')}-${pad2(zoned.month)}-${pad2(zoned.day)}`;
|
|
33
|
-
const time = `${pad2(zoned.hour)}:${pad2(zoned.minute)}:${pad2(zoned.second)}`;
|
|
34
|
-
return `${date}T${time}${offsetLabel(zoned.offsetMinutes)}`;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
14
|
/** `x tasks list` row: the descriptor plus the next occurrence, ms and rendered alike. */
|
|
38
15
|
export interface TaskFact extends TaskDescriptor {
|
|
39
16
|
readonly nextMs: number;
|
|
@@ -43,7 +20,7 @@ export interface TaskFact extends TaskDescriptor {
|
|
|
43
20
|
function toFact(handle: TaskHandle, nowMs: number): TaskFact {
|
|
44
21
|
const descriptor = handle.describe();
|
|
45
22
|
const nextMs = nextCronOccurrenceMs(descriptor.cron, descriptor.tz, nowMs);
|
|
46
|
-
return { ...descriptor, nextMs, next: isoInZone(nextMs, descriptor.tz) };
|
|
23
|
+
return { ...descriptor, nextMs, next: isoInZone(fromEpochMs(nextMs), descriptor.tz) };
|
|
47
24
|
}
|
|
48
25
|
|
|
49
26
|
export function listTaskFacts(nowMs: number): readonly TaskFact[] {
|
|
@@ -107,7 +84,7 @@ export function taskShowFacts(
|
|
|
107
84
|
let cursor = nowMs;
|
|
108
85
|
for (let i = 0; i < count; i += 1) {
|
|
109
86
|
cursor = nextCronOccurrenceMs(descriptor.cron, descriptor.tz, cursor);
|
|
110
|
-
upcoming.push({ ms: cursor, at: isoInZone(cursor, descriptor.tz) });
|
|
87
|
+
upcoming.push({ ms: cursor, at: isoInZone(fromEpochMs(cursor), descriptor.tz) });
|
|
111
88
|
}
|
|
112
89
|
return { descriptor, describe: describeCron(descriptor.cron, 'en-US', phrases), upcoming };
|
|
113
90
|
}
|
package/src/templates/action.ts
CHANGED
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
// job handle, an MCP tool and these tests; the generator writes the declaration and the tests.
|
|
4
4
|
|
|
5
5
|
import type { FeatureTarget } from './entity';
|
|
6
|
+
import { sortedImports } from './imports';
|
|
6
7
|
import type { GeneratedFile, NameSet } from './naming';
|
|
7
8
|
import { names } from './naming';
|
|
8
|
-
import {
|
|
9
|
+
import { PLACEHOLDER_DB_MODULE } from './scaffold-db-client';
|
|
10
|
+
import { sliceExports, sliceFoundation, sliceTakesScaffoldRow } from './slice-foundation';
|
|
9
11
|
import { wrapImport } from './wrap';
|
|
10
12
|
|
|
11
13
|
/**
|
|
@@ -80,6 +82,9 @@ export const ${name.camel} = mutator({
|
|
|
80
82
|
input: t.object({ id: t.uuid, orgId: t.uuid, title: t.string }),
|
|
81
83
|
output: t.object({ id: t.uuid, title: t.string }),
|
|
82
84
|
policy: can${feature.pascal}Write,
|
|
85
|
+
// Required on a mutator (X_MUTATOR_NOT_IDEMPOTENT): the client replays this write under one
|
|
86
|
+
// Idempotency-Key after a dropped response, and the replay answers the first result.
|
|
87
|
+
idempotent: true,
|
|
83
88
|
// No \`mcp\` until a real description is written: see \`x g action\`.
|
|
84
89
|
// tx.table(name) rather than tx.${feature.plural}: the typed accessor exists only once the app
|
|
85
90
|
// augments LocalTables, and generated code cannot assume that has happened yet. The name is the
|
|
@@ -104,6 +109,10 @@ ${
|
|
|
104
109
|
});
|
|
105
110
|
`;
|
|
106
111
|
|
|
112
|
+
/** The code the slice's `errors.ts` declares for a missing row. */
|
|
113
|
+
const notFoundCode = (feature: NameSet): string =>
|
|
114
|
+
`X_${feature.kebab.toUpperCase().split('-').join('_')}_NOT_FOUND`;
|
|
115
|
+
|
|
107
116
|
const ID = '00000000-0000-4000-8000-000000000001';
|
|
108
117
|
const ORG = '00000000-0000-4000-8000-000000000002';
|
|
109
118
|
const OTHER_ORG = '00000000-0000-4000-8000-000000000009';
|
|
@@ -160,20 +169,137 @@ const outsider = testActor('outsider', {
|
|
|
160
169
|
: ''
|
|
161
170
|
}`;
|
|
162
171
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
172
|
+
/** What the unit test may do with the slice the declaration lives in. */
|
|
173
|
+
interface HandlerShape {
|
|
174
|
+
readonly isMutator: boolean;
|
|
175
|
+
/** The handler reads a row by id and throws `<Feature>NotFoundError` when there is none. */
|
|
176
|
+
readonly lookup: boolean;
|
|
177
|
+
/** The slice still takes the row `x g entity` scaffolds, so the test may store one. */
|
|
178
|
+
readonly storesRow: boolean;
|
|
179
|
+
readonly dbModule: string;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The optimistic half, run against the client store `@ultimat3/testing` ships. A twin is replayed
|
|
184
|
+
* on every rebase, so the two facts worth failing on are the row one application leaves and that a
|
|
185
|
+
* second leaves the same one — and that a row the store never held is not invented.
|
|
186
|
+
*/
|
|
187
|
+
const localTwinTests = (): string => `
|
|
188
|
+
unitTest('the local twin marks the row pending, and a replay lands in the same place', () => {
|
|
189
|
+
const store = memoryLocalTx({ [TABLE]: { [id]: { id, title: 'before', pending: false } } });
|
|
190
|
+
target.local(store.tx, input);
|
|
191
|
+
const once = store.rows(TABLE);
|
|
192
|
+
expect(once).toEqual({ [id]: { id, title: 'a title', pending: true } });
|
|
193
|
+
// A rebase replays the queued write: twice through must equal once through.
|
|
194
|
+
target.local(store.tx, input);
|
|
195
|
+
expect(store.rows(TABLE)).toEqual(once);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
unitTest('the local twin invents no row the store never held', () => {
|
|
199
|
+
const store = memoryLocalTx();
|
|
200
|
+
target.local(store.tx, input);
|
|
201
|
+
expect(store.rows(TABLE)).toEqual({});
|
|
202
|
+
});
|
|
203
|
+
`;
|
|
204
|
+
|
|
205
|
+
/** The authoritative half's cases, refusal first, in the shape the slice earns. */
|
|
206
|
+
const handlerTests = (feature: NameSet, shape: HandlerShape): string => {
|
|
207
|
+
const answer = shape.isMutator ? "{ id, title: 'a title' }" : '{ id }';
|
|
208
|
+
if (!shape.lookup) {
|
|
209
|
+
return `
|
|
210
|
+
unitTest('an actor without the grant is refused before the handler runs', async () => {
|
|
211
|
+
const stranger = testActor('stranger', { orgId }).actor;
|
|
212
|
+
const refused = await target.as(stranger, input).catch((error: unknown) => error);
|
|
213
|
+
expect(refused).toBeUltimateError('X_FORBIDDEN');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
unitTest('the handler answers a writer in the org', async () => {
|
|
217
|
+
expect(await target.as(writer, input)).toEqual(${answer});
|
|
218
|
+
});
|
|
219
|
+
`;
|
|
220
|
+
}
|
|
221
|
+
const found = shape.isMutator
|
|
222
|
+
? "{ id: row.id, title: 'a title' }"
|
|
223
|
+
: "{ id: row.id, title: 'kept' }";
|
|
224
|
+
const call = shape.isMutator
|
|
225
|
+
? "{ id: row.id, orgId, title: 'a title' }"
|
|
226
|
+
: '{ id: row.id, orgId }';
|
|
227
|
+
return `
|
|
228
|
+
unitTest('the handler refuses an id nothing holds', async () => {
|
|
229
|
+
const refused = await target.as(writer, input).catch((error: unknown) => error);
|
|
230
|
+
expect(refused).toBeUltimateError('${notFoundCode(feature)}');
|
|
231
|
+
});
|
|
232
|
+
${
|
|
233
|
+
shape.storesRow
|
|
234
|
+
? `
|
|
235
|
+
unitTest('the handler answers the row it found', async () => {
|
|
236
|
+
const draft = { orgId, title: 'kept', price: { minor: 0, currency: 'USD' } };
|
|
237
|
+
const row = await runWithContext(createContext({ actor: writer }), () => repo.insert(draft));
|
|
238
|
+
const answer = await target.as(writer, ${call});
|
|
239
|
+
expect(answer).toEqual(${found});
|
|
240
|
+
});
|
|
241
|
+
`
|
|
242
|
+
: ''
|
|
243
|
+
}`;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The unit test: the declaration's shape, the input it refuses, and the HANDLER — run through
|
|
248
|
+
* `.as()`, the one execution path, against the in-memory driver. A mutator's local twin runs
|
|
249
|
+
* against `memoryLocalTx`. The contract projections are next door, in their own suite.
|
|
250
|
+
*
|
|
251
|
+
* A row is stored only in a slice whose row this generator can spell (`sliceTakesScaffoldRow`): a
|
|
252
|
+
* slice an author reshaped has its own columns, and a test that inserted `{ title, price }` into
|
|
253
|
+
* it would be a guess about someone else's table.
|
|
254
|
+
*/
|
|
255
|
+
const actionUnitTest = (name: NameSet, feature: NameSet, shape: HandlerShape): string => {
|
|
256
|
+
const stores = shape.lookup && shape.storesRow;
|
|
257
|
+
const testing = [
|
|
258
|
+
...(stores ? ['afterEach'] : []),
|
|
259
|
+
'expect',
|
|
260
|
+
...(shape.isMutator ? ['memoryLocalTx'] : []),
|
|
261
|
+
'unitTest',
|
|
262
|
+
].join(', ');
|
|
263
|
+
return `// ${name.camel}: its declared shape, the input it refuses, and what its handler answers — in
|
|
264
|
+
// process, so all three belong to the \`unit\` step. The contract projections are next door.
|
|
265
|
+
${sortedImports([
|
|
266
|
+
...(stores ? ["import { createContext, runWithContext } from '@ultimat3/core';"] : []),
|
|
267
|
+
"import { testActor } from '@ultimat3/policy';",
|
|
268
|
+
`import { ${testing} } from '@ultimat3/testing';`,
|
|
269
|
+
...(stores ? [`import { driver } from '${shape.dbModule}';`] : []),
|
|
270
|
+
])}${stores ? "\nimport * as repo from '../repo';" : ''}
|
|
271
|
+
import { ${name.camel} } from './${name.kebab}';
|
|
272
|
+
|
|
273
|
+
const id = '${ID}';
|
|
274
|
+
const orgId = '${ORG}';
|
|
275
|
+
const input = { id, orgId${shape.isMutator ? ", title: 'a title'" : ''} };
|
|
276
|
+
${shape.isMutator ? `\n// The client store's table: the entity's own, so the twin and the server row share one key.\nconst TABLE = '${feature.table}';\n` : ''}
|
|
277
|
+
// Named here because every projection needs a stable name and this file does not boot the app.
|
|
278
|
+
// At boot \`registerActions(await import('./actions'))\` stamps the same name onto the same
|
|
279
|
+
// object, so \`${name.camel}.tool()\` works there with nothing to remember.
|
|
280
|
+
const target = ${name.camel}.named('${name.camel}');
|
|
281
|
+
|
|
282
|
+
// Holds the grant in the org the input names: past the policy, so what answers is the handler.
|
|
283
|
+
const grant = ['${feature.kebab}:write'];
|
|
284
|
+
const writer = testActor('writer', { orgId, permissions: grant }).actor;
|
|
285
|
+
${
|
|
286
|
+
stores
|
|
287
|
+
? `
|
|
288
|
+
// One store per process: without this, one test's rows are the next test's fixtures.
|
|
289
|
+
afterEach(() => {
|
|
290
|
+
driver.reset?.();
|
|
291
|
+
});
|
|
292
|
+
`
|
|
293
|
+
: ''
|
|
294
|
+
}
|
|
295
|
+
${shapeTest(name, shape.isMutator)}
|
|
171
296
|
|
|
172
297
|
unitTest('${name.camel} rejects input that is not a uuid', async () => {
|
|
173
298
|
await expect(target.input).toRejectInput({ ...input, id: 'not-a-uuid' });
|
|
174
299
|
await expect(target.input).toAcceptInput(input);
|
|
175
300
|
});
|
|
176
|
-
`;
|
|
301
|
+
${shape.isMutator ? localTwinTests() : ''}${handlerTests(feature, shape)}`;
|
|
302
|
+
};
|
|
177
303
|
|
|
178
304
|
const actionContractTest = (
|
|
179
305
|
name: NameSet,
|
|
@@ -218,6 +344,8 @@ export interface ActionOptions extends FeatureTarget {
|
|
|
218
344
|
* data. `x g resource` passes the entity it is writing in the same run.
|
|
219
345
|
*/
|
|
220
346
|
readonly sliceEntity?: string;
|
|
347
|
+
/** The slice's `repo.ts` as it stands on disk, or absent alongside `sliceEntity`. */
|
|
348
|
+
readonly sliceRepo?: string;
|
|
221
349
|
}
|
|
222
350
|
|
|
223
351
|
export function actionFiles(rawName: string, target: ActionOptions): readonly GeneratedFile[] {
|
|
@@ -246,7 +374,15 @@ export function actionFiles(rawName: string, target: ActionOptions): readonly Ge
|
|
|
246
374
|
// suites: the input parse is a `unit` assertion and the three projections are `contract` ones.
|
|
247
375
|
// Emitted as one file, the contract half ran under `unit` and `x test contract` answered
|
|
248
376
|
// X_TEST_NO_FILES; renaming that one file would have put the `unitTest` in the same bind.
|
|
249
|
-
{
|
|
377
|
+
{
|
|
378
|
+
path: `${dir}/${name.kebab}.test.ts`,
|
|
379
|
+
contents: actionUnitTest(name, feature, {
|
|
380
|
+
isMutator,
|
|
381
|
+
lookup,
|
|
382
|
+
storesRow: sliceTakesScaffoldRow(target, target.sliceEntity, target.sliceRepo),
|
|
383
|
+
dbModule: target.dbModule ?? PLACEHOLDER_DB_MODULE,
|
|
384
|
+
}),
|
|
385
|
+
},
|
|
250
386
|
{
|
|
251
387
|
path: `${dir}/${name.kebab}.contract.test.ts`,
|
|
252
388
|
contents: actionContractTest(name, feature, isMutator),
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// The catalog keys the admin reads for a generated entity. An entity in the app's handle IS an
|
|
2
|
+
// admin screen — `defineAdmin({ entities: adminEntitiesOf(db) })` — and that screen resolves
|
|
3
|
+
// `admin.<table>.title` and one `admin.<table>.field.<column>` per column. Nothing emitted them, so
|
|
4
|
+
// a generated list rendered `⟦admin.widgets.field.title⟧` in every header.
|
|
5
|
+
|
|
6
|
+
import { catalogJson } from './catalog-json';
|
|
7
|
+
import type { FeatureTarget } from './entity';
|
|
8
|
+
import { entityFiles } from './entity';
|
|
9
|
+
import { catalogPath, resolveLocales } from './locales';
|
|
10
|
+
import type { GeneratedFile } from './naming';
|
|
11
|
+
import { kebab, names } from './naming';
|
|
12
|
+
|
|
13
|
+
/** ` title: text({ max: 200 }),` — one column of the entity this same run writes. */
|
|
14
|
+
const COLUMN_LINE = /^ {4}([A-Za-z_$][\w$]*): [A-Za-z_$][\w$]*\(/gm;
|
|
15
|
+
|
|
16
|
+
/** `createdAt` → `Created at`, `orgId` → `Org ID`: a label a person reads, in sentence case. */
|
|
17
|
+
export const columnLabel = (property: string): string => {
|
|
18
|
+
const words = kebab(property)
|
|
19
|
+
.split('-')
|
|
20
|
+
.map((word) => (word === 'id' ? 'ID' : word));
|
|
21
|
+
const [first = '', ...rest] = words;
|
|
22
|
+
return [`${first[0]?.toUpperCase() ?? ''}${first.slice(1)}`, ...rest].join(' ');
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The entries, keyed exactly as `@ultimat3/admin` derives them: both read the entity's own name
|
|
27
|
+
* (`$name`, the table) and its property names. The columns come off the entity this run writes —
|
|
28
|
+
* never a second list typed here — so a column added to the template is a label added with it.
|
|
29
|
+
*/
|
|
30
|
+
export function adminCatalogEntries(
|
|
31
|
+
rawName: string,
|
|
32
|
+
target: FeatureTarget,
|
|
33
|
+
): Readonly<Record<string, string>> {
|
|
34
|
+
const name = names(rawName);
|
|
35
|
+
const source = String(
|
|
36
|
+
entityFiles(rawName, target).find((file) => file.path.endsWith('/entity.ts'))?.contents ?? '',
|
|
37
|
+
);
|
|
38
|
+
const entries: Record<string, string> = {
|
|
39
|
+
[`admin.${name.table}.title`]: columnLabel(name.plural),
|
|
40
|
+
};
|
|
41
|
+
for (const match of source.matchAll(COLUMN_LINE)) {
|
|
42
|
+
const property = match[1] ?? '';
|
|
43
|
+
entries[`admin.${name.table}.field.${property}`] = columnLabel(property);
|
|
44
|
+
}
|
|
45
|
+
return entries;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** One mergeable catalog file per locale — for `x g entity`, which has no other catalog entry. */
|
|
49
|
+
export const adminCatalogFiles = (
|
|
50
|
+
rawName: string,
|
|
51
|
+
target: FeatureTarget,
|
|
52
|
+
locales: readonly string[] | undefined,
|
|
53
|
+
): readonly GeneratedFile[] =>
|
|
54
|
+
resolveLocales(locales).map((locale) => ({
|
|
55
|
+
path: catalogPath(locale),
|
|
56
|
+
contents: catalogJson(adminCatalogEntries(rawName, target)),
|
|
57
|
+
merge: 'json' as const,
|
|
58
|
+
}));
|
|
@@ -4,15 +4,27 @@
|
|
|
4
4
|
// table and `guardedPage()` is the one thing that decides it. A scaffold that wrote a route
|
|
5
5
|
// declaration here would hand back the unguarded second way in that seam exists to close.
|
|
6
6
|
|
|
7
|
+
import { ADMIN_FILE } from '../admin-registration';
|
|
7
8
|
import { catalogJson } from './catalog-json';
|
|
8
9
|
import { sortedImports } from './imports';
|
|
9
10
|
import { catalogPath, resolveLocales } from './locales';
|
|
10
11
|
import type { GeneratedFile } from './naming';
|
|
11
12
|
import { camel, kebab, pascal } from './naming';
|
|
12
|
-
import { LINE_WIDTH } from './wrap';
|
|
13
|
+
import { LINE_WIDTH, wrapImport } from './wrap';
|
|
13
14
|
|
|
14
|
-
/**
|
|
15
|
-
|
|
15
|
+
/** The directory `defineAdmin` is declared in — the admin's one home, inside the app scan. */
|
|
16
|
+
const ADMIN_HOME = ADMIN_FILE.slice(0, ADMIN_FILE.lastIndexOf('/'));
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Where a page lands when the caller does not say: beside the declaration `x new` writes, under the
|
|
20
|
+
* directory the app scan imports. `apps/admin/src/pages` was outside it, and an admin kept there was
|
|
21
|
+
* never mounted (`X_ADMIN_UNSCANNED`).
|
|
22
|
+
*/
|
|
23
|
+
export const DEFAULT_ADMIN_PAGE_DIR = `${ADMIN_HOME}/pages`;
|
|
24
|
+
|
|
25
|
+
/** The import a `defineAdmin` in `ADMIN_FILE` writes for a page in `dir`, when one can be derived. */
|
|
26
|
+
const specifierFor = (dir: string, name: string): string =>
|
|
27
|
+
dir.startsWith(`${ADMIN_HOME}/`) ? `./${dir.slice(ADMIN_HOME.length + 1)}/${name}` : `./${name}`;
|
|
16
28
|
|
|
17
29
|
export interface AdminPageOptions {
|
|
18
30
|
/** The permission the page's own work needs. `admin:read` is composed in front of it. */
|
|
@@ -115,8 +127,8 @@ const pageSource = (
|
|
|
115
127
|
// so a route declaration in this file would be a second, unguarded way in.
|
|
116
128
|
//
|
|
117
129
|
// Wire it in once, and add \`navGroup\` to link it in the sidebar — this file is ${dir}/${name}.tsx,
|
|
118
|
-
// so the specifier is relative to wherever \`defineAdmin\` lives:
|
|
119
|
-
// import { ${declaration}Page } from '
|
|
130
|
+
// so the specifier is relative to wherever \`defineAdmin\` lives (${ADMIN_FILE} in a scaffolded app):
|
|
131
|
+
// import { ${declaration}Page } from '${specifierFor(dir, name)}';
|
|
120
132
|
// defineAdmin({ …, pages: […, ${declaration}Page] })
|
|
121
133
|
//
|
|
122
134
|
// The permission below is declared here AND has to be decided: an authz map built from a fixed
|
|
@@ -146,11 +158,27 @@ export const ${declaration}Page: AdminCustomPage = {
|
|
|
146
158
|
|
|
147
159
|
const pageTest = (name: string, permission: string): string => {
|
|
148
160
|
const declaration = camel(name);
|
|
161
|
+
const Name = pascal(name);
|
|
162
|
+
const url = `http://localhost/admin/${name}`;
|
|
149
163
|
return `// The ${name} admin page is guarded and owns no route of its own — the two facts that separate an
|
|
150
|
-
// admin screen from a page, and the two an edit here is most likely to break
|
|
164
|
+
// admin screen from a page, and the two an edit here is most likely to break — and it renders
|
|
165
|
+
// from the request it is handed.
|
|
166
|
+
import { adminTestCtx } from '@ultimat3/admin';
|
|
151
167
|
import { knownPermissions } from '@ultimat3/policy';
|
|
152
|
-
import { expect, unitTest } from '@ultimat3/testing';
|
|
153
|
-
|
|
168
|
+
import { expect, renderView, unitTest } from '@ultimat3/testing';
|
|
169
|
+
${wrapImport([`${Name}Page`, `${declaration}Page`], `./${name}`)}
|
|
170
|
+
|
|
171
|
+
// The component, called the way the admin's frame calls it once the guard has passed: a ctx for
|
|
172
|
+
// an actor holding the page's own permission, the route params, and the request URL.
|
|
173
|
+
unitTest('the ${name} admin page renders', async () => {
|
|
174
|
+
const view = await renderView(${Name}Page, {
|
|
175
|
+
ctx: adminTestCtx({ granted: ['admin:read', '${permission}'] }),
|
|
176
|
+
params: {},
|
|
177
|
+
url: '${url}',
|
|
178
|
+
});
|
|
179
|
+
expect(view.html).toContain('<h1>');
|
|
180
|
+
expect(view.text).toContain('${url}');
|
|
181
|
+
});
|
|
154
182
|
|
|
155
183
|
// Both facts the frame reads off the declaration, so both are decidable without a request: a path
|
|
156
184
|
// that is not rooted is X_ADMIN_PAGE_PATH_INVALID, and no permission at all is
|