@ultimat3/cli 24.0.0 → 25.1.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 +15 -8
- package/README.md +30 -5
- package/package.json +29 -29
- package/src/admin-catalog-keys.ts +2 -2
- package/src/affected.ts +3 -1
- package/src/app-boundaries.ts +17 -4
- package/src/app-config-load.ts +3 -3
- package/src/app-env.ts +21 -14
- package/src/app-load.ts +38 -14
- package/src/app-manifest.ts +2 -2
- package/src/app-mcp.ts +25 -27
- package/src/app-name.ts +52 -0
- package/src/app-permissions-borrowed.ts +2 -8
- package/src/app-permissions-site.ts +31 -7
- 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 +115 -121
- package/src/cdp-shot-driver.ts +17 -0
- package/src/cdp-shot-watch.ts +35 -31
- package/src/ci-log.ts +0 -0
- package/src/cmd-auth.ts +2 -2
- package/src/cmd-build-spec.ts +7 -1
- package/src/cmd-build.ts +89 -9
- package/src/cmd-db-backfill.ts +1 -1
- package/src/cmd-db-branch.ts +2 -2
- package/src/cmd-db.ts +5 -2
- package/src/cmd-deploy-compose.ts +94 -0
- package/src/cmd-deploy-drain.ts +48 -0
- package/src/cmd-deploy-start-first.ts +178 -0
- package/src/cmd-deploy.ts +158 -36
- package/src/cmd-dev.ts +18 -357
- package/src/cmd-doctor.ts +16 -66
- package/src/cmd-env.ts +29 -14
- package/src/cmd-generate.ts +12 -2
- package/src/cmd-i18n.ts +11 -8
- package/src/cmd-jobs-spec.ts +7 -22
- package/src/cmd-jobs.ts +18 -137
- package/src/cmd-mcp.ts +25 -7
- package/src/cmd-new.ts +36 -8
- package/src/cmd-planned.ts +9 -0
- package/src/cmd-routes.ts +3 -3
- package/src/cmd-shot-matrix.ts +21 -4
- package/src/cmd-shot-spec.ts +10 -2
- package/src/cmd-shot.ts +35 -39
- package/src/cmd-test.ts +1 -1
- 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 +19 -16
- package/src/dev-boot.ts +290 -0
- package/src/dev-child-watch.ts +63 -12
- package/src/dev-dashboard-guard.ts +1 -1
- package/src/dev-dashboard.ts +5 -1
- package/src/dev-lock.ts +96 -24
- 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 +14 -7
- package/src/doctor-auth.ts +4 -4
- package/src/doctor-offline.ts +6 -5
- package/src/document-options.ts +7 -0
- package/src/drift-replica-identity.ts +2 -2
- package/src/drift.ts +29 -6
- package/src/error-codes.ts +6 -0
- package/src/error-page-csp.ts +14 -15
- 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.ts +13 -2
- package/src/generate-catalog-locales.ts +79 -0
- package/src/generate-kinds.ts +3 -17
- package/src/generate-plural.ts +50 -0
- package/src/generate-shadow.ts +50 -1
- package/src/hold.ts +56 -1
- package/src/i18n-audit.ts +21 -8
- 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 +1 -3
- package/src/island-bundle.ts +54 -64
- 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 +32 -16
- package/src/local-cli-handoff.ts +90 -0
- package/src/mcp-errors.ts +8 -1
- package/src/mcp-host.ts +6 -5
- 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 +27 -16
- package/src/metrics-endpoint.ts +3 -2
- package/src/migration-extensions.ts +1 -1
- package/src/otlp-export.ts +2 -2
- package/src/output.ts +18 -0
- package/src/page-speculation.ts +14 -11
- 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-report.ts +58 -0
- package/src/prerender-served.ts +52 -0
- package/src/prerender.ts +63 -98
- package/src/pwa-artifacts.ts +8 -1
- package/src/pwa-manifest-locales.ts +9 -9
- package/src/retry-memo.ts +1 -1
- package/src/role-realtime.ts +7 -1
- package/src/role-replicator.ts +110 -31
- package/src/role-start.ts +66 -87
- package/src/role-sync.ts +65 -6
- package/src/role-wake.ts +2 -3
- 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 +15 -2
- package/src/runtime-bindings.ts +23 -3
- package/src/runtime-cache.ts +8 -8
- package/src/runtime-hooks.ts +2 -1
- package/src/runtime-isr-outcome.ts +100 -0
- package/src/runtime-isr.ts +2 -2
- package/src/runtime-jobs.ts +78 -18
- package/src/runtime-live-feed.ts +1 -1
- package/src/runtime-mail.ts +26 -0
- package/src/runtime-mfa-warning.ts +2 -2
- package/src/runtime-overrides.ts +3 -3
- package/src/runtime-purge.ts +1 -1
- package/src/runtime-queue.ts +19 -30
- package/src/runtime-realtime.ts +5 -3
- package/src/runtime-render.ts +77 -82
- package/src/runtime-replica.ts +3 -8
- package/src/runtime-services.ts +36 -15
- package/src/runtime-storage-upload.ts +2 -2
- package/src/runtime-storage.ts +3 -3
- package/src/schema-diff.ts +1 -1
- package/src/secrets-rotation.ts +8 -9
- package/src/seo-routes.ts +2 -2
- package/src/serve-boot.ts +22 -11
- package/src/serve-drain.ts +3 -1
- package/src/serve-retire.ts +95 -0
- package/src/serve-web.ts +19 -8
- 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 -15
- 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-seo.ts +6 -4
- package/src/source-files.ts +13 -3
- package/src/static-report.ts +5 -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 +1 -1
- package/src/templates/action.ts +4 -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/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 +23 -8
- package/src/templates/scaffold-api.ts +2 -3
- package/src/templates/scaffold-app.ts +2 -1
- 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.ts +12 -6
- 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-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-templates.ts +74 -18
- package/src/templates/scaffold-helm.ts +31 -4
- 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 +18 -15
- package/src/templates/scaffold-shell.ts +17 -2
- package/src/test-only-shipped.ts +193 -0
- package/src/test-select.ts +2 -1
- package/src/test-shards.ts +1 -4
- package/src/theme-boot.ts +2 -2
- package/src/theme-brand.ts +66 -0
- package/src/trusted-hops.ts +49 -0
- package/src/unscanned-admin.ts +2 -2
- package/src/verify-checks.ts +12 -2
- package/src/verify-coverage-run.ts +3 -2
- package/src/verify-deadline.ts +87 -10
- package/src/verify-merge.ts +48 -16
- package/src/verify-part-step.ts +10 -2
- package/src/verify-role-load-probe.ts +12 -2
- package/src/verify-role-load.ts +9 -4
- package/src/verify-run.ts +3 -0
- package/src/verify-step.ts +2 -0
- package/src/verify-test-run.ts +3 -2
- package/src/verify-tests.ts +0 -1
- 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/jobs-drain.ts +0 -230
- package/src/scaffold-typecheck.ts +0 -227
- package/src/thrown-by.ts +0 -24
|
@@ -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
|
+
`;
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
// The `--example` dashboard: the seeded `post` slice from above. Real rows through the slice's own
|
|
2
|
-
//
|
|
3
|
-
// a
|
|
2
|
+
// query — never its repo, which the `boundaries` step refuses a route (X_BOUNDARY_ROUTE_TO_DB) —
|
|
3
|
+
// aggregated by a pure view module this file also emits, so the numbers are testable without a
|
|
4
|
+
// database. The page's route declaration and its island come from `scaffold-dashboard-shared.ts`.
|
|
4
5
|
|
|
5
6
|
import { sortedImports } from './imports';
|
|
6
7
|
import type { GeneratedFile, NameSet } from './naming';
|
|
8
|
+
import { examplePageTest, exampleViewTest } from './scaffold-dashboard-example-tests';
|
|
7
9
|
import { DASHBOARD_DIR, routeConfig, themeActions, themeIsland } from './scaffold-dashboard-shared';
|
|
8
10
|
|
|
9
11
|
// Plain strings for the framework lines, never template literals: the workspace-dependency scanner
|
|
@@ -12,20 +14,25 @@ import { DASHBOARD_DIR, routeConfig, themeActions, themeIsland } from './scaffol
|
|
|
12
14
|
const examplePage = (
|
|
13
15
|
app: NameSet,
|
|
14
16
|
): string => `// The authed dashboard: what the seeded \`post\` slice looks like from above. Real rows, read
|
|
15
|
-
// through the slice's own
|
|
16
|
-
// are testable without a database.
|
|
17
|
+
// through the slice's own query and aggregated by \`dashboard-view.ts\`, which is pure so the
|
|
18
|
+
// numbers are testable without a database. Every chart is server markup — an svg and a hidden data table —
|
|
19
|
+
// so the page's only script is still the theme toggle.
|
|
17
20
|
//
|
|
18
21
|
// \`useT()\`, not \`t\` from @ultimat3/i18n — see apps/web/site/page.tsx for why.
|
|
19
22
|
${sortedImports([
|
|
20
23
|
`import { useT } from '@${app.kebab}/i18n';`,
|
|
21
24
|
"import { isUltimateError } from '@ultimat3/core';",
|
|
22
25
|
"import { currentLocale } from '@ultimat3/i18n';",
|
|
26
|
+
"import { formatMoney } from '@ultimat3/money';",
|
|
23
27
|
"import { defineRoute, island } from '@ultimat3/render';",
|
|
24
28
|
[
|
|
25
29
|
'import {',
|
|
26
|
-
'
|
|
30
|
+
' AreaChart,',
|
|
27
31
|
' DataTable,',
|
|
32
|
+
' DonutChart,',
|
|
33
|
+
' Gauge,',
|
|
28
34
|
' Grid,',
|
|
35
|
+
' InlineBar,',
|
|
29
36
|
' PageHeader,',
|
|
30
37
|
' RelativeTime,',
|
|
31
38
|
' Section,',
|
|
@@ -35,13 +42,15 @@ ${sortedImports([
|
|
|
35
42
|
].join('\n'),
|
|
36
43
|
])}
|
|
37
44
|
import { Shell } from '../../shared/shell';
|
|
38
|
-
import
|
|
45
|
+
import { postList } from '../post/live/post-list';
|
|
39
46
|
import {
|
|
40
47
|
bucketByDay,
|
|
41
48
|
CHART_DAYS,
|
|
42
49
|
formatCount,
|
|
43
50
|
type PostRow,
|
|
44
51
|
postStats,
|
|
52
|
+
priceCeilings,
|
|
53
|
+
priceMix,
|
|
45
54
|
toPostRow,
|
|
46
55
|
} from './dashboard-view';
|
|
47
56
|
import styles from './page.module.scss';
|
|
@@ -53,7 +62,7 @@ const ROW_LIMIT = 500;
|
|
|
53
62
|
const TABLE_ROWS = 10;
|
|
54
63
|
|
|
55
64
|
export interface DashboardData {
|
|
56
|
-
/** Newest first, as the
|
|
65
|
+
/** Newest first, as the query orders them. */
|
|
57
66
|
readonly rows: readonly PostRow[];
|
|
58
67
|
/** The instant the windows were cut at, so server and test agree on "the last 7 days". */
|
|
59
68
|
readonly now: string;
|
|
@@ -78,11 +87,13 @@ export async function dashboardData(
|
|
|
78
87
|
}
|
|
79
88
|
|
|
80
89
|
/**
|
|
81
|
-
* Whose posts: the ACTOR's org
|
|
82
|
-
*
|
|
90
|
+
* Whose posts: the ACTOR's org, and only for an actor \`post:read\` admits. Through the query, never
|
|
91
|
+
* the repo: \`dashboard:read\` lets a viewer onto this page and says nothing about posts, and the
|
|
92
|
+
* query is where the read's policy, order and bound live. The typed handle under it scopes every
|
|
93
|
+
* read to the request's actor, so the viewer \`auth/dev-actor.ts\` resolves sees the rows
|
|
83
94
|
* \`packages/db/src/seed.ts\` wrote, and a real session sees its own org's the day one exists.
|
|
84
95
|
*/
|
|
85
|
-
const load = (): Promise<DashboardData> => dashboardData(() =>
|
|
96
|
+
const load = (): Promise<DashboardData> => dashboardData(() => postList({ limit: ROW_LIMIT }));
|
|
86
97
|
|
|
87
98
|
${themeIsland}
|
|
88
99
|
|
|
@@ -98,7 +109,10 @@ export function Page(props: PageProps) {
|
|
|
98
109
|
const now = new Date(props.data.now);
|
|
99
110
|
const stats = postStats(props.data.rows, now);
|
|
100
111
|
const points = bucketByDay(props.data.rows, now, CHART_DAYS);
|
|
112
|
+
const mix = priceMix(props.data.rows);
|
|
101
113
|
const latest = props.data.rows.slice(0, TABLE_ROWS);
|
|
114
|
+
// Per currency: a bar is a share of the dearest post IN ITS CURRENCY, never of another's minor units.
|
|
115
|
+
const ceilings = priceCeilings(latest);
|
|
102
116
|
|
|
103
117
|
return (
|
|
104
118
|
<Shell
|
|
@@ -109,8 +123,8 @@ export function Page(props: PageProps) {
|
|
|
109
123
|
>
|
|
110
124
|
<div class={styles.page}>
|
|
111
125
|
<PageHeader title={t('app.dashboard.title')} description={t('app.dashboard.subtitle')} />
|
|
112
|
-
{/* 10rem, not the catalog's 16rem default:
|
|
113
|
-
instead of
|
|
126
|
+
{/* 10rem, not the catalog's 16rem default: two tiles sit side by side on a phone and four
|
|
127
|
+
across a monitor, instead of one alone on a row. */}
|
|
114
128
|
<Grid minColumn="10rem">
|
|
115
129
|
<StatTile
|
|
116
130
|
stat="posts-total"
|
|
@@ -131,10 +145,47 @@ export function Page(props: PageProps) {
|
|
|
131
145
|
value={formatCount(stats.today, locale)}
|
|
132
146
|
hint={t('app.dashboard.hintToday')}
|
|
133
147
|
/>
|
|
148
|
+
<StatTile
|
|
149
|
+
stat="posts-paid"
|
|
150
|
+
label={t('app.dashboard.statPaid')}
|
|
151
|
+
value={formatCount(mix.paid, locale)}
|
|
152
|
+
hint={t('app.dashboard.hintPaid')}
|
|
153
|
+
/>
|
|
134
154
|
</Grid>
|
|
135
|
-
<
|
|
136
|
-
<
|
|
137
|
-
|
|
155
|
+
<div class={styles.charts}>
|
|
156
|
+
<Section
|
|
157
|
+
title={t('app.dashboard.chartTitle')}
|
|
158
|
+
description={t('app.dashboard.chartRange')}
|
|
159
|
+
>
|
|
160
|
+
<AreaChart
|
|
161
|
+
label={t('app.dashboard.chartLabel')}
|
|
162
|
+
keys={points.map((point) => point.key)}
|
|
163
|
+
series={[
|
|
164
|
+
{ label: t('app.dashboard.chartSeries'), values: points.map((p) => p.value) },
|
|
165
|
+
]}
|
|
166
|
+
keyLabel={t('app.dashboard.chartKey')}
|
|
167
|
+
/>
|
|
168
|
+
</Section>
|
|
169
|
+
<Section title={t('app.dashboard.mixTitle')}>
|
|
170
|
+
<div class={styles.side}>
|
|
171
|
+
<DonutChart
|
|
172
|
+
label={t('app.dashboard.mixLabel')}
|
|
173
|
+
segments={[
|
|
174
|
+
{ label: t('app.dashboard.mixFree'), value: mix.free },
|
|
175
|
+
{ label: t('app.dashboard.mixPaid'), value: mix.paid },
|
|
176
|
+
]}
|
|
177
|
+
centreLabel={t('app.dashboard.mixTotal')}
|
|
178
|
+
keyLabel={t('app.dashboard.mixKey')}
|
|
179
|
+
valueLabel={t('app.dashboard.statTotal')}
|
|
180
|
+
/>
|
|
181
|
+
<Gauge
|
|
182
|
+
label={t('app.dashboard.gaugeLabel')}
|
|
183
|
+
value={stats.lastWeek}
|
|
184
|
+
max={stats.total}
|
|
185
|
+
/>
|
|
186
|
+
</div>
|
|
187
|
+
</Section>
|
|
188
|
+
</div>
|
|
138
189
|
<Section title={t('app.dashboard.tableTitle')}>
|
|
139
190
|
<DataTable
|
|
140
191
|
caption={t('app.dashboard.tableCaption')}
|
|
@@ -143,9 +194,24 @@ export function Page(props: PageProps) {
|
|
|
143
194
|
emptyTitle={t('app.dashboard.emptyTitle')}
|
|
144
195
|
columns={[
|
|
145
196
|
{ key: 'title', header: t('app.dashboard.columnTitle'), cell: (row) => row.title },
|
|
197
|
+
{
|
|
198
|
+
key: 'price',
|
|
199
|
+
header: t('app.dashboard.columnPrice'),
|
|
200
|
+
numeric: true,
|
|
201
|
+
cell: (row) => (
|
|
202
|
+
<InlineBar
|
|
203
|
+
value={row.price.minor}
|
|
204
|
+
max={ceilings.get(row.price.currency) ?? 0}
|
|
205
|
+
text={formatMoney(row.price, locale)}
|
|
206
|
+
/>
|
|
207
|
+
),
|
|
208
|
+
},
|
|
146
209
|
{
|
|
147
210
|
key: 'createdAt',
|
|
148
211
|
header: t('app.dashboard.columnCreated'),
|
|
212
|
+
// Below \`md\` the date folds into the row's "more" disclosure: the title and the
|
|
213
|
+
// price are what a phone has room for, and nothing is lost.
|
|
214
|
+
priority: 2,
|
|
149
215
|
cell: (row) => (
|
|
150
216
|
<RelativeTime value={row.createdAt} now={props.data.now} locale={locale} />
|
|
151
217
|
),
|
|
@@ -163,6 +229,7 @@ const exampleView =
|
|
|
163
229
|
(): string => `// The dashboard's numbers, apart from its markup: pure functions over the rows the page loaded,
|
|
164
230
|
// so the server render and a test cannot disagree about a figure. No I/O, no \`t()\`, no JSX.
|
|
165
231
|
|
|
232
|
+
import type { Money } from '@ultimat3/money';
|
|
166
233
|
import { type ChartPoint, deltaOf, type StatDelta } from '@ultimat3/ui';
|
|
167
234
|
|
|
168
235
|
/** Days of history the chart shows, oldest first. */
|
|
@@ -174,26 +241,54 @@ const DAY_MS = 86_400_000;
|
|
|
174
241
|
export interface PostRow {
|
|
175
242
|
readonly id: string;
|
|
176
243
|
readonly title: string;
|
|
244
|
+
/** Integer minor units plus an ISO code, never a float — the entity's \`money()\` column. */
|
|
245
|
+
readonly price: Money;
|
|
177
246
|
readonly createdAt: string;
|
|
178
247
|
}
|
|
179
248
|
|
|
180
249
|
/**
|
|
181
|
-
* Duck-typed rather than the entity's own row: the page only ever reads these
|
|
182
|
-
*
|
|
250
|
+
* Duck-typed rather than the entity's own row: the page only ever reads these four columns. The
|
|
251
|
+
* query reads through the typed handle, so \`createdAt\` arrives as the \`Date\` the entity declares.
|
|
183
252
|
*/
|
|
184
253
|
export function toPostRow(row: {
|
|
185
254
|
readonly id: string;
|
|
186
255
|
readonly title: string;
|
|
256
|
+
readonly price: Money;
|
|
187
257
|
readonly createdAt: Date | string;
|
|
188
258
|
}): PostRow {
|
|
189
259
|
const at = row.createdAt;
|
|
190
260
|
return {
|
|
191
261
|
id: row.id,
|
|
192
262
|
title: row.title,
|
|
263
|
+
price: row.price,
|
|
193
264
|
createdAt: at instanceof Date ? at.toISOString() : at,
|
|
194
265
|
};
|
|
195
266
|
}
|
|
196
267
|
|
|
268
|
+
/** Free (a zero price) against paid, over every row loaded — the two halves of the ring. */
|
|
269
|
+
export interface PriceMix {
|
|
270
|
+
readonly free: number;
|
|
271
|
+
readonly paid: number;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function priceMix(rows: readonly PostRow[]): PriceMix {
|
|
275
|
+
const paid = rows.filter((row) => row.price.minor > 0).length;
|
|
276
|
+
return { free: rows.length - paid, paid };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* The dearest price per currency, in minor units — what each row's in-cell bar is a share of.
|
|
281
|
+
* Per currency because 1,900 cents and 1,900 yen are not the same height.
|
|
282
|
+
*/
|
|
283
|
+
export function priceCeilings(rows: readonly PostRow[]): ReadonlyMap<string, number> {
|
|
284
|
+
const ceilings = new Map<string, number>();
|
|
285
|
+
for (const row of rows) {
|
|
286
|
+
const { currency, minor } = row.price;
|
|
287
|
+
ceilings.set(currency, Math.max(ceilings.get(currency) ?? 0, minor));
|
|
288
|
+
}
|
|
289
|
+
return ceilings;
|
|
290
|
+
}
|
|
291
|
+
|
|
197
292
|
export interface PostStats {
|
|
198
293
|
readonly total: number;
|
|
199
294
|
/** Created in the seven days ending at \`now\`. */
|
|
@@ -255,161 +350,6 @@ export const formatCount = (value: number, locale: string): string =>
|
|
|
255
350
|
new Intl.NumberFormat(locale).format(value);
|
|
256
351
|
`;
|
|
257
352
|
|
|
258
|
-
const exampleViewTest =
|
|
259
|
-
(): string => `// The numbers the dashboard shows, pinned against a fixed clock. The interesting cases are the
|
|
260
|
-
// window edges: a post from eight days ago is in the prior week, not this one, and a day with no
|
|
261
|
-
// posts is still a bar.
|
|
262
|
-
import { expect, unitTest } from '@ultimat3/testing';
|
|
263
|
-
import { bucketByDay, formatCount, type PostRow, postStats, toPostRow } from './dashboard-view';
|
|
264
|
-
|
|
265
|
-
const NOW = new Date('2026-03-15T12:00:00.000Z');
|
|
266
|
-
const DAY_MS = 86_400_000;
|
|
267
|
-
|
|
268
|
-
const row = (id: string, daysAgo: number): PostRow => ({
|
|
269
|
-
id,
|
|
270
|
-
title: \`post \${id}\`,
|
|
271
|
-
createdAt: new Date(NOW.getTime() - daysAgo * DAY_MS).toISOString(),
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
const rows = [row('a', 1), row('b', 3), row('c', 8)];
|
|
275
|
-
|
|
276
|
-
unitTest('postStats counts the week, compares it with the prior one, and counts today', () => {
|
|
277
|
-
const stats = postStats(rows, NOW);
|
|
278
|
-
expect(stats.total).toBe(3);
|
|
279
|
-
expect(stats.lastWeek).toBe(2);
|
|
280
|
-
// Two this week against one the week before: +100%, and the chip says so.
|
|
281
|
-
expect(stats.delta).toEqual({ text: '+100%', trend: 'up' });
|
|
282
|
-
// 'a' is yesterday and nothing was created on 03-15 itself.
|
|
283
|
-
expect(stats.today).toBe(0);
|
|
284
|
-
expect(postStats([...rows, row('d', 0.25)], NOW).today).toBe(1);
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
unitTest('a zero baseline yields no delta, and no rows count nothing', () => {
|
|
288
|
-
expect(postStats([row('a', 1)], NOW).delta).toBeUndefined();
|
|
289
|
-
expect(postStats([], NOW)).toEqual({ total: 0, lastWeek: 0, delta: undefined, today: 0 });
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
unitTest('bucketByDay is one bar per day, oldest first, quiet days included', () => {
|
|
293
|
-
const points = bucketByDay(rows, NOW, 14);
|
|
294
|
-
expect(points).toHaveLength(14);
|
|
295
|
-
expect(points.at(-1)?.key).toBe('03-15');
|
|
296
|
-
expect(points[0]?.key).toBe('03-02');
|
|
297
|
-
expect(points.reduce((sum, point) => sum + point.value, 0)).toBe(3);
|
|
298
|
-
// Eight days ago is 03-07: inside a 14-day window, so the bar is there.
|
|
299
|
-
expect(points.find((point) => point.key === '03-07')?.value).toBe(1);
|
|
300
|
-
expect(bucketByDay([], NOW, 3).map((point) => point.value)).toEqual([0, 0, 0]);
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
unitTest('toPostRow serialises a Date and passes ISO text through', () => {
|
|
304
|
-
const at = new Date('2026-01-02T03:04:05.000Z');
|
|
305
|
-
expect(toPostRow({ id: 'x', title: 't', createdAt: at }).createdAt).toBe(at.toISOString());
|
|
306
|
-
expect(toPostRow({ id: 'x', title: 't', createdAt: '2026-01-01' }).createdAt).toBe('2026-01-01');
|
|
307
|
-
});
|
|
308
|
-
|
|
309
|
-
unitTest('formatCount follows the locale', () => {
|
|
310
|
-
expect(formatCount(1234, 'en')).toBe('1,234');
|
|
311
|
-
expect(formatCount(1234, 'de')).toBe('1.234');
|
|
312
|
-
});
|
|
313
|
-
`;
|
|
314
|
-
|
|
315
|
-
const examplePageTest = (
|
|
316
|
-
app: NameSet,
|
|
317
|
-
): string => `// The dashboard, rendered as a request renders it: \`load\` reads the actor's org through the
|
|
318
|
-
// slice's repo — the in-memory driver under test — and the page turns the rows into tiles, a chart
|
|
319
|
-
// and a table. Losing the policy is the other regression worth a test: the page still renders, to
|
|
320
|
-
// anyone.
|
|
321
|
-
${sortedImports([
|
|
322
|
-
`import { driver } from '@${app.kebab}/db';`,
|
|
323
|
-
`import { useT } from '@${app.kebab}/i18n';`,
|
|
324
|
-
"import { createContext, frozenClock, runWithContext } from '@ultimat3/core';",
|
|
325
|
-
"import { testActor } from '@ultimat3/policy';",
|
|
326
|
-
"import { dbUnavailable } from '@ultimat3/db';",
|
|
327
|
-
"import { afterEach, expect, renderRoute, unitTest } from '@ultimat3/testing';",
|
|
328
|
-
])}
|
|
329
|
-
import { DEMO_ORG_ID } from '../../shared/demo-org';
|
|
330
|
-
import * as repo from '../post/repo';
|
|
331
|
-
import * as page from './page';
|
|
332
|
-
|
|
333
|
-
const url = 'https://example.test/dashboard';
|
|
334
|
-
const viewer = testActor('viewer', { orgId: DEMO_ORG_ID }).actor;
|
|
335
|
-
const stranger = testActor('stranger', { orgId: '00000000-0000-4000-8000-000000000009' }).actor;
|
|
336
|
-
|
|
337
|
-
const DAY_MS = 86_400_000;
|
|
338
|
-
|
|
339
|
-
/**
|
|
340
|
-
* One post in the viewer's org, written the way the app writes one — \`daysAgo\` days before now.
|
|
341
|
-
* The request's clock is what stamps \`createdAt\`, so the test chooses the instant.
|
|
342
|
-
*/
|
|
343
|
-
const post = (title: string, daysAgo: number) => {
|
|
344
|
-
const clock = frozenClock(new Date(Date.now() - daysAgo * DAY_MS));
|
|
345
|
-
const draft = { orgId: DEMO_ORG_ID, title, price: { minor: 0, currency: 'USD' } };
|
|
346
|
-
return runWithContext(createContext({ actor: viewer, clock }), () => repo.insert(draft));
|
|
347
|
-
};
|
|
348
|
-
|
|
349
|
-
/** The figure a tile shows, read off the markup by the \`stat\` the page gave it. */
|
|
350
|
-
const stat = (html: string, name: string): string | undefined =>
|
|
351
|
-
html.match(new RegExp(\`data-stat="\${name}">([^<]*)<\`))?.[1];
|
|
352
|
-
|
|
353
|
-
// One store per process: without this, one test's rows are the next test's fixtures.
|
|
354
|
-
afterEach(() => {
|
|
355
|
-
driver.reset?.();
|
|
356
|
-
});
|
|
357
|
-
|
|
358
|
-
unitTest('the dashboard is gated, renders per request, and hydrates one island', async () => {
|
|
359
|
-
const view = await renderRoute(page, { url, actor: viewer });
|
|
360
|
-
expect(page.config.render).toBe('ssr');
|
|
361
|
-
expect(page.config.policy?.permission).toBe('dashboard:read');
|
|
362
|
-
expect(page.config.offline).toBe('runtime');
|
|
363
|
-
expect(page.config.budget.js).toBe('60kb');
|
|
364
|
-
// What the render emitted, beside what the config declares: the theme toggle, and only it.
|
|
365
|
-
expect(view.islands.map((island) => [island.moduleId, island.strategy])).toEqual([
|
|
366
|
-
['shared-theme-toggle', 'visible'],
|
|
367
|
-
]);
|
|
368
|
-
});
|
|
369
|
-
|
|
370
|
-
unitTest('it counts and lists the posts of the org that is looking', async () => {
|
|
371
|
-
await post('Last month', 30);
|
|
372
|
-
await post('This week', 3);
|
|
373
|
-
await post('Today', 0);
|
|
374
|
-
const view = await renderRoute(page, { url, actor: viewer });
|
|
375
|
-
// Newest first, as the repo orders them — and each tile counts its own window.
|
|
376
|
-
expect(view.data.rows.map((row) => row.title)).toEqual(['Today', 'This week', 'Last month']);
|
|
377
|
-
expect(stat(view.html, 'posts-total')).toBe('3');
|
|
378
|
-
expect(stat(view.html, 'posts-week')).toBe('2');
|
|
379
|
-
expect(stat(view.html, 'posts-today')).toBe('1');
|
|
380
|
-
// One table row per post, and one bar per day of the window whether or not it has posts.
|
|
381
|
-
expect(view.html.match(/<tr data-row=/g)).toHaveLength(3);
|
|
382
|
-
expect(view.html.match(/data-bar="true"/g)).toHaveLength(14);
|
|
383
|
-
expect(view.text).toContain('This week');
|
|
384
|
-
// The sidebar knows where it is.
|
|
385
|
-
expect(view.html).toMatch(/<a href="\\/dashboard" aria-current="page"/);
|
|
386
|
-
});
|
|
387
|
-
|
|
388
|
-
unitTest('another org sees none of them: the page says so instead of an empty table', async () => {
|
|
389
|
-
const t = useT();
|
|
390
|
-
await post('Not theirs', 0);
|
|
391
|
-
const view = await renderRoute(page, { url, actor: stranger });
|
|
392
|
-
expect(view.data.rows).toEqual([]);
|
|
393
|
-
expect(stat(view.html, 'posts-total')).toBe('0');
|
|
394
|
-
expect(view.html).not.toContain('<tr data-row=');
|
|
395
|
-
expect(view.text).toContain(t('app.dashboard.emptyTitle'));
|
|
396
|
-
expect(view.meta.title).toBe(t('app.dashboard.title'));
|
|
397
|
-
expect(view.meta.description).toBe(t('app.dashboard.description'));
|
|
398
|
-
});
|
|
399
|
-
|
|
400
|
-
unitTest('the build measures the empty page: no database is not a failure there', async () => {
|
|
401
|
-
const measured = await page.dashboardData(() => Promise.reject(dbUnavailable('no database')));
|
|
402
|
-
// The clock is frozen under test, so the instant the windows are cut at is this one.
|
|
403
|
-
expect(measured).toEqual({ rows: [], now: new Date().toISOString() });
|
|
404
|
-
});
|
|
405
|
-
|
|
406
|
-
unitTest('any other failure of the read propagates', async () => {
|
|
407
|
-
const cause = new TypeError('the read broke');
|
|
408
|
-
const read = (): Promise<never> => Promise.reject(cause);
|
|
409
|
-
expect(await page.dashboardData(read).catch((error: unknown) => error)).toBe(cause);
|
|
410
|
-
});
|
|
411
|
-
`;
|
|
412
|
-
|
|
413
353
|
/** The example dashboard's page, view module and the tests beside both. */
|
|
414
354
|
export const exampleDashboardFiles = (app: NameSet): readonly GeneratedFile[] => [
|
|
415
355
|
{ path: `${DASHBOARD_DIR}/page.tsx`, contents: examplePage(app) },
|