@ultimat3/cli 22.14.0 → 23.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +25 -8
- package/README.md +28 -5
- package/package.json +29 -28
- package/src/admin-catalog-keys.ts +16 -0
- package/src/admin-registration.ts +192 -0
- package/src/api-registration.ts +36 -11
- package/src/app-boundaries.ts +6 -20
- package/src/app-load.ts +55 -11
- package/src/app-manifest.ts +14 -0
- package/src/app-permissions.ts +0 -0
- package/src/app-reload-graph.ts +7 -30
- package/src/app-transport.ts +219 -0
- package/src/boundary-findings.ts +16 -4
- package/src/browser-transport.ts +160 -0
- package/src/budgets.ts +35 -3
- package/src/cmd-build-spec.ts +10 -3
- package/src/cmd-build.ts +56 -9
- package/src/cmd-db.ts +60 -11
- package/src/cmd-dev.ts +2 -2
- package/src/cmd-doctor.ts +35 -11
- package/src/cmd-generate.ts +118 -17
- package/src/cmd-jobs-spec.ts +19 -3
- package/src/cmd-jobs.ts +120 -3
- package/src/cmd-new.ts +15 -1
- package/src/cmd-routes.ts +15 -1
- package/src/cmd-secrets-ring.ts +76 -0
- package/src/cmd-secrets-spec.ts +10 -2
- package/src/cmd-secrets.ts +48 -12
- package/src/cmd-tasks.ts +84 -15
- package/src/cmd-verify.ts +10 -2
- package/src/comment-safe.ts +16 -0
- package/src/coverage-floor.ts +334 -0
- package/src/coverage-lcov.ts +193 -0
- package/src/coverage-source.ts +136 -0
- package/src/db-object-drift.ts +30 -0
- package/src/db-replay-engine.ts +115 -0
- package/src/db-schema-dump.ts +150 -0
- package/src/db-schema-refresh.ts +122 -0
- package/src/dev-dashboard.ts +2 -2
- package/src/dev-route-table.ts +30 -6
- package/src/doctor-coverage.ts +39 -0
- package/src/doctor-guards.ts +54 -0
- package/src/doctor-sealed.ts +132 -0
- package/src/document-graph.ts +82 -0
- package/src/document-options.ts +64 -0
- package/src/error-codes.ts +32 -0
- package/src/framework-schema.ts +15 -0
- package/src/generate-files.ts +76 -10
- package/src/generate-grant-findings.ts +76 -0
- package/src/generate-grants.ts +77 -3
- package/src/generate-kinds.ts +19 -0
- package/src/generate-write.ts +15 -27
- package/src/generated-imports.ts +59 -0
- package/src/guard-sources.ts +104 -0
- package/src/guards.ts +23 -7
- package/src/handle-registration.ts +217 -0
- package/src/i18n-registration.ts +36 -0
- package/src/image-prepare.ts +51 -8
- package/src/import-closure.ts +165 -0
- package/src/index.ts +11 -2
- package/src/island-store.ts +24 -16
- package/src/jobs-drain.ts +14 -7
- package/src/jobs-json.ts +50 -0
- package/src/jobs-report.ts +3 -0
- package/src/json-merge.ts +46 -0
- package/src/mcp-errors.ts +29 -1
- package/src/messages.ts +33 -0
- package/src/metrics-endpoint.ts +71 -24
- package/src/migration-extensions.ts +29 -0
- package/src/module-imports.ts +47 -0
- package/src/output.ts +18 -9
- package/src/page-speculation.ts +124 -0
- package/src/permission-grants.ts +41 -9
- package/src/prerender.ts +9 -5
- package/src/publish-closure.ts +101 -0
- package/src/role-load.ts +143 -0
- package/src/role-start.ts +18 -1
- package/src/role-wake.ts +45 -0
- package/src/runtime-admin.ts +141 -0
- package/src/runtime-hooks.ts +5 -2
- package/src/runtime-queue.ts +8 -1
- package/src/runtime-render.ts +36 -60
- package/src/runtime-services.ts +2 -1
- package/src/scaffold-typecheck.ts +1 -0
- package/src/schema-dump-drift.ts +64 -0
- package/src/serve-boot.ts +85 -147
- package/src/serve-prebuilt-paths.ts +19 -0
- package/src/serve-prebuilt.ts +84 -0
- package/src/serve-web.ts +164 -0
- package/src/serve.ts +6 -9
- package/src/server-barrels.ts +54 -0
- package/src/source-edit.ts +49 -0
- package/src/static-document.ts +108 -0
- package/src/tasks-facts.ts +4 -1
- package/src/templates/action.ts +144 -11
- package/src/templates/admin-catalog.ts +58 -0
- package/src/templates/admin-page.ts +36 -8
- package/src/templates/admin.ts +57 -17
- package/src/templates/backfill.ts +121 -87
- package/src/templates/entity.ts +140 -44
- package/src/templates/guard-animated-layout-property.ts +4 -11
- package/src/templates/guard-bare-error.ts +9 -13
- package/src/templates/guard-focus-visible.ts +4 -11
- package/src/templates/guard-image-dimensions.ts +6 -6
- package/src/templates/guard-island-without-states.ts +2 -4
- package/src/templates/guard-raw-breakpoint.ts +210 -0
- package/src/templates/guard-raw-colour.ts +4 -11
- package/src/templates/guard-raw-length.ts +375 -0
- package/src/templates/guard-raw-motion.ts +213 -0
- package/src/templates/guard-raw-shadow.ts +187 -0
- package/src/templates/guard-raw-z-index.ts +166 -0
- package/src/templates/guard-repo-raw-sql.ts +345 -0
- package/src/templates/guard-semantic-interactive.ts +6 -6
- package/src/templates/guard-undeclared-custom-property.ts +217 -0
- package/src/templates/guard-undefined-style-class.ts +215 -0
- package/src/templates/guard-untranslated-string.ts +6 -6
- package/src/templates/guard-unzoned-date.ts +9 -13
- package/src/templates/index.ts +3 -2
- package/src/templates/island.ts +34 -51
- package/src/templates/job-body-test.ts +121 -0
- package/src/templates/job.ts +23 -8
- package/src/templates/policy.ts +16 -9
- package/src/templates/query.ts +242 -26
- package/src/templates/resource-create.ts +62 -18
- package/src/templates/resource-form-island.ts +58 -80
- package/src/templates/resource-page.ts +269 -0
- package/src/templates/resource-service.ts +96 -0
- package/src/templates/resource.ts +86 -61
- package/src/templates/route.ts +38 -29
- package/src/templates/scaffold-api.ts +55 -1
- package/src/templates/scaffold-app.ts +171 -51
- package/src/templates/scaffold-auth.ts +83 -19
- package/src/templates/scaffold-browser-client.ts +90 -0
- package/src/templates/scaffold-container.ts +16 -0
- package/src/templates/scaffold-dashboard-bare.ts +64 -2
- package/src/templates/scaffold-dashboard-example.ts +124 -31
- package/src/templates/scaffold-dashboard-shared.ts +1 -20
- package/src/templates/scaffold-dashboard.ts +1 -2
- package/src/templates/scaffold-db-client.ts +142 -0
- package/src/templates/scaffold-db-package.ts +90 -8
- package/src/templates/scaffold-docs.ts +50 -5
- package/src/templates/scaffold-guards.ts +54 -11
- package/src/templates/scaffold-http.ts +9 -7
- package/src/templates/scaffold-repo.ts +52 -2
- package/src/templates/scaffold-roles.ts +56 -9
- package/src/templates/scaffold-shell.ts +36 -70
- package/src/templates/scaffold-site.ts +45 -19
- package/src/templates/scaffold-ui-package.ts +18 -0
- package/src/templates/slice-foundation.ts +46 -8
- package/src/test-counts.ts +18 -2
- package/src/transport-calls.ts +105 -0
- package/src/unscanned-admin.ts +54 -0
- package/src/verify-checks.ts +26 -7
- package/src/verify-coverage-run.ts +190 -0
- package/src/verify-deadline.ts +256 -0
- package/src/verify-errors.ts +15 -6
- package/src/verify-floor.ts +36 -9
- package/src/verify-merge.ts +117 -24
- package/src/verify-progress.ts +25 -0
- package/src/verify-role-load-probe.ts +54 -0
- package/src/verify-role-load.ts +148 -0
- package/src/verify-run.ts +93 -35
- package/src/verify-shard.ts +20 -9
- package/src/verify-step.ts +42 -1
- package/src/verify-tests.ts +78 -6
- package/src/workspace-checks.ts +50 -8
- package/src/workspace-dep-edit.ts +127 -0
- package/src/cmd-dev-fixture.ts +0 -204
- package/src/dev-live-fixture.ts +0 -122
- package/src/policy-fixture.ts +0 -78
- package/src/realtime-browser-probe-fixture.ts +0 -9
- package/src/role-start-fixture.ts +0 -78
- package/src/scaffold-fixture.ts +0 -160
- package/src/templates/island-fixture.ts +0 -76
|
@@ -22,14 +22,23 @@ not exist — five of these had an empty column and were each measured green on
|
|
|
22
22
|
| Rule | Detail | Refused by |
|
|
23
23
|
|---|---|---|
|
|
24
24
|
| One gate | \`bin/check\` — \`x build\` and then \`x verify\`. Green means shippable; never merge red. | the gate itself, and \`.github/workflows/ci.yml\` on every push and pull request |
|
|
25
|
+
| Coverage | the unit suite covers 95% of the lines and functions of this app's own source — every file under \`apps/\` and \`packages/\`, one no test loads counted at 0%. A floor, not the goal: a test added to raise it is proven by mutation — break the source, watch it go red, restore | \`X_COVERAGE_BELOW_FLOOR\` on the \`unit\` step; \`coverage\` in \`x.verify.json\` states the floor, and it only rises |
|
|
25
26
|
| One way | generators, not hand-rolled files: \`x g resource\`, \`x g action\`, \`x g route\` | review |
|
|
26
27
|
| Surfaces | \`site/\` is 0kb JS and may not import \`app/\`; \`shared/\` is a leaf | \`X_BOUNDARY_SITE_TO_APP\` |
|
|
27
28
|
| Data | routes call actions and queries; only \`repo.ts\` touches the database | \`X_BOUNDARY_ROUTE_TO_DB\` |
|
|
29
|
+
| Repos | a \`repo.ts\` reads through the typed handle, never a \`sql\` literal the handle can express | \`guards/repo-raw-sql.ts\` |
|
|
28
30
|
| Errors | never \`throw new Error\` — subclass \`UltimateError\` with a code, a cause and a fix | \`guards/bare-error.ts\` |
|
|
29
31
|
| Money | \`{ minor, currency }\`, never a float — \`money()\` on the column | the type: \`price: 19.99\` is TS2322, \`number\` is not \`MoneyInput\` |
|
|
30
32
|
| Time | store UTC, format with an explicit IANA time zone | \`guards/unzoned-date.ts\` |
|
|
31
33
|
| Strings | every user-facing string goes through \`t()\` | \`guards/untranslated-string.ts\` |
|
|
32
34
|
| Colour | semantic tokens only, never a raw hex | \`guards/raw-colour.ts\` |
|
|
35
|
+
| Lengths | a length is a token or \`rem()\`, never a raw \`px\` | \`guards/raw-length.ts\` |
|
|
36
|
+
| Breakpoints | a viewport query names a breakpoint rung (\`respond-up\`, \`respond-down\`, \`respond-between\`), never a width | \`guards/raw-breakpoint.ts\` |
|
|
37
|
+
| Layers | a \`z-index\` is a named layer, never a number | \`guards/raw-z-index.ts\` |
|
|
38
|
+
| Shadows | a shadow comes off the shadow scale, never a hand-written offset and blur | \`guards/raw-shadow.ts\` |
|
|
39
|
+
| Timing | a duration and an easing curve come off the motion scale, never a literal | \`guards/raw-motion.ts\` |
|
|
40
|
+
| Classes | a class a component reads off its stylesheet is one that sheet compiles | \`guards/undefined-style-class.ts\` |
|
|
41
|
+
| Properties | a custom property a stylesheet reads is one the app declares | \`guards/undeclared-custom-property.ts\` |
|
|
33
42
|
| Interaction | a click is answered by a control — never a \`<div onClick>\`, and never a \`role=\` where the native tag exists | \`guards/semantic-interactive.ts\` |
|
|
34
43
|
| Focus | \`outline: none\` replaces the ring in the same rule or the one beside it, or it does not remove it | \`guards/focus-visible.ts\` |
|
|
35
44
|
| Images | every image carries width + height or an aspect-ratio, and the priority one is never \`loading="lazy"\` | \`guards/image-dimensions.ts\` |
|
|
@@ -51,6 +60,23 @@ unit of review, and \`x verify\` refuses it. The one exemption is a file that is
|
|
|
51
60
|
it has one job by construction, and its length tracks the API's size rather than its complexity;
|
|
52
61
|
one statement of logic in such a file re-arms the ceiling on the same save.
|
|
53
62
|
|
|
63
|
+
Coverage counts execution, not validation: 100% is reachable with zero assertions, and a covered
|
|
64
|
+
branch whose test cannot fail is worse than an uncovered one, because it reads as done. An
|
|
65
|
+
\`exclude\` entry under \`coverage\` is for code a unit test cannot execute — an island's
|
|
66
|
+
browser-only mount, a container entry point — carries its \`why\`, and is printed by \`x doctor\`.
|
|
67
|
+
|
|
68
|
+
Every test the scaffold and the generators write is the idiom to copy. All of them run on the
|
|
69
|
+
\`unit\` step — in process, on the in-memory driver, with no database, browser or server:
|
|
70
|
+
|
|
71
|
+
| What | How its test runs it | Copy from |
|
|
72
|
+
|---|---|---|
|
|
73
|
+
| a page | \`renderRoute(page, { url, actor })\` — \`load\`, \`meta\` and the page on one resolution; assert on \`view.text\`, \`view.html\`, \`view.meta\`, \`view.islands\` | \`apps/web/app/dashboard/page.test.ts\` |
|
|
74
|
+
| a component | \`renderView(Component, props)\` | \`apps/web/shared/shell.test.ts\` |
|
|
75
|
+
| an island | \`mountIsland\`, once per state its \`*.island.states.ts\` declares | \`apps/web/shared/theme-toggle.island.test.ts\` |
|
|
76
|
+
| an action or a query | \`target.as(actor, input)\` — the refusal first, then what it answers | \`apps/web/api/health.test.ts\` |
|
|
77
|
+
| a job, a task, a backfill | \`test('…', async ({ runJobs }) => …)\` — a worker in this process; assert on \`trace.steps\` and each run's \`outcome\` | what \`x g job\` writes |
|
|
78
|
+
| a repo, a seed | as an actor, \`runWithContext(createContext({ actor }), …)\`, with \`driver.reset?.()\` after each | \`packages/db/src/seed.test.ts\` |
|
|
79
|
+
|
|
54
80
|
Money is the one row with no guard, deliberately: a float has no static signature a text rule can
|
|
55
81
|
see, and the type already fires — measured, \`price: 19.99\` in a seed is
|
|
56
82
|
\`TS2322: Type 'number' is not assignable to type 'MoneyInput'\`. A guard that pretended to check
|
|
@@ -86,6 +112,9 @@ ${app.kebab} — Ultimate app. Read AGENTS.md first; it is the same content in t
|
|
|
86
112
|
- Scaffold, do not hand-write: \`x g <kind> <name>\` — \`x g --help\` lists every kind, and is the
|
|
87
113
|
only place that list is stated.
|
|
88
114
|
- Destructive DB work goes in a branch: \`x db branch create <name>\`, never the shared dev DB.
|
|
115
|
+
- \`x db gen\` also writes \`packages/db/schema/\` — the whole schema as SQL, one file per table.
|
|
116
|
+
Read it; never edit it. \`x verify\` fails with \`X_SCHEMA_DUMP_DRIFT\` when it is not what the
|
|
117
|
+
migrations produce, and \`x db gen\` rewrites it.
|
|
89
118
|
- \`x doctor\` explains a broken environment and prints the fix command for every finding.
|
|
90
119
|
|
|
91
120
|
\`.claude/\` holds the harness that reads this file: \`/feature\`, \`/planx\`, \`/verify\` and four
|
|
@@ -182,7 +211,12 @@ exec bunx x verify "$@"
|
|
|
182
211
|
|
|
183
212
|
const composeDev = (
|
|
184
213
|
app: NameSet,
|
|
185
|
-
): string => `# Optional: x dev needs none of this. Use it when you want
|
|
214
|
+
): string => `# Optional: x dev needs none of this. Use it when you want a real Postgres, NATS and S3 locally:
|
|
215
|
+
#
|
|
216
|
+
# docker compose -f docker/docker-compose.dev.yml up -d
|
|
217
|
+
# DATABASE_URL=postgres://postgres:${app.kebab}@127.0.0.1:5432/${app.kebab} NATS_URL=nats://127.0.0.1:4222 \\
|
|
218
|
+
# S3_ENDPOINT=http://127.0.0.1:9000 S3_BUCKET=${app.kebab} S3_FORCE_PATH_STYLE=1 \\
|
|
219
|
+
# S3_ACCESS_KEY_ID=${app.kebab} S3_SECRET_ACCESS_KEY=${app.kebab}-dev x dev
|
|
186
220
|
#
|
|
187
221
|
# Every published port binds 127.0.0.1, not 0.0.0.0. This stack ships its credentials in the file,
|
|
188
222
|
# as a dev stack reasonably does — so the short form \`'5432:5432'\` would put an authenticated
|
|
@@ -204,13 +238,24 @@ services:
|
|
|
204
238
|
image: nats:2-alpine
|
|
205
239
|
command: ['-js']
|
|
206
240
|
ports: ['127.0.0.1:4222:4222']
|
|
241
|
+
# Versity S3 Gateway over a directory: a directory under /data is a bucket, so the volume mounted
|
|
242
|
+
# at /data/${app.kebab} IS the bucket — ready on the first \`up\`, no CreateBucket call. It checks
|
|
243
|
+
# the region a request was signed for, and an app that sets no S3_REGION signs for \`auto\` — so
|
|
244
|
+
# that is this gateway's region. A server for a laptop; production is any S3-compatible endpoint.
|
|
207
245
|
s3:
|
|
208
|
-
image:
|
|
209
|
-
command: ['server', '/data']
|
|
246
|
+
image: versity/versitygw:v1.8.0
|
|
210
247
|
environment:
|
|
211
|
-
|
|
212
|
-
|
|
248
|
+
VGW_BACKEND: posix
|
|
249
|
+
VGW_BACKEND_ARG: /data
|
|
250
|
+
VGW_PORT: ':9000'
|
|
251
|
+
VGW_REGION: auto
|
|
252
|
+
ROOT_ACCESS_KEY: ${app.kebab}
|
|
253
|
+
ROOT_SECRET_KEY: ${app.kebab}-dev
|
|
213
254
|
ports: ['127.0.0.1:9000:9000']
|
|
255
|
+
volumes: ['s3bucket:/data/${app.kebab}']
|
|
256
|
+
|
|
257
|
+
volumes:
|
|
258
|
+
s3bucket:
|
|
214
259
|
`;
|
|
215
260
|
|
|
216
261
|
/** Docs, shims and container files for a new app, in the order a reader meets them. */
|
|
@@ -11,31 +11,74 @@
|
|
|
11
11
|
// about: a control a keyboard cannot reach, a focus ring taken away and not replaced, an image with
|
|
12
12
|
// no box, an animation the compositor cannot run, and an island nobody has seen fail. Each is
|
|
13
13
|
// statically decidable from the file alone — the reason those five and not the many that are not.
|
|
14
|
+
//
|
|
15
|
+
// The seven after those are about the STYLESHEET, which Biome does not read at all: a length, a
|
|
16
|
+
// breakpoint, a layer, a shadow and a tempo off their scales, a class the sheet never compiled and
|
|
17
|
+
// a custom property nothing declares. Until they shipped, raw colour was the only one of the
|
|
18
|
+
// design system's rules a scaffolded app's gate held.
|
|
19
|
+
//
|
|
20
|
+
// And one is about DATA: a `repo.ts` reads through the typed handle. It ships because the generator
|
|
21
|
+
// used to emit string SQL, and every app that started from it copied the shape.
|
|
14
22
|
|
|
15
23
|
import { animatedLayoutPropertyGuardFiles } from './guard-animated-layout-property';
|
|
16
24
|
import { bareErrorGuardFiles } from './guard-bare-error';
|
|
17
25
|
import { focusVisibleGuardFiles } from './guard-focus-visible';
|
|
18
26
|
import { imageDimensionsGuardFiles } from './guard-image-dimensions';
|
|
19
27
|
import { islandWithoutStatesGuardFiles } from './guard-island-without-states';
|
|
28
|
+
import { rawBreakpointGuardFiles } from './guard-raw-breakpoint';
|
|
20
29
|
import { rawColourGuardFiles } from './guard-raw-colour';
|
|
30
|
+
import { rawLengthGuardFiles } from './guard-raw-length';
|
|
31
|
+
import { rawMotionGuardFiles } from './guard-raw-motion';
|
|
32
|
+
import { rawShadowGuardFiles } from './guard-raw-shadow';
|
|
33
|
+
import { rawZIndexGuardFiles } from './guard-raw-z-index';
|
|
34
|
+
import { repoRawSqlGuardFiles } from './guard-repo-raw-sql';
|
|
21
35
|
import { semanticInteractiveGuardFiles } from './guard-semantic-interactive';
|
|
36
|
+
import { undeclaredCustomPropertyGuardFiles } from './guard-undeclared-custom-property';
|
|
37
|
+
import { undefinedStyleClassGuardFiles } from './guard-undefined-style-class';
|
|
22
38
|
import { untranslatedStringGuardFiles } from './guard-untranslated-string';
|
|
23
39
|
import { unzonedDateGuardFiles } from './guard-unzoned-date';
|
|
24
40
|
import type { GeneratedFile } from './naming';
|
|
25
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Every guard the framework ships, by the name its file carries — the ONE list. `x new` writes all
|
|
44
|
+
* of them (`scaffoldGuardFiles`), `x g guard <name>` writes the one it is asked for by name
|
|
45
|
+
* (`shippedGuardFiles`), and `x doctor` names the ones an app does not have. Sorted, so the three
|
|
46
|
+
* agree on an order. A new shipped guard is one module beside these and one row here.
|
|
47
|
+
*/
|
|
48
|
+
const SHIPPED: readonly (readonly [string, () => readonly GeneratedFile[]])[] = [
|
|
49
|
+
['animated-layout-property', animatedLayoutPropertyGuardFiles],
|
|
50
|
+
['bare-error', bareErrorGuardFiles],
|
|
51
|
+
['focus-visible', focusVisibleGuardFiles],
|
|
52
|
+
['image-dimensions', imageDimensionsGuardFiles],
|
|
53
|
+
['island-without-states', islandWithoutStatesGuardFiles],
|
|
54
|
+
['raw-breakpoint', rawBreakpointGuardFiles],
|
|
55
|
+
['raw-colour', rawColourGuardFiles],
|
|
56
|
+
['raw-length', rawLengthGuardFiles],
|
|
57
|
+
['raw-motion', rawMotionGuardFiles],
|
|
58
|
+
['raw-shadow', rawShadowGuardFiles],
|
|
59
|
+
['raw-z-index', rawZIndexGuardFiles],
|
|
60
|
+
['repo-raw-sql', repoRawSqlGuardFiles],
|
|
61
|
+
['semantic-interactive', semanticInteractiveGuardFiles],
|
|
62
|
+
['undeclared-custom-property', undeclaredCustomPropertyGuardFiles],
|
|
63
|
+
['undefined-style-class', undefinedStyleClassGuardFiles],
|
|
64
|
+
['untranslated-string', untranslatedStringGuardFiles],
|
|
65
|
+
['unzoned-date', unzonedDateGuardFiles],
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
/** The names, in the order every list of them is shown. */
|
|
69
|
+
export const SHIPPED_GUARD_NAMES: readonly string[] = SHIPPED.map(([name]) => name);
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The shipped guard of that name with its test, or `undefined` when the name is not one — which
|
|
73
|
+
* is an app's own convention, and `x g guard` then writes the blank template instead.
|
|
74
|
+
*/
|
|
75
|
+
export const shippedGuardFiles = (name: string): readonly GeneratedFile[] | undefined =>
|
|
76
|
+
SHIPPED.find(([shipped]) => shipped === name)?.[1]();
|
|
77
|
+
|
|
26
78
|
/**
|
|
27
79
|
* Every guard `x new` ships, each with its test. One module per guard, because an app deletes a
|
|
28
80
|
* rule it does not want by deleting one file — and a guard the app then writes for itself is
|
|
29
81
|
* `x g guard <name>`, the same shape.
|
|
30
82
|
*/
|
|
31
|
-
export const scaffoldGuardFiles = (): readonly GeneratedFile[] =>
|
|
32
|
-
|
|
33
|
-
...bareErrorGuardFiles(),
|
|
34
|
-
...focusVisibleGuardFiles(),
|
|
35
|
-
...imageDimensionsGuardFiles(),
|
|
36
|
-
...islandWithoutStatesGuardFiles(),
|
|
37
|
-
...rawColourGuardFiles(),
|
|
38
|
-
...semanticInteractiveGuardFiles(),
|
|
39
|
-
...untranslatedStringGuardFiles(),
|
|
40
|
-
...unzonedDateGuardFiles(),
|
|
41
|
-
];
|
|
83
|
+
export const scaffoldGuardFiles = (): readonly GeneratedFile[] =>
|
|
84
|
+
SHIPPED.flatMap(([, files]) => files());
|
|
@@ -49,27 +49,29 @@ const httpConfigTest =
|
|
|
49
49
|
// only thing that can go wrong is nobody importing the module — which is exactly how a shipped app
|
|
50
50
|
// rendered every string as ⟦key⟧ for a whole release (issue #249), one seam along.
|
|
51
51
|
import { configuredHttp, resetHttpConfig } from '@ultimat3/http';
|
|
52
|
-
import { expect, unitTest } from '@ultimat3/testing';
|
|
52
|
+
import { afterAll, expect, unitTest } from '@ultimat3/testing';
|
|
53
53
|
import './http';
|
|
54
54
|
|
|
55
|
+
// Read ONCE, at module scope: every case asserts on what the import registered, in any order.
|
|
56
|
+
const declared = configuredHttp();
|
|
57
|
+
|
|
55
58
|
unitTest('importing the module IS the registration', () => {
|
|
56
|
-
const declared = configuredHttp();
|
|
57
|
-
expect(declared).toBeDefined();
|
|
58
59
|
// The list is empty on a fresh scaffold and that is the shipped default; what is asserted is
|
|
59
60
|
// that the KEY reaches the boot, so adding an origin to it takes effect.
|
|
60
61
|
expect(declared?.cors?.origins).toEqual([]);
|
|
61
62
|
});
|
|
62
63
|
|
|
63
64
|
unitTest('the boot-owned keys are absent — the boot measures them, an app can only guess', () => {
|
|
64
|
-
|
|
65
|
+
expect(declared).toBeDefined();
|
|
65
66
|
for (const key of ['port', 'hostname', 'dev', 'buildId', 'signInPath']) {
|
|
66
|
-
expect({ key, declared: Object.hasOwn(declared, key) }).toEqual({ key, declared: false });
|
|
67
|
+
expect({ key, declared: Object.hasOwn(declared ?? {}, key) }).toEqual({ key, declared: false });
|
|
67
68
|
}
|
|
68
69
|
});
|
|
69
70
|
|
|
70
71
|
// The registration is process-global, so a suite that left it set would hand the next file this
|
|
71
|
-
// app's config. \`resetHttpConfig()\` is the seam
|
|
72
|
-
|
|
72
|
+
// app's config. \`resetHttpConfig()\` is the seam, and this is the one place it is called — after
|
|
73
|
+
// the cases, so none of them depends on running before it.
|
|
74
|
+
afterAll(() => {
|
|
73
75
|
resetHttpConfig();
|
|
74
76
|
expect(configuredHttp()).toBeUndefined();
|
|
75
77
|
});
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
import { ENV_EXAMPLE_PATH } from '@ultimat3/core';
|
|
8
8
|
import { REQUIRED_BUN } from '../app-root';
|
|
9
|
+
import type { CoverageExclude } from '../coverage-floor';
|
|
10
|
+
import { COVERAGE_BAR } from '../coverage-floor';
|
|
9
11
|
import { VERIFY_FLOOR_FILE } from '../verify-floor';
|
|
10
12
|
import type { VerifyStepName } from '../verify-step';
|
|
11
13
|
import type { GeneratedFile, NameSet } from './naming';
|
|
@@ -187,7 +189,7 @@ const appConfig = (
|
|
|
187
189
|
// a missing value fails the boot with the exact command that fixes it, never at the first request.
|
|
188
190
|
// A named export, never a default: the CLI and the runtime both import \`config\` by name.
|
|
189
191
|
import type { EnvSchema } from '@ultimat3/core';
|
|
190
|
-
import { defineConfig, defineEnv } from '@ultimat3/core';
|
|
192
|
+
import { defineConfig, defineEnv, defineMeasurementActor, userActor } from '@ultimat3/core';
|
|
191
193
|
|
|
192
194
|
${envDeclaration()}
|
|
193
195
|
|
|
@@ -227,6 +229,18 @@ export const config = defineConfig({
|
|
|
227
229
|
theme: { defaultMode: 'dark' },
|
|
228
230
|
ai: { mcp: { expose: true, path: '/mcp' } },
|
|
229
231
|
});
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Who \`x build\`'s budget pass renders \`app/\` as. A page that reads rows reads its actor's org
|
|
235
|
+
* through the typed handle, which refuses an actor with none (X_TENANCY_ACTOR_ORG_REQUIRED) — and
|
|
236
|
+
* the framework's default measurement actor is a \`service\` with no org, so such a route is never
|
|
237
|
+
* weighed (X_BUDGET_UNMEASURED). A member of the demo org is a declared viewer: no row has to
|
|
238
|
+
* exist. Loaded lazily, so this file's import graph stays the config's own.
|
|
239
|
+
*/
|
|
240
|
+
defineMeasurementActor(async () => {
|
|
241
|
+
const { DEMO_ORG_ID } = await import('./apps/web/shared/demo-org');
|
|
242
|
+
return userActor({ id: 'x-build-measure', orgId: DEMO_ORG_ID, roles: ['member'] });
|
|
243
|
+
});
|
|
230
244
|
`;
|
|
231
245
|
|
|
232
246
|
// `biome.json` is strict JSON — Biome's own parser rejects a `//` comment in it, which made every
|
|
@@ -330,8 +344,44 @@ const SCAFFOLD_FLOOR: readonly VerifyStepName[] = [
|
|
|
330
344
|
/** The two suites only the example slice writes a file for — a floor step with no file is red. */
|
|
331
345
|
const EXAMPLE_FLOOR: readonly VerifyStepName[] = ['live', 'job'];
|
|
332
346
|
|
|
347
|
+
/**
|
|
348
|
+
* What a unit test cannot execute in a scaffolded app, each with its reason — `x doctor` prints
|
|
349
|
+
* them. Two kinds and no more: a file here is a file the floor does not see, so the list is not
|
|
350
|
+
* a place to put code that merely has no test yet.
|
|
351
|
+
*/
|
|
352
|
+
export const SCAFFOLD_COVERAGE_EXCLUDE: readonly CoverageExclude[] = [
|
|
353
|
+
{
|
|
354
|
+
glob: 'apps/**/*.island.tsx',
|
|
355
|
+
why: 'an island runs in a browser: its test mounts a BUILT chunk (mountIsland), and Bun cannot map that chunk back to this source — its states are held by guards/island-without-states.ts and photographed by x shot --island',
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
glob: 'apps/*/server.ts',
|
|
359
|
+
why: 'the container entry point: it boots the roles and lives as long as the process — bin/dev and the image exercise it',
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
glob: 'apps/*/prerender.ts',
|
|
363
|
+
why: "the static build's entry point: x build --target static runs it, and the budgets step weighs what it writes",
|
|
364
|
+
},
|
|
365
|
+
];
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* The coverage floor every scaffolded app starts on: the bar the framework holds its own packages
|
|
369
|
+
* to (`COVERAGE_BAR`), not a softer one for apps. `scaffold-smoke` runs the app's own `x verify`,
|
|
370
|
+
* so the scaffold is green at it on what it generates or the job is red.
|
|
371
|
+
*/
|
|
333
372
|
const verifyFloor = (example: boolean): string =>
|
|
334
|
-
`${JSON.stringify(
|
|
373
|
+
`${JSON.stringify(
|
|
374
|
+
{
|
|
375
|
+
steps: example ? [...SCAFFOLD_FLOOR, ...EXAMPLE_FLOOR] : SCAFFOLD_FLOOR,
|
|
376
|
+
coverage: {
|
|
377
|
+
lines: COVERAGE_BAR,
|
|
378
|
+
funcs: COVERAGE_BAR,
|
|
379
|
+
exclude: SCAFFOLD_COVERAGE_EXCLUDE,
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
null,
|
|
383
|
+
2,
|
|
384
|
+
)}\n`;
|
|
335
385
|
|
|
336
386
|
const bunfig = (): string => `[test]
|
|
337
387
|
root = "."
|
|
@@ -6,18 +6,34 @@
|
|
|
6
6
|
import type { GeneratedFile } from './naming';
|
|
7
7
|
import { wrapList } from './wrap';
|
|
8
8
|
|
|
9
|
-
/**
|
|
10
|
-
|
|
11
|
-
* with it: \`x g resource post\` would have granted them, and the scaffold writes that result.
|
|
12
|
-
*/
|
|
13
|
-
const grants = (base: string, example: string | undefined): string =>
|
|
9
|
+
/** A role's grants as Biome prints them: one line when it fits, one grant per line when not. */
|
|
10
|
+
const grants = (list: readonly string[]): string =>
|
|
14
11
|
wrapList(
|
|
15
12
|
' ',
|
|
16
13
|
'grants: [',
|
|
17
|
-
|
|
14
|
+
list.map((grant) => `'${grant}'`),
|
|
18
15
|
'],',
|
|
19
16
|
);
|
|
20
17
|
|
|
18
|
+
/** The three gates `@ultimat3/admin` asks before any per-table one — `ADMIN_OPERATION_RULES`. */
|
|
19
|
+
const ADMIN_GATES = ['admin:read', 'admin:write', 'admin:destroy'] as const;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* What the two built-in screens with no table behind them ask beside `admin:read`: `/admin/jobs`
|
|
23
|
+
* and `/admin/audit`. Held by the role that runs the admin, or both links are a 403.
|
|
24
|
+
*/
|
|
25
|
+
const ADMIN_SCREENS = ['job:read', 'audit:read'] as const;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* What the admin asks for one table on the handle: `<table>:read|write|delete`. The example slice
|
|
29
|
+
* adds `posts`, so the scaffold writes the grants `x g resource post` would have — the same three
|
|
30
|
+
* `generate-grants.ts` adds for every entity generated later.
|
|
31
|
+
*/
|
|
32
|
+
const tableGrants = (table: string): readonly string[] =>
|
|
33
|
+
['read', 'write', 'delete'].map((verb) => `${table}:${verb}`);
|
|
34
|
+
|
|
35
|
+
const EXAMPLE_TABLE = 'posts';
|
|
36
|
+
|
|
21
37
|
const rolesSource = (
|
|
22
38
|
example: boolean,
|
|
23
39
|
): string => `// Who holds which permission, for the whole app. Roles are sugar: every one expands to a flat
|
|
@@ -33,6 +49,12 @@ const rolesSource = (
|
|
|
33
49
|
// \`x g policy <feature>\` declares \`<feature>:read\` and \`<feature>:write\` and grants them below —
|
|
34
50
|
// read to member, write to admin. A permission no role holds is one no actor can ever exercise,
|
|
35
51
|
// and \`x verify\`'s policy step refuses it (X_PERMISSION_UNGRANTED).
|
|
52
|
+
//
|
|
53
|
+
// The admin at /admin asks TWO permissions per operation: its own gate (\`admin:read\` to look,
|
|
54
|
+
// \`admin:write\` to create or edit, \`admin:destroy\` to delete) and the table's
|
|
55
|
+
// (\`<table>:read|write|delete\`); its jobs and audit screens ask \`job:read\` and \`audit:read\`. \`x g entity\` and \`x g resource\` declare and grant a new table's
|
|
56
|
+
// three to \`admin\` here. A view-only operator is a role holding \`admin:read\` and each
|
|
57
|
+
// \`<table>:read\` and nothing else: every write is refused by the same decision that hid its button.
|
|
36
58
|
|
|
37
59
|
import { definePermissions, defineRoles } from '@ultimat3/policy';
|
|
38
60
|
|
|
@@ -42,16 +64,30 @@ import { definePermissions, defineRoles } from '@ultimat3/policy';
|
|
|
42
64
|
// in silence while nothing declared it, so every scaffolded app answered HTTP 500 on /dashboard
|
|
43
65
|
// and /admin from its first \`x dev\`, under a green gate. A permission a role grants and a
|
|
44
66
|
// permission a route requires both belong here.
|
|
45
|
-
|
|
67
|
+
${wrapList(
|
|
68
|
+
'',
|
|
69
|
+
'export const appPermissions = definePermissions([',
|
|
70
|
+
[
|
|
71
|
+
...ADMIN_GATES,
|
|
72
|
+
...ADMIN_SCREENS,
|
|
73
|
+
'dashboard:read',
|
|
74
|
+
...(example ? tableGrants(EXAMPLE_TABLE) : []),
|
|
75
|
+
].map((permission) => `'${permission}'`),
|
|
76
|
+
']);',
|
|
77
|
+
)}
|
|
46
78
|
|
|
47
79
|
export const roles = defineRoles({
|
|
48
80
|
member: {
|
|
49
81
|
description: 'Signed in. Reads the app surface.',
|
|
50
|
-
${grants('dashboard:read', example ? 'post:read' :
|
|
82
|
+
${grants(['dashboard:read', ...(example ? ['post:read'] : [])])}
|
|
51
83
|
},
|
|
52
84
|
admin: {
|
|
53
85
|
description: 'Runs the app: the /admin surface, plus everything a member may do.',
|
|
54
|
-
${grants(
|
|
86
|
+
${grants([
|
|
87
|
+
...ADMIN_GATES,
|
|
88
|
+
...ADMIN_SCREENS,
|
|
89
|
+
...(example ? ['post:write', ...tableGrants(EXAMPLE_TABLE)] : []),
|
|
90
|
+
])}
|
|
55
91
|
inherits: ['member'],
|
|
56
92
|
},
|
|
57
93
|
});
|
|
@@ -85,6 +121,17 @@ unitTest('admin inherits every member grant and adds its own', () => {
|
|
|
85
121
|
for (const permission of member) expect(admin).toContain(permission);
|
|
86
122
|
});
|
|
87
123
|
|
|
124
|
+
// \`admin:read\` alone looks; a write needs \`admin:write\` and a delete \`admin:destroy\`. The admin
|
|
125
|
+
// role holds all three, and a member none — so "view-only" is a role, never a mode.
|
|
126
|
+
unitTest('the admin role holds all three admin gates, and a member none of them', () => {
|
|
127
|
+
const admin = expandRoles(['admin'], roles);
|
|
128
|
+
const member = expandRoles(['member'], roles);
|
|
129
|
+
for (const gate of ['admin:read', 'admin:write', 'admin:destroy']) {
|
|
130
|
+
expect(admin).toContain(gate);
|
|
131
|
+
expect(member).not.toContain(gate);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
88
135
|
unitTest('a role nobody declared grants nothing', () => {
|
|
89
136
|
expect(expandRoles(['visitor'], roles)).toEqual([]);
|
|
90
137
|
});
|
|
@@ -183,7 +183,7 @@ const shellStyle = (): string => `@use '@ultimat3/ui/tokens' as tokens;
|
|
|
183
183
|
block-size: tokens.space(2);
|
|
184
184
|
border-radius: tokens.radius('full');
|
|
185
185
|
background: tokens.role('success');
|
|
186
|
-
animation: pulse
|
|
186
|
+
animation: pulse calc(#{tokens.duration('slower')} * 4) tokens.easing('in-out') infinite;
|
|
187
187
|
}
|
|
188
188
|
|
|
189
189
|
// Opacity only — the one property family the motion guard admits, and the global reduced-motion
|
|
@@ -245,45 +245,25 @@ const shellStyle = (): string => `@use '@ultimat3/ui/tokens' as tokens;
|
|
|
245
245
|
`;
|
|
246
246
|
|
|
247
247
|
const shellTest =
|
|
248
|
-
(): string => `// The frame, rendered the way a route renders it
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
|
|
252
|
-
import {
|
|
253
|
-
import { renderToHtml } from '@ultimat3/render/server';
|
|
254
|
-
import { expect, unitTest } from '@ultimat3/testing';
|
|
255
|
-
|
|
256
|
-
// A DYNAMIC import, after the static ones above have run: importing \`@ultimat3/render\` is what
|
|
257
|
-
// installs the loader that compiles this app's \`.tsx\` to the framework's JSX factory, and a
|
|
258
|
-
// plugin only reaches modules loaded after it. Imported statically, \`shell.tsx\` is transpiled
|
|
259
|
-
// ahead of that install, to \`React.createElement\` against a global that does not exist — the
|
|
260
|
-
// route modules never meet this because \`x dev\` loads them after the framework.
|
|
261
|
-
//
|
|
262
|
-
// \`h\` is typed for the framework's own components; \`Shell\` carries Solid's JSX types. The cast
|
|
263
|
-
// is the seam itself — the assertions below are what check it at runtime.
|
|
264
|
-
const shell = (await import('./shell')).Shell as unknown as JsxComponent;
|
|
248
|
+
(): string => `// The frame, rendered the way a route renders it, with the app's own catalog registered by the
|
|
249
|
+
// import. What can go wrong is structural — the current page losing its \`aria-current\`, the
|
|
250
|
+
// page's actions landing outside the banner — and both are invisible to a typecheck.
|
|
251
|
+
import { expect, renderView, unitTest } from '@ultimat3/testing';
|
|
252
|
+
import { Shell } from './shell';
|
|
265
253
|
|
|
266
254
|
unitTest('the shell marks the current destination and keeps the page in <main>', async () => {
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
);
|
|
270
|
-
expect(html).toContain('aria-current="page"');
|
|
271
|
-
expect(html).toContain('href="/dashboard"');
|
|
255
|
+
const view = await renderView(Shell, { nav: 'dashboard', children: 'the page body' });
|
|
256
|
+
expect(view.html).toMatch(/<a href="\\/dashboard" aria-current="page"/);
|
|
272
257
|
// Landmarks come from AppShell: one banner, one navigation, one main, one contentinfo.
|
|
273
|
-
expect(html.match(/<main\\b/g)
|
|
274
|
-
expect(html.match(/<nav\\b/g)
|
|
275
|
-
expect(html).toMatch(/<main[^>]*>
|
|
258
|
+
expect(view.html.match(/<main\\b/g)).toHaveLength(1);
|
|
259
|
+
expect(view.html.match(/<nav\\b/g)).toHaveLength(1);
|
|
260
|
+
expect(view.html).toMatch(/<main[^>]*>the page body<\\/main>/);
|
|
276
261
|
});
|
|
277
262
|
|
|
278
263
|
unitTest('no nav marks nothing current, and the actions land in the header', async () => {
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
children: h('p', null, 'body'),
|
|
283
|
-
}),
|
|
284
|
-
);
|
|
285
|
-
expect(html).not.toContain('aria-current="page"');
|
|
286
|
-
expect(html).toMatch(/<header[^>]*>[\\s\\S]*data-role="action"[\\s\\S]*<\\/header>/);
|
|
264
|
+
const view = await renderView(Shell, { actions: 'the page actions', children: 'body' });
|
|
265
|
+
expect(view.html).not.toContain('aria-current="page"');
|
|
266
|
+
expect(view.html).toMatch(/<header[^>]*>[\\s\\S]*the page actions[\\s\\S]*<\\/header>/);
|
|
287
267
|
});
|
|
288
268
|
`;
|
|
289
269
|
|
|
@@ -379,20 +359,10 @@ const toggleTest =
|
|
|
379
359
|
|
|
380
360
|
import { join } from 'node:path';
|
|
381
361
|
import { buildIslands } from '@ultimat3/cli';
|
|
382
|
-
import {
|
|
383
|
-
afterAll,
|
|
384
|
-
beforeAll,
|
|
385
|
-
describe,
|
|
386
|
-
expect,
|
|
387
|
-
type MountedIsland,
|
|
388
|
-
mountIsland,
|
|
389
|
-
test,
|
|
390
|
-
} from '@ultimat3/testing';
|
|
362
|
+
import { describeIslandState, expect, test } from '@ultimat3/testing';
|
|
391
363
|
import { THEME_ATTRIBUTE, THEME_STORAGE_KEY } from '@ultimat3/ui';
|
|
392
364
|
import { bootedEnv } from './theme-toggle.island';
|
|
393
|
-
|
|
394
|
-
const APP_ROOT = join(import.meta.dir, ${upToAppRoot(SHELL_DIR)});
|
|
395
|
-
const ISLAND = '${SHELL_DIR}/theme-toggle.island.tsx';
|
|
365
|
+
import { themeToggleStates } from './theme-toggle.island.states';
|
|
396
366
|
|
|
397
367
|
/** What the island reads through \`browserThemeEnv\`: storage and the OS query, both fakes. */
|
|
398
368
|
const stored = new Map<string, string>();
|
|
@@ -407,42 +377,38 @@ const matchMedia = (): Record<string, unknown> => ({
|
|
|
407
377
|
removeEventListener: () => {},
|
|
408
378
|
});
|
|
409
379
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
shell: '<button type="button">theme</button>',
|
|
419
|
-
globals: { localStorage, matchMedia },
|
|
420
|
-
});
|
|
421
|
-
}, 60_000);
|
|
422
|
-
|
|
423
|
-
afterAll(() => {
|
|
424
|
-
mounted?.[Symbol.dispose]();
|
|
425
|
-
});
|
|
380
|
+
// The state \`x shot --island theme-toggle\` photographs is the state this mounts: one manifest,
|
|
381
|
+
// so the picture and the assertion cannot be of two different components.
|
|
382
|
+
const island = {
|
|
383
|
+
build: buildIslands,
|
|
384
|
+
root: join(import.meta.dir, ${upToAppRoot(SHELL_DIR)}),
|
|
385
|
+
shell: '<button type="button">theme</button>',
|
|
386
|
+
globals: { localStorage, matchMedia },
|
|
387
|
+
};
|
|
426
388
|
|
|
427
|
-
|
|
428
|
-
test('
|
|
429
|
-
|
|
430
|
-
|
|
389
|
+
describeIslandState(themeToggleStates, 'default', island, (mounted) => {
|
|
390
|
+
test('the default state renders the catalog control over the server shell', () => {
|
|
391
|
+
// \`aria-pressed\` is the live control's: the shell's own button carries none, so a mount that
|
|
392
|
+
// rendered nothing leaves this selector with no match.
|
|
393
|
+
expect(mounted().find('button[aria-pressed]')).not.toBeNull();
|
|
394
|
+
expect(mounted().code).not.toMatch(/\\bReact\\b/);
|
|
395
|
+
// The state's own prop reached the island: the provider stamps the locale it was handed.
|
|
396
|
+
expect(mounted().documentElement.getAttribute('lang')).toBe('en');
|
|
431
397
|
});
|
|
432
398
|
|
|
433
399
|
test('a click stores the opposite theme and applies it to <html>', () => {
|
|
434
400
|
// No stamp on this document, so the island booted light; the click flips it. \`false\` means
|
|
435
401
|
// no handler ran — an onClick that never reached the DOM looks identical to a selector typo.
|
|
436
|
-
expect(mounted.fire('button', 'click')).toBe(true);
|
|
402
|
+
expect(mounted().fire('button', 'click')).toBe(true);
|
|
437
403
|
expect(stored.get(THEME_STORAGE_KEY)).toBe('dark');
|
|
438
|
-
expect(mounted.documentElement.getAttribute(THEME_ATTRIBUTE)).toBe('dark');
|
|
404
|
+
expect(mounted().documentElement.getAttribute(THEME_ATTRIBUTE)).toBe('dark');
|
|
439
405
|
});
|
|
440
406
|
|
|
441
407
|
test('the env the island builds starts from the theme stamped on <html>', () => {
|
|
442
408
|
// The fake document is still installed here, so this reads exactly what \`mount\` read.
|
|
443
|
-
mounted.documentElement.setAttribute(THEME_ATTRIBUTE, 'dark');
|
|
409
|
+
mounted().documentElement.setAttribute(THEME_ATTRIBUTE, 'dark');
|
|
444
410
|
expect(bootedEnv().prefersDark()).toBe(true);
|
|
445
|
-
mounted.documentElement.setAttribute(THEME_ATTRIBUTE, 'light');
|
|
411
|
+
mounted().documentElement.setAttribute(THEME_ATTRIBUTE, 'light');
|
|
446
412
|
expect(bootedEnv().prefersDark()).toBe(false);
|
|
447
413
|
});
|
|
448
414
|
});
|