@ultimat3/cli 23.0.0 → 25.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 +28 -13
- package/README.md +32 -7
- package/package.json +29 -29
- package/src/admin-catalog-keys.ts +2 -2
- package/src/affected.ts +30 -36
- package/src/api-registration.ts +89 -0
- package/src/app-artifacts.ts +1 -1
- package/src/app-auth.ts +4 -27
- package/src/app-boundaries.ts +17 -4
- package/src/app-config-load.ts +51 -0
- package/src/app-env.ts +26 -25
- package/src/app-load.ts +38 -14
- package/src/app-manifest.ts +4 -10
- package/src/app-mcp.ts +31 -46
- package/src/app-name.ts +52 -0
- package/src/app-permissions-borrowed.ts +123 -0
- package/src/app-permissions-site.ts +48 -0
- package/src/app-permissions.ts +7 -17
- package/src/app-root.ts +9 -5
- package/src/bin.ts +3 -7
- package/src/browser-launcher.ts +4 -2
- package/src/budgets-charged.ts +60 -0
- package/src/budgets-scan.ts +76 -0
- package/src/budgets-unmeasured.ts +91 -0
- package/src/budgets.ts +117 -154
- package/src/cdp-shot-driver.ts +20 -1
- package/src/cdp-shot-watch.ts +35 -31
- package/src/ci-log.ts +0 -0
- package/src/cmd-auth-spec.ts +16 -0
- package/src/cmd-auth.ts +65 -0
- package/src/cmd-build-spec.ts +7 -1
- package/src/cmd-build.ts +151 -22
- package/src/cmd-db-backfill.ts +26 -13
- package/src/cmd-db-branch.ts +2 -2
- package/src/cmd-db.ts +10 -6
- package/src/cmd-deploy-compose.ts +94 -0
- package/src/cmd-deploy-drain.ts +48 -0
- package/src/cmd-deploy-helm.ts +7 -6
- package/src/cmd-deploy-start-first.ts +178 -0
- package/src/cmd-deploy.ts +158 -36
- package/src/cmd-dev.ts +18 -356
- package/src/cmd-doctor-spec.ts +2 -9
- package/src/cmd-doctor.ts +57 -80
- package/src/cmd-env.ts +55 -16
- package/src/cmd-generate.ts +89 -32
- package/src/cmd-help.ts +19 -6
- package/src/cmd-i18n.ts +21 -17
- package/src/cmd-jobs-spec.ts +7 -22
- package/src/cmd-jobs.ts +33 -140
- package/src/cmd-manifest-spec.ts +1 -1
- package/src/cmd-manifest.ts +6 -25
- package/src/cmd-mcp.ts +71 -22
- package/src/cmd-new.ts +36 -8
- package/src/cmd-planned.ts +24 -8
- package/src/cmd-routes.ts +15 -5
- package/src/cmd-secrets.ts +47 -6
- package/src/cmd-shot-matrix.ts +22 -7
- package/src/cmd-shot-spec.ts +10 -2
- package/src/cmd-shot.ts +46 -41
- package/src/cmd-tasks.ts +2 -2
- package/src/cmd-test-spec.ts +1 -7
- package/src/cmd-test.ts +14 -30
- package/src/cmd-verify.ts +4 -4
- package/src/coverage-lcov.ts +4 -2
- package/src/db-backfill.ts +2 -2
- package/src/db-replay-engine.ts +4 -10
- package/src/db-subscribes.ts +27 -16
- package/src/dev-boot.ts +290 -0
- package/src/dev-child-watch.ts +63 -12
- package/src/dev-dashboard-guard.ts +76 -0
- package/src/dev-dashboard.ts +56 -30
- package/src/dev-lock.ts +100 -31
- package/src/dev-rebuild.ts +80 -0
- package/src/dev-restart-retire.ts +46 -0
- package/src/dev-route-table.ts +30 -8
- package/src/dev-supervisor.ts +114 -22
- package/src/dev-traces.ts +53 -20
- package/src/dispatch.ts +17 -13
- package/src/doctor-app-url.ts +39 -0
- package/src/doctor-auth.ts +95 -0
- package/src/doctor-offline.ts +6 -5
- package/src/document-options.ts +7 -0
- package/src/drift-replica-identity.ts +42 -0
- package/src/drift.ts +29 -6
- package/src/error-catalog.ts +6 -9
- package/src/error-codes.ts +29 -3
- package/src/error-page-csp.ts +14 -15
- package/src/error-unthrown.ts +29 -11
- package/src/errors.ts +54 -83
- package/src/exec.ts +8 -0
- package/src/favicon.ts +2 -2
- package/src/fix-command.ts +9 -1
- package/src/framework-env.ts +148 -0
- package/src/framework-schema-apply.ts +78 -0
- package/src/framework-schema-stamp.ts +96 -0
- package/src/framework-schema.ts +25 -3
- package/src/generate-catalog-locales.ts +79 -0
- package/src/generate-files.ts +4 -3
- package/src/generate-kinds.ts +18 -0
- package/src/generate-plural.ts +50 -0
- package/src/generate-shadow.ts +95 -0
- package/src/generate-write.ts +47 -13
- package/src/gh-target.ts +12 -7
- package/src/hold.ts +56 -1
- package/src/i18n-audit.ts +25 -9
- package/src/i18n-index.ts +5 -6
- package/src/i18n-registration.ts +7 -9
- package/src/import-scan.ts +28 -3
- package/src/index.ts +2 -6
- package/src/island-bundle.ts +57 -79
- package/src/island-capture.ts +1 -1
- package/src/island-duplicate-refusal.ts +57 -0
- package/src/island-duplicates.ts +221 -0
- package/src/island-entry-missing.ts +64 -0
- package/src/island-harness-route.ts +20 -4
- package/src/island-harness-script.ts +23 -2
- package/src/island-identity.ts +17 -14
- package/src/island-package-dedupe.ts +150 -0
- package/src/island-realtime.ts +36 -16
- package/src/island-routes.ts +2 -2
- package/src/island-runtime.ts +241 -0
- package/src/island-sources.ts +105 -0
- package/src/island-states-load.ts +6 -0
- package/src/island-store.ts +90 -6
- package/src/jobs-json.ts +0 -26
- package/src/jobs-report.ts +36 -20
- package/src/local-cli-handoff.ts +90 -0
- package/src/loopback-host.ts +13 -0
- package/src/manifest-staleness.ts +62 -0
- package/src/mcp-errors.ts +20 -4
- package/src/mcp-host.ts +22 -14
- package/src/mcp-test-run.ts +38 -0
- package/src/mcp-ui-inspect.ts +3 -2
- package/src/mcp-ui-interact.ts +3 -2
- package/src/mcp-ui.ts +8 -7
- package/src/measure-scope.ts +15 -10
- package/src/messages.ts +29 -16
- package/src/metrics-endpoint.ts +28 -3
- package/src/migration-extensions.ts +1 -1
- package/src/otlp-export.ts +2 -2
- package/src/output.ts +24 -0
- package/src/page-navigation.ts +7 -23
- package/src/page-speculation.ts +19 -31
- package/src/parse.ts +0 -8
- package/src/permission-grants.ts +3 -3
- package/src/posix-path.ts +30 -0
- package/src/prerender-locales.ts +6 -4
- package/src/prerender-out.ts +34 -7
- package/src/prerender-report.ts +58 -0
- package/src/prerender-served.ts +52 -0
- package/src/prerender.ts +71 -103
- package/src/pwa-artifacts.ts +33 -94
- package/src/pwa-manifest-locales.ts +9 -9
- package/src/registry.ts +2 -0
- package/src/retry-memo.ts +1 -1
- package/src/revalidated-response.ts +4 -3
- package/src/role-realtime.ts +7 -1
- package/src/role-replicator.ts +124 -29
- package/src/role-start.ts +69 -85
- package/src/role-sync.ts +119 -25
- package/src/root-env.ts +157 -28
- package/src/route-islands.ts +69 -0
- package/src/runtime-admin.ts +4 -1
- package/src/runtime-assets.ts +26 -9
- package/src/runtime-bindings.ts +27 -7
- package/src/runtime-cache.ts +46 -55
- package/src/runtime-hooks.ts +2 -1
- package/src/runtime-idempotency-scope.ts +23 -0
- package/src/runtime-isr-outcome.ts +100 -0
- package/src/runtime-isr.ts +36 -0
- package/src/runtime-jobs.ts +76 -41
- package/src/runtime-live-feed.ts +1 -1
- package/src/runtime-mail.ts +26 -0
- package/src/runtime-mfa-warning.ts +29 -0
- package/src/runtime-notify-retention.ts +10 -46
- package/src/runtime-overrides.ts +4 -4
- package/src/runtime-purge.ts +38 -16
- package/src/runtime-queue.ts +43 -22
- package/src/runtime-realtime.ts +11 -45
- package/src/runtime-render.ts +136 -114
- package/src/runtime-replica.ts +3 -8
- package/src/runtime-services.ts +49 -26
- package/src/runtime-storage-upload.ts +2 -2
- package/src/runtime-storage.ts +13 -8
- package/src/schema-diff.ts +16 -0
- package/src/schema-drift.ts +45 -15
- package/src/secrets-rotation.ts +8 -9
- package/src/seo-routes.ts +80 -9
- package/src/serve-boot.ts +51 -12
- package/src/serve-drain.ts +11 -33
- package/src/serve-env.ts +9 -6
- package/src/serve-retire.ts +95 -0
- package/src/serve-web.ts +31 -15
- package/src/serve.ts +27 -10
- package/src/server-barrels.ts +6 -3
- package/src/shot-browser.ts +1 -1
- package/src/shot-locale.ts +6 -33
- package/src/shot-settle.ts +3 -3
- package/src/shot-theme.ts +1 -1
- package/src/shot-viewport.ts +152 -0
- package/src/signal-shred.ts +26 -12
- package/src/site-asset-routes.ts +2 -2
- package/src/site-assets.ts +3 -5
- package/src/site-config.ts +9 -48
- package/src/site-seo.ts +7 -5
- package/src/sitemap-lastmod.ts +5 -0
- package/src/solid-loader.ts +12 -3
- package/src/source-files.ts +19 -3
- package/src/static-report.ts +5 -0
- package/src/stored-object-headers.ts +43 -0
- package/src/style-routes.ts +2 -2
- package/src/sw-artifacts.ts +8 -8
- package/src/sw-precache-plan.ts +34 -4
- package/src/sync-authenticator.ts +3 -3
- package/src/sync-url.ts +3 -3
- package/src/tasks-facts.ts +3 -29
- package/src/templates/action.ts +7 -4
- package/src/templates/backfill.ts +4 -4
- package/src/templates/entity.ts +2 -2
- package/src/templates/github/ci.yml.ts +28 -18
- package/src/templates/github/image.yml.ts +92 -0
- package/src/templates/github/runner.ts +18 -0
- package/src/templates/guard-bare-error.ts +16 -2
- package/src/templates/guard-unzoned-date.ts +56 -6
- package/src/templates/index.ts +1 -1
- package/src/templates/job-body-test.ts +2 -2
- package/src/templates/job.ts +6 -6
- package/src/templates/naming.ts +28 -0
- package/src/templates/query.ts +9 -8
- package/src/templates/resource-page.ts +9 -7
- package/src/templates/resource-service.ts +2 -2
- package/src/templates/route.ts +26 -14
- package/src/templates/scaffold-api.ts +2 -3
- package/src/templates/scaffold-app.ts +8 -3
- package/src/templates/scaffold-auth.ts +205 -70
- package/src/templates/scaffold-bin.ts +122 -0
- package/src/templates/scaffold-claude-commands.ts +11 -11
- package/src/templates/scaffold-container-compose.ts +10 -1
- package/src/templates/scaffold-container.ts +16 -8
- package/src/templates/scaffold-dashboard-bare.ts +8 -4
- package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
- package/src/templates/scaffold-dashboard-example.ts +112 -172
- package/src/templates/scaffold-dashboard-shared.ts +62 -1
- package/src/templates/scaffold-dashboard.ts +9 -5
- package/src/templates/scaffold-db-client.ts +4 -2
- package/src/templates/scaffold-db-package.ts +2 -2
- package/src/templates/scaffold-docs.ts +20 -77
- package/src/templates/scaffold-domain-package.ts +0 -4
- package/src/templates/scaffold-entries.ts +4 -3
- package/src/templates/scaffold-env.ts +6 -3
- package/src/templates/scaffold-gitattributes.ts +59 -0
- package/src/templates/scaffold-helm-fences.ts +76 -0
- package/src/templates/scaffold-helm-templates.ts +100 -19
- package/src/templates/scaffold-helm.ts +62 -5
- package/src/templates/scaffold-http.ts +1 -1
- package/src/templates/scaffold-i18n.ts +15 -2
- package/src/templates/scaffold-icon.ts +2 -2
- package/src/templates/scaffold-repo.ts +27 -16
- package/src/templates/scaffold-shell.ts +55 -37
- package/src/test-counts.ts +2 -6
- package/src/test-ignores.ts +25 -0
- package/src/test-only-shipped.ts +193 -0
- package/src/test-passes.ts +0 -15
- package/src/test-select.ts +7 -15
- package/src/test-shards.ts +40 -95
- package/src/test-workers.ts +26 -4
- package/src/theme-boot.ts +8 -33
- package/src/theme-brand.ts +66 -0
- package/src/trusted-hops-error.ts +17 -0
- package/src/trusted-hops.ts +49 -0
- package/src/unscanned-admin.ts +2 -2
- package/src/unthrown-codes.ts +26 -0
- package/src/verify-checks.ts +29 -48
- package/src/verify-coverage-run.ts +3 -2
- package/src/verify-deadline.ts +207 -33
- package/src/verify-merge.ts +108 -43
- package/src/verify-part-step.ts +98 -0
- package/src/verify-role-load-probe.ts +12 -2
- package/src/verify-role-load.ts +9 -4
- package/src/verify-run.ts +32 -8
- package/src/verify-shard.ts +13 -2
- package/src/verify-stalled.ts +152 -0
- package/src/verify-step.ts +2 -0
- package/src/verify-test-run.ts +3 -2
- package/src/verify-tests.ts +5 -3
- package/src/worker-bundle.ts +2 -2
- package/src/workspace-checks.ts +53 -7
- package/src/workspace-graph.ts +3 -2
- package/src/write-line.ts +82 -7
- package/src/browser-launcher-fake-html.ts +0 -80
- package/src/browser-launcher-fake.ts +0 -168
- package/src/build-errors.ts +0 -54
- package/src/jobs-drain.ts +0 -163
- package/src/scaffold-typecheck.ts +0 -227
- package/src/thrown-by.ts +0 -24
- package/src/verify-errors.ts +0 -58
|
@@ -10,7 +10,7 @@ import { SECRETS_KEY_FILE } from '@ultimat3/core';
|
|
|
10
10
|
import { PREBUILT_COMMAND } from '../serve-prebuilt-paths';
|
|
11
11
|
import type { GeneratedFile, NameSet } from './naming';
|
|
12
12
|
import { composeProdFile, PROD_ENV_FILE } from './scaffold-container-compose';
|
|
13
|
-
import { helmFiles } from './scaffold-helm';
|
|
13
|
+
import { helmFiles, RUNTIME_UID } from './scaffold-helm';
|
|
14
14
|
|
|
15
15
|
const dockerfile = (
|
|
16
16
|
app: NameSet,
|
|
@@ -30,14 +30,18 @@ const dockerfile = (
|
|
|
30
30
|
# install refuses. \`bunfig.toml\` survives because its \`[install]\` section is input to the install,
|
|
31
31
|
# and so does a \`.tgz\`: a \`file:\` tarball dependency (a vendored or private package) is resolved
|
|
32
32
|
# from the file itself, and without it the frozen install cannot find what the lockfile names.
|
|
33
|
-
|
|
33
|
+
# The tag names the series for the reader; the digest is what builds — a tag is a pointer its
|
|
34
|
+
# publisher can move. Dependabot's docker ecosystem moves the digest together with the tag.
|
|
35
|
+
# The digest is \`oven/bun:1.4.2-alpine\`'s, the patch the framework that wrote this file was
|
|
36
|
+
# tested on — the same patch as \`engines.bun\` in package.json.
|
|
37
|
+
FROM oven/bun:1.4-alpine@sha256:d888c0ae6c86d7866ff10c5aafdd9077b36aee6455b33dd270fb93c0dd5cef6f AS manifests
|
|
34
38
|
WORKDIR /app
|
|
35
39
|
COPY . .
|
|
36
40
|
RUN find . ! -type d ! -name package.json ! -name bunfig.toml ! -name '*.tgz' -delete \\
|
|
37
41
|
&& find . -type d -empty -delete
|
|
38
42
|
|
|
39
43
|
# ---------- deps: runtime dependencies only, cached on the lockfile and the manifests ----------
|
|
40
|
-
FROM oven/bun:1.4-alpine AS deps
|
|
44
|
+
FROM oven/bun:1.4-alpine@sha256:d888c0ae6c86d7866ff10c5aafdd9077b36aee6455b33dd270fb93c0dd5cef6f AS deps
|
|
41
45
|
WORKDIR /app
|
|
42
46
|
COPY --from=manifests /app ./
|
|
43
47
|
COPY bun.lock ./
|
|
@@ -90,9 +94,11 @@ HEALTHCHECK --interval=10s --timeout=3s --start-period=30s --retries=3 CMD \\
|
|
|
90
94
|
# \`.x/\` is where any binding that is still embedded keeps its state — the local storage disk, and
|
|
91
95
|
# PGlite if DATABASE_URL is unset. Owned by the runtime user, because /app is not: the alternative
|
|
92
96
|
# is a non-root process failing at boot on a directory it is the only one that ever writes.
|
|
93
|
-
|
|
97
|
+
# Numeric, and the same number docker/helm/values.yaml runs every pod as: ${RUNTIME_UID} is the base
|
|
98
|
+
# image's \`bun\` user, and a numeric USER is one a kubelet can check against runAsNonRoot.
|
|
99
|
+
RUN mkdir -p /app/.x && chown -R ${RUNTIME_UID}:${RUNTIME_UID} /app/.x
|
|
94
100
|
|
|
95
|
-
USER
|
|
101
|
+
USER ${RUNTIME_UID}:${RUNTIME_UID}
|
|
96
102
|
# apps/web/server.ts reads ROLE and PORT and nothing else. \`migrate\` applies the migrations and
|
|
97
103
|
# exits; every other role serves until SIGTERM.
|
|
98
104
|
ENTRYPOINT ["bun", "apps/web/server.ts"]
|
|
@@ -188,9 +194,11 @@ x build --target static --out dist/static # one HTML file per \`render: 'stati
|
|
|
188
194
|
x build --target binary --out dist/app # a single executable, no Bun install needed
|
|
189
195
|
\`\`\`
|
|
190
196
|
|
|
191
|
-
The binary bundles the framework
|
|
192
|
-
|
|
193
|
-
|
|
197
|
+
The binary bundles neither the framework nor the app: it is a launcher. \`@ultimat3/*\` stays
|
|
198
|
+
external and loads from the app's own \`node_modules\`, the app's \`package.json\` and
|
|
199
|
+
\`tsconfig.json\` are read at run time, and the registries are filled by scanning
|
|
200
|
+
\`apps/*/{site,app,api,shared}\` at boot. So it must be **started from the app root**, with the
|
|
201
|
+
source tree and an installed \`node_modules\` beside it. The image is the self-contained artifact.
|
|
194
202
|
|
|
195
203
|
## A PaaS (Heroku, Render, Fly, Railway, Cloud Run, App Runner…)
|
|
196
204
|
|
|
@@ -224,10 +224,14 @@ unitTest('the tiles are the framework registries, counted', async () => {
|
|
|
224
224
|
unitTest('the table is the route table: path, surface and render mode per route', async () => {
|
|
225
225
|
const t = useT();
|
|
226
226
|
const view = await renderRoute(page, { url });
|
|
227
|
-
expect(view.html.match(/<tr
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
)
|
|
227
|
+
expect(view.html.match(/<tr[^>]*\\sdata-row=/g)).toHaveLength(1);
|
|
228
|
+
// Each cell ends in its value; on a phone a card label rides in front of it, hidden from AT.
|
|
229
|
+
const row = view.html.match(/<tr[^>]*data-row="\\/dashboard">([\\s\\S]*?)<\\/tr>/)?.[1] ?? '';
|
|
230
|
+
expect([...row.matchAll(/([^>]*)<\\/td>/g)].map((cell) => cell[1])).toEqual([
|
|
231
|
+
'/dashboard',
|
|
232
|
+
'app',
|
|
233
|
+
'ssr',
|
|
234
|
+
]);
|
|
231
235
|
expect(view.text).toContain(t('app.dashboard.routesCaption'));
|
|
232
236
|
expect(view.meta.title).toBe(t('app.dashboard.title'));
|
|
233
237
|
// The sidebar knows where it is.
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// The tests the `--example` dashboard ships beside its page and its view module — split from
|
|
2
|
+
// `scaffold-dashboard-example.ts` to keep each file under the ceiling. Emitted, never run here: the
|
|
3
|
+
// generated app's own `bun test` runs them, and the scaffold gate holds that run to green.
|
|
4
|
+
|
|
5
|
+
import { sortedImports } from './imports';
|
|
6
|
+
import type { NameSet } from './naming';
|
|
7
|
+
|
|
8
|
+
export const exampleViewTest =
|
|
9
|
+
(): string => `// The numbers the dashboard shows, pinned against a fixed clock. The interesting cases are the
|
|
10
|
+
// window edges: a post from eight days ago is in the prior week, not this one, and a day with no
|
|
11
|
+
// posts is still a bar.
|
|
12
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
13
|
+
import {
|
|
14
|
+
bucketByDay,
|
|
15
|
+
formatCount,
|
|
16
|
+
type PostRow,
|
|
17
|
+
postStats,
|
|
18
|
+
priceCeilings,
|
|
19
|
+
priceMix,
|
|
20
|
+
toPostRow,
|
|
21
|
+
} from './dashboard-view';
|
|
22
|
+
|
|
23
|
+
const NOW = new Date('2026-03-15T12:00:00.000Z');
|
|
24
|
+
const DAY_MS = 86_400_000;
|
|
25
|
+
|
|
26
|
+
const row = (id: string, daysAgo: number, minor = 0, currency = 'USD'): PostRow => ({
|
|
27
|
+
id,
|
|
28
|
+
title: \`post \${id}\`,
|
|
29
|
+
price: { minor, currency },
|
|
30
|
+
createdAt: new Date(NOW.getTime() - daysAgo * DAY_MS).toISOString(),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const rows = [row('a', 1), row('b', 3), row('c', 8)];
|
|
34
|
+
|
|
35
|
+
unitTest('postStats counts the week, compares it with the prior one, and counts today', () => {
|
|
36
|
+
const stats = postStats(rows, NOW);
|
|
37
|
+
expect(stats.total).toBe(3);
|
|
38
|
+
expect(stats.lastWeek).toBe(2);
|
|
39
|
+
// Two this week against one the week before: +100%, and the chip says so.
|
|
40
|
+
expect(stats.delta).toEqual({ text: '+100%', trend: 'up' });
|
|
41
|
+
// 'a' is yesterday and nothing was created on 03-15 itself.
|
|
42
|
+
expect(stats.today).toBe(0);
|
|
43
|
+
expect(postStats([...rows, row('d', 0.25)], NOW).today).toBe(1);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
unitTest('a zero baseline yields no delta, and no rows count nothing', () => {
|
|
47
|
+
expect(postStats([row('a', 1)], NOW).delta).toBeUndefined();
|
|
48
|
+
expect(postStats([], NOW)).toEqual({ total: 0, lastWeek: 0, delta: undefined, today: 0 });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
unitTest('bucketByDay is one bar per day, oldest first, quiet days included', () => {
|
|
52
|
+
const points = bucketByDay(rows, NOW, 14);
|
|
53
|
+
expect(points).toHaveLength(14);
|
|
54
|
+
expect(points.at(-1)?.key).toBe('03-15');
|
|
55
|
+
expect(points[0]?.key).toBe('03-02');
|
|
56
|
+
expect(points.reduce((sum, point) => sum + point.value, 0)).toBe(3);
|
|
57
|
+
// Eight days ago is 03-07: inside a 14-day window, so the bar is there.
|
|
58
|
+
expect(points.find((point) => point.key === '03-07')?.value).toBe(1);
|
|
59
|
+
expect(bucketByDay([], NOW, 3).map((point) => point.value)).toEqual([0, 0, 0]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
unitTest('toPostRow serialises a Date, passes ISO text through, and keeps the price whole', () => {
|
|
63
|
+
const at = new Date('2026-01-02T03:04:05.000Z');
|
|
64
|
+
const price = { minor: 1900, currency: 'USD' };
|
|
65
|
+
expect(toPostRow({ id: 'x', title: 't', price, createdAt: at }).createdAt).toBe(at.toISOString());
|
|
66
|
+
expect(toPostRow({ id: 'x', title: 't', price, createdAt: '2026-01-01' })).toEqual({
|
|
67
|
+
id: 'x',
|
|
68
|
+
title: 't',
|
|
69
|
+
price,
|
|
70
|
+
createdAt: '2026-01-01',
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
unitTest('priceMix splits free from paid, and priceCeilings is the dearest per currency', () => {
|
|
75
|
+
const priced = [row('a', 1, 0), row('b', 1, 1900), row('c', 1, 500), row('d', 1, 300, 'JPY')];
|
|
76
|
+
expect(priceMix(priced)).toEqual({ free: 1, paid: 3 });
|
|
77
|
+
expect(priceMix([])).toEqual({ free: 0, paid: 0 });
|
|
78
|
+
// 300 yen is never measured against 1,900 cents: each currency has its own ceiling.
|
|
79
|
+
expect([...priceCeilings(priced)]).toEqual([
|
|
80
|
+
['USD', 1900],
|
|
81
|
+
['JPY', 300],
|
|
82
|
+
]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
unitTest('formatCount follows the locale', () => {
|
|
86
|
+
expect(formatCount(1234, 'en')).toBe('1,234');
|
|
87
|
+
expect(formatCount(1234, 'de')).toBe('1.234');
|
|
88
|
+
});
|
|
89
|
+
`;
|
|
90
|
+
|
|
91
|
+
export const examplePageTest = (
|
|
92
|
+
app: NameSet,
|
|
93
|
+
): string => `// The dashboard, rendered as a request renders it: \`load\` reads the actor's org through the
|
|
94
|
+
// slice's query — the in-memory driver under test — and the page turns the rows into tiles, a chart
|
|
95
|
+
// and a table. Losing a policy is the other regression worth a test: the page's own, and the read's
|
|
96
|
+
// — \`dashboard:read\` opens the page, and only \`post:read\` shows its posts.
|
|
97
|
+
${sortedImports([
|
|
98
|
+
`import { driver } from '@${app.kebab}/db';`,
|
|
99
|
+
`import { useT } from '@${app.kebab}/i18n';`,
|
|
100
|
+
"import { ctxOf, frozenClock, isUltimateError, runWithContext } from '@ultimat3/core';",
|
|
101
|
+
"import { testActor } from '@ultimat3/policy';",
|
|
102
|
+
"import { dbUnavailable } from '@ultimat3/db';",
|
|
103
|
+
"import { afterEach, expect, renderRoute, unitTest } from '@ultimat3/testing';",
|
|
104
|
+
])}
|
|
105
|
+
// The app's API, as boot loads it: \`defineApi\` is what names the query the page reads, and a
|
|
106
|
+
// query with no name is refused (X_QUERY_UNREGISTERED) before it reads a row.
|
|
107
|
+
import '../../api';
|
|
108
|
+
import { DEMO_ORG_ID } from '../../shared/demo-org';
|
|
109
|
+
import * as repo from '../post/repo';
|
|
110
|
+
import * as page from './page';
|
|
111
|
+
|
|
112
|
+
const url = 'https://example.test/dashboard';
|
|
113
|
+
// The read grant, a direct one: what a \`member\` holds in apps/web/shared/roles.ts.
|
|
114
|
+
const read = 'post:read';
|
|
115
|
+
const viewer = testActor('viewer', { orgId: DEMO_ORG_ID, permissions: [read] }).actor;
|
|
116
|
+
const elsewhere = '00000000-0000-4000-8000-000000000009';
|
|
117
|
+
const stranger = testActor('stranger', { orgId: elsewhere, permissions: [read] }).actor;
|
|
118
|
+
|
|
119
|
+
const DAY_MS = 86_400_000;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* One post in the viewer's org, written the way the app writes one — \`daysAgo\` days before now.
|
|
123
|
+
* The request's clock is what stamps \`createdAt\`, so the test chooses the instant.
|
|
124
|
+
*/
|
|
125
|
+
const post = (title: string, daysAgo: number, minor = 0) => {
|
|
126
|
+
const clock = frozenClock(new Date(Date.now() - daysAgo * DAY_MS));
|
|
127
|
+
const draft = { orgId: DEMO_ORG_ID, title, price: { minor, currency: 'USD' } };
|
|
128
|
+
return runWithContext(ctxOf({ actor: viewer, clock }), () => repo.insert(draft));
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/** The figure a tile shows, read off the markup by the \`stat\` the page gave it. */
|
|
132
|
+
const stat = (html: string, name: string): string | undefined =>
|
|
133
|
+
html.match(new RegExp(\`data-stat="\${name}">([^<]*)<\`))?.[1];
|
|
134
|
+
|
|
135
|
+
// One store per process: without this, one test's rows are the next test's fixtures.
|
|
136
|
+
afterEach(() => {
|
|
137
|
+
driver.reset?.();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
unitTest('the dashboard is gated, renders per request, and hydrates one island', async () => {
|
|
141
|
+
const view = await renderRoute(page, { url, actor: viewer });
|
|
142
|
+
expect(page.config.render).toBe('ssr');
|
|
143
|
+
expect(page.config.policy?.permission).toBe('dashboard:read');
|
|
144
|
+
expect(page.config.offline).toBe('runtime');
|
|
145
|
+
expect(page.config.budget.js).toBe('60kb');
|
|
146
|
+
// What the render emitted, beside what the config declares: the theme toggle, and only it.
|
|
147
|
+
expect(view.islands.map((island) => [island.moduleId, island.strategy])).toEqual([
|
|
148
|
+
['shared-theme-toggle', 'visible'],
|
|
149
|
+
]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
unitTest('it counts and lists the posts of the org that is looking', async () => {
|
|
153
|
+
await post('Last month', 30);
|
|
154
|
+
await post('This week', 3, 1900);
|
|
155
|
+
await post('Today', 0);
|
|
156
|
+
const view = await renderRoute(page, { url, actor: viewer });
|
|
157
|
+
// Newest first, as the query orders them — and each tile counts its own window.
|
|
158
|
+
expect(view.data.rows.map((row) => row.title)).toEqual(['Today', 'This week', 'Last month']);
|
|
159
|
+
expect(stat(view.html, 'posts-total')).toBe('3');
|
|
160
|
+
expect(stat(view.html, 'posts-week')).toBe('2');
|
|
161
|
+
expect(stat(view.html, 'posts-today')).toBe('1');
|
|
162
|
+
expect(stat(view.html, 'posts-paid')).toBe('1');
|
|
163
|
+
// One table row per post, each price with its in-cell bar.
|
|
164
|
+
expect(view.html.match(/<tr[^>]*\\sdata-row=/g)).toHaveLength(3);
|
|
165
|
+
expect(view.html.match(/data-inline-bar-value/g)).toHaveLength(3);
|
|
166
|
+
expect(view.text).toContain('$19.00');
|
|
167
|
+
// The trend is one filled series; the ring draws free and paid; the dial is a meter.
|
|
168
|
+
expect(view.html.match(/data-area="true"/g)).toHaveLength(1);
|
|
169
|
+
expect(view.html.match(/data-segment="/g)).toHaveLength(2);
|
|
170
|
+
expect(view.html).toContain('role="meter"');
|
|
171
|
+
expect(view.text).toContain('This week');
|
|
172
|
+
// The sidebar knows where it is.
|
|
173
|
+
expect(view.html).toMatch(/<a href="\\/dashboard" aria-current="page"/);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
unitTest('another org sees none of them: the page says so instead of an empty table', async () => {
|
|
177
|
+
const t = useT();
|
|
178
|
+
await post('Not theirs', 0);
|
|
179
|
+
const view = await renderRoute(page, { url, actor: stranger });
|
|
180
|
+
expect(view.data.rows).toEqual([]);
|
|
181
|
+
expect(stat(view.html, 'posts-total')).toBe('0');
|
|
182
|
+
expect(view.html).not.toMatch(/<tr[^>]*\\sdata-row=/);
|
|
183
|
+
expect(view.text).toContain(t('app.dashboard.emptyTitle'));
|
|
184
|
+
expect(view.meta.title).toBe(t('app.dashboard.title'));
|
|
185
|
+
expect(view.meta.description).toBe(t('app.dashboard.description'));
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
unitTest('a viewer the post read refuses is refused, never shown the org’s posts', async () => {
|
|
189
|
+
await post('Not for them', 0);
|
|
190
|
+
// In the org, onto the page, without \`post:read\`: the query's policy decides, not the page's.
|
|
191
|
+
const outsider = testActor('outsider', { orgId: DEMO_ORG_ID }).actor;
|
|
192
|
+
const rendering = renderRoute(page, { url, actor: outsider });
|
|
193
|
+
const refused = await rendering.catch((error: unknown) => error);
|
|
194
|
+
expect(isUltimateError(refused) && refused.code).toBe('X_FORBIDDEN');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
unitTest('the build measures the empty page: no database is not a failure there', async () => {
|
|
198
|
+
const measured = await page.dashboardData(() => Promise.reject(dbUnavailable('no database')));
|
|
199
|
+
// The clock is frozen under test, so the instant the windows are cut at is this one.
|
|
200
|
+
expect(measured).toEqual({ rows: [], now: new Date().toISOString() });
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
unitTest('any other failure of the read propagates', async () => {
|
|
204
|
+
const cause = new TypeError('the read broke');
|
|
205
|
+
const read = (): Promise<never> => Promise.reject(cause);
|
|
206
|
+
expect(await page.dashboardData(read).catch((error: unknown) => error)).toBe(cause);
|
|
207
|
+
});
|
|
208
|
+
`;
|