@astrale-os/cli 1.0.0-beta.3 → 1.0.0-beta.31
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/README.md +84 -6
- package/dist/astrale.js +57586 -60753
- package/dist/public/connect-core.js +2158 -30706
- package/dist/public/keys/index.js +1016 -29541
- package/dist/public/paths/index.js +370 -28654
- package/dist/types/admin/contract.d.ts +26 -0
- package/dist/types/admin/instance/client.d.ts +20 -0
- package/dist/types/admin/instance/index.d.ts +2 -0
- package/dist/types/admin/instance/model.d.ts +6 -10
- package/dist/types/connection/auth.d.ts +3 -0
- package/dist/types/connection/call.d.ts +3 -0
- package/dist/types/connection/command.d.ts +27 -0
- package/dist/types/connection/credential.d.ts +14 -0
- package/dist/types/connection/errors.d.ts +1 -0
- package/dist/types/connection/exchange.d.ts +9 -0
- package/dist/types/connection/failure/classify.d.ts +2 -0
- package/dist/types/connection/failure/index.d.ts +5 -0
- package/dist/types/connection/failure/model.d.ts +25 -0
- package/dist/types/connection/failure/render.d.ts +3 -0
- package/dist/types/connection/index.d.ts +8 -0
- package/dist/types/connection/reasons.d.ts +37 -0
- package/dist/types/connection/self.d.ts +37 -0
- package/dist/types/connection/session.d.ts +31 -0
- package/dist/types/connection/target.d.ts +38 -0
- package/dist/types/errors.d.ts +1 -1
- package/dist/types/lib/admin-instance.d.ts +17 -0
- package/dist/types/lib/admin-target.d.ts +11 -36
- package/dist/types/lib/config.d.ts +13 -59
- package/dist/types/lib/failure-debug.d.ts +1 -0
- package/dist/types/lib/idp.d.ts +25 -173
- package/dist/types/lib/instance-target.d.ts +1 -1
- package/dist/types/lib/instance.d.ts +30 -88
- package/dist/types/lib/log.d.ts +5 -3
- package/dist/types/lib/output.d.ts +1 -1
- package/dist/types/lib/proc.d.ts +43 -0
- package/dist/types/lib/self.d.ts +9 -0
- package/dist/types/lib/update.d.ts +109 -0
- package/dist/types/lib/validation.d.ts +4 -1
- package/dist/types/state/exchange-credentials.d.ts +6 -2
- package/dist/types/state/files.d.ts +2 -0
- package/dist/types/state/index.d.ts +3 -2
- package/dist/types/state/paths.d.ts +2 -0
- package/dist/types/state/session-routes.d.ts +10 -0
- package/package.json +16 -19
- package/src/__tests__/fixtures/publication.ts +20 -0
- package/src/admin/.spec/architecture.md +12 -5
- package/src/admin/__tests__/fixture.ts +25 -0
- package/src/admin/catalog/.spec/api.d.ts +1 -3
- package/src/admin/catalog/.spec/architecture.md +5 -4
- package/src/admin/catalog/__tests__/client.test.ts +143 -69
- package/src/admin/catalog/client.ts +40 -54
- package/src/admin/catalog/model.ts +3 -4
- package/src/admin/contract.ts +47 -0
- package/src/admin/graph/.spec/api.d.ts +4 -10
- package/src/admin/graph/nodes.ts +1 -1
- package/src/admin/instance/.spec/api.d.ts +5 -9
- package/src/admin/instance/.spec/architecture.md +7 -7
- package/src/admin/instance/__tests__/client.test.ts +157 -175
- package/src/admin/instance/client.ts +39 -128
- package/src/admin/instance/index.ts +1 -2
- package/src/admin/instance/model.ts +12 -18
- package/src/commands/__tests__/admin-instance.test.ts +11 -8
- package/src/commands/__tests__/call-describe.test.ts +46 -33
- package/src/commands/__tests__/call.test.ts +34 -0
- package/src/commands/__tests__/domain-install-operation.test.ts +30 -22
- package/src/commands/__tests__/domain-install-owned.test.ts +1 -1
- package/src/commands/__tests__/domain-list.test.ts +5 -25
- package/src/commands/__tests__/install-direct.test.ts +115 -8
- package/src/commands/__tests__/install-identity-override.test.ts +6 -17
- package/src/commands/__tests__/instance-list-rows.test.ts +1 -1
- package/src/commands/__tests__/instance-status.test.ts +139 -0
- package/src/commands/__tests__/introspect-parse.test.ts +80 -1
- package/src/commands/__tests__/logs.test.ts +250 -2
- package/src/commands/__tests__/read-commands.test.ts +119 -11
- package/src/commands/__tests__/token-ttl.test.ts +49 -4
- package/src/commands/__tests__/update.test.ts +25 -3
- package/src/commands/__tests__/view.test.ts +50 -2
- package/src/commands/auth/logout.ts +2 -1
- package/src/commands/browser.ts +29 -0
- package/src/commands/call-describe.ts +60 -68
- package/src/commands/call.ts +71 -59
- package/src/commands/domain/install.ts +73 -46
- package/src/commands/domain/list.ts +1 -1
- package/src/commands/domain/publish.ts +14 -11
- package/src/commands/domain/uninstall.ts +2 -2
- package/src/commands/get.ts +15 -16
- package/src/commands/identity/__tests__/register.test.ts +3 -3
- package/src/commands/identity/register.ts +8 -8
- package/src/commands/instance/create.ts +8 -16
- package/src/commands/instance/delete.ts +12 -9
- package/src/commands/instance/list.ts +2 -2
- package/src/commands/instance/status.ts +66 -20
- package/src/commands/instance/use.ts +16 -12
- package/src/commands/introspect.ts +53 -41
- package/src/commands/logs.ts +123 -36
- package/src/commands/mutate.ts +6 -5
- package/src/commands/query.ts +39 -36
- package/src/commands/session/analyze.ts +26 -4
- package/src/commands/token.ts +42 -27
- package/src/commands/ui/__tests__/commands.test.ts +152 -0
- package/src/commands/ui/add.ts +51 -0
- package/src/commands/ui/doctor.ts +21 -0
- package/src/commands/ui/init.ts +38 -0
- package/src/commands/ui/list.ts +26 -0
- package/src/commands/ui/preset-apply.ts +19 -0
- package/src/commands/ui/preset-list.ts +12 -0
- package/src/commands/ui/shared.ts +25 -0
- package/src/commands/update.ts +90 -48
- package/src/commands/view.ts +82 -37
- package/src/connection/.spec/api.d.ts +7 -8
- package/src/connection/.spec/architecture.md +14 -5
- package/src/connection/.spec/flows/session.ts +2 -2
- package/src/connection/.spec/laws/connection.ts +6 -2
- package/src/connection/.spec/layout.ts +1 -0
- package/src/connection/__tests__/auth.test.ts +1 -0
- package/src/connection/__tests__/credential.test.ts +21 -1
- package/src/connection/__tests__/errors.test.ts +27 -20
- package/src/connection/__tests__/exchange.test.ts +64 -11
- package/src/connection/__tests__/failure-fixtures.ts +39 -0
- package/src/connection/__tests__/failure-safety.test.ts +75 -0
- package/src/connection/__tests__/self.test.ts +31 -11
- package/src/connection/__tests__/session.test.ts +8 -4
- package/src/connection/__tests__/target.test.ts +2 -0
- package/src/connection/auth.ts +13 -0
- package/src/connection/call.ts +1 -1
- package/src/connection/credential.ts +2 -2
- package/src/connection/errors.ts +1 -355
- package/src/connection/exchange.ts +110 -61
- package/src/connection/failure/classify.ts +110 -0
- package/src/connection/failure/index.ts +18 -0
- package/src/connection/failure/model.ts +28 -0
- package/src/connection/failure/render.ts +116 -0
- package/src/connection/reasons.ts +16 -0
- package/src/connection/self.ts +39 -21
- package/src/connection/session.ts +27 -9
- package/src/errors.ts +3 -2
- package/src/graph/.spec/api.d.ts +13 -6
- package/src/graph/.spec/laws/documents.ts +7 -7
- package/src/graph/.spec/layout.ts +9 -1
- package/src/graph/__tests__/mutation.test.ts +2 -2
- package/src/graph/__tests__/query.test.ts +35 -9
- package/src/graph/class.ts +30 -0
- package/src/graph/index.ts +1 -0
- package/src/graph/query.ts +27 -40
- package/src/identity/.spec/api.d.ts +2 -2
- package/src/identity/__tests__/fixtures/registry-journey.ts +13 -1
- package/src/identity/__tests__/registration.test.ts +1 -1
- package/src/identity/__tests__/registry.test.ts +4 -0
- package/src/identity/registration.ts +3 -3
- package/src/identity/registry.ts +2 -0
- package/src/keys/__tests__/keys.test.ts +4 -0
- package/src/keys/credential.ts +1 -0
- package/src/lib/__tests__/admin-target.test.ts +12 -0
- package/src/lib/__tests__/binary.test.ts +16 -1
- package/src/lib/__tests__/browser-retention.test.ts +212 -0
- package/src/lib/__tests__/command-dx.test.ts +1 -1
- package/src/lib/__tests__/config.test.ts +27 -0
- package/src/lib/__tests__/domain-publication.test.ts +10 -16
- package/src/lib/__tests__/idp.test.ts +34 -4
- package/src/lib/__tests__/instance-candidates.test.ts +1 -3
- package/src/lib/__tests__/instance-target.test.ts +7 -39
- package/src/lib/__tests__/log-errors.test.ts +64 -0
- package/src/lib/__tests__/skills.test.ts +834 -0
- package/src/lib/__tests__/studio-server-deps.test.ts +1 -1
- package/src/lib/__tests__/update.test.ts +264 -11
- package/src/lib/__tests__/view-assets.test.ts +25 -0
- package/src/lib/__tests__/view-external-open-intent.test.ts +150 -0
- package/src/lib/__tests__/view-external-open-origins.test.ts +27 -0
- package/src/lib/__tests__/view-open-intent.test.ts +0 -2
- package/src/lib/__tests__/view-server.test.ts +26 -3
- package/src/lib/__tests__/view-session.test.ts +2 -1
- package/src/lib/admin-instance.ts +14 -8
- package/src/lib/admin-target.ts +21 -2
- package/src/lib/binary.ts +22 -5
- package/src/lib/browser-retention.ts +210 -0
- package/src/lib/command-dx.ts +9 -9
- package/src/lib/config.ts +12 -1
- package/src/lib/domain-publication.ts +9 -7
- package/src/lib/failure-debug.ts +26 -0
- package/src/lib/idp.ts +34 -3
- package/src/lib/instance-candidates.ts +2 -2
- package/src/lib/instance-target.ts +3 -14
- package/src/lib/instance.ts +16 -11
- package/src/lib/log.ts +16 -9
- package/src/lib/output.ts +1 -1
- package/src/lib/proc.ts +7 -2
- package/src/lib/provision-instance.ts +34 -57
- package/src/lib/skills/lock.ts +117 -0
- package/src/lib/skills/sync.ts +878 -0
- package/src/lib/skills.ts +1 -52
- package/src/lib/update.ts +298 -45
- package/src/lib/validation.ts +2 -2
- package/src/lib/view/assets.ts +11 -2
- package/src/lib/view/external-open-intent.ts +41 -0
- package/src/lib/view/external-open-origins.ts +37 -0
- package/src/lib/view/host-capabilities.ts +17 -0
- package/src/lib/view/resolve.ts +1 -1
- package/src/lib/view/server.ts +18 -3
- package/src/lib/view/session.ts +2 -0
- package/src/program/.spec/api.d.ts +1 -0
- package/src/program/__tests__/program.test.ts +201 -7
- package/src/program/argv.ts +14 -0
- package/src/program/build.ts +26 -4
- package/src/program/command.ts +1 -0
- package/src/program/index.ts +1 -0
- package/src/program/registry.ts +2 -1
- package/src/setup/__tests__/instance-step.test.ts +27 -10
- package/src/setup/steps/instance.ts +22 -15
- package/src/setup/steps/skills.ts +40 -38
- package/src/state/.spec/api.d.ts +21 -2
- package/src/state/.spec/architecture.md +18 -4
- package/src/state/.spec/layout.ts +1 -0
- package/src/state/__tests__/exchange-credentials.test.ts +88 -42
- package/src/state/__tests__/files.test.ts +24 -1
- package/src/state/__tests__/fixtures/session-route-process.ts +93 -0
- package/src/state/__tests__/paths.test.ts +1 -0
- package/src/state/__tests__/session-routes.test.ts +138 -0
- package/src/state/exchange-credentials.ts +22 -9
- package/src/state/files.ts +41 -0
- package/src/state/index.ts +3 -1
- package/src/state/paths.ts +3 -0
- package/src/state/session-routes.ts +34 -0
- package/src/telemetry/__tests__/analyze-log.test.ts +38 -0
- package/src/telemetry/__tests__/recorder.test.ts +1 -0
- package/src/telemetry/__tests__/retention.test.ts +267 -0
- package/src/telemetry/__tests__/settings.test.ts +102 -0
- package/src/telemetry/__tests__/store-scan.test.ts +128 -0
- package/src/telemetry/__tests__/trigger.test.ts +33 -4
- package/src/telemetry/analyze.ts +24 -2
- package/src/telemetry/recorder.ts +15 -4
- package/src/telemetry/retention.ts +176 -0
- package/src/telemetry/session.ts +18 -12
- package/src/telemetry/settings.ts +64 -11
- package/src/telemetry/store.ts +92 -9
- package/src/telemetry/trigger.ts +16 -28
- package/src/ui/.spec/api.d.ts +14 -0
- package/src/ui/.spec/architecture.md +14 -0
- package/src/ui/.spec/laws.ts +50 -0
- package/src/ui/.spec/layout.ts +16 -0
- package/src/ui/__tests__/ui.test.ts +1742 -0
- package/src/ui/index.ts +13 -0
- package/src/ui/lock.ts +89 -0
- package/src/ui/model.ts +83 -0
- package/src/ui/operations.ts +1044 -0
- package/src/ui/project.ts +333 -0
- package/src/ui/release.ts +398 -0
- package/src/ui/runner.ts +18 -0
- package/studio/client/dist/assets/elk-api-DjmKsHXc.js +1 -0
- package/studio/client/dist/assets/index-BFVFs_8x.js +81 -0
- package/studio/client/dist/assets/index-DuB9iUdu.css +2 -0
- package/studio/client/dist/assets/rolldown-runtime-Dd_uD5pT.js +1 -0
- package/studio/client/dist/assets/schema-studio--a0kX3QU.css +1 -0
- package/studio/client/dist/assets/schema-studio-DH6oEWME.js +8 -0
- package/studio/client/dist/index.html +4 -3
- package/studio/package.json +8 -14
- package/studio/server/agent/ask.test.ts +1 -1
- package/studio/server/agent/bridge/grant.test.ts +1 -1
- package/studio/server/agent/bridge/routes.test.ts +1 -1
- package/studio/server/agent/harness/claude/adapter.test.ts +15 -5
- package/studio/server/agent/harness/gateway/token.test.ts +1 -1
- package/studio/server/agent/harness/gateway/token.ts +3 -3
- package/studio/server/agent/prompts/anchors.test.ts +41 -32
- package/studio/server/agent/prompts/anchors.ts +23 -51
- package/studio/server/agent/prompts/system.test.ts +4 -4
- package/studio/server/agent/prompts/system.ts +5 -5
- package/studio/server/agent/routes.test.ts +1 -1
- package/studio/server/agent/run/coordinator.test.ts +1 -1
- package/studio/server/agent/run/preparation.ts +1 -1
- package/studio/server/api/canvas.ts +0 -1
- package/studio/server/api/context.ts +1 -1
- package/studio/server/api/deployment.ts +1 -1
- package/studio/server/api/views.ts +2 -2
- package/studio/server/api/workspace.ts +1 -1
- package/studio/server/api-agent-loadout.test.ts +1 -1
- package/studio/server/api.test.ts +1 -1
- package/studio/server/cache.test.ts +24 -18
- package/studio/server/cache.ts +10 -54
- package/studio/server/cli-consumers.test.ts +32 -4
- package/studio/server/cli.test.ts +2 -2
- package/studio/server/domain.test.ts +50 -68
- package/studio/server/domain.ts +82 -63
- package/studio/server/handoff/copy.ts +2 -2
- package/studio/server/index.ts +1 -1
- package/studio/server/instances/probe.test.ts +1 -1
- package/studio/server/introspect/anatomy/views/routes.ts +46 -37
- package/studio/server/introspect/anatomy/views.ts +10 -16
- package/studio/server/introspect/anatomy-extras.test.ts +29 -56
- package/studio/server/introspect/anatomy.test.ts +22 -14
- package/studio/server/introspect/anatomy.ts +6 -9
- package/studio/server/introspect/bundle.ts +0 -3
- package/studio/server/introspect/canonical-schema.test.ts +155 -424
- package/studio/server/introspect/canonical-schema.ts +248 -601
- package/studio/server/introspect/core.ts +14 -22
- package/studio/server/introspect/diff.test.ts +21 -9
- package/studio/server/introspect/diff.ts +19 -64
- package/studio/server/introspect/extractor.ts +23 -122
- package/studio/server/introspect/overlay-tsmorph.test.ts +116 -241
- package/studio/server/introspect/overlay-tsmorph.ts +0 -1
- package/studio/server/introspect/overlay.ts +3 -51
- package/studio/server/introspect/revision.test.ts +24 -28
- package/studio/server/introspect/revision.ts +11 -22
- package/studio/server/introspect/runtime.test.ts +77 -195
- package/studio/server/introspect/runtime.ts +5 -67
- package/studio/server/introspect/schema-ir-json.test.ts +81 -0
- package/studio/server/introspect/schema-ir-json.ts +39 -69
- package/studio/server/introspect/schema-refs.test.ts +43 -88
- package/studio/server/introspect/schema-refs.ts +14 -49
- package/studio/server/introspect/source-overlay/handlers.ts +116 -636
- package/studio/server/introspect/source-overlay/kernel-calls.ts +0 -3
- package/studio/server/introspect/source-overlay/spans.ts +15 -45
- package/studio/server/lifecycle.ts +4 -1
- package/studio/server/state/baseline.test.ts +14 -9
- package/studio/server/state/baseline.ts +3 -5
- package/studio/server/state/documents.test.ts +69 -0
- package/studio/server/state/documents.ts +57 -10
- package/studio/server/state/visibility.ts +1 -5
- package/studio/server/views/model.ts +3 -3
- package/studio/server/views/target.test.ts +18 -76
- package/studio/server/views/target.ts +25 -51
- package/studio/server/watch.test.ts +11 -11
- package/studio/server/watch.ts +8 -4
- package/studio/server/workspace/updates.ts +23 -1
- package/studio/server/workspace-watch.ts +2 -2
- package/studio/shared/contracts/runtime.ts +4 -0
- package/studio/shared/contracts/schema.ts +44 -131
- package/studio/shared/contracts/surface.test.ts +15 -17
- package/studio/shared/contracts/workspace.ts +5 -11
- package/studio/shared/schema/identity.test.ts +20 -44
- package/studio/shared/schema/identity.ts +9 -24
- package/studio/tsconfig.json +2 -1
- package/tsconfig.json +1 -1
- package/viewer/dist/index.html +21 -0
- package/viewer/dist/main.js +41 -62
- package/.check-workspace.cjs +0 -40
- package/src/admin/binding.ts +0 -168
- package/src/commands/__tests__/instance-create-hosts.test.ts +0 -29
- package/studio/client/dist/assets/elk-api-lwhFo7vB.js +0 -1
- package/studio/client/dist/assets/index-B-c-smo9.js +0 -8
- package/studio/client/dist/assets/index-BckHuAWk.js +0 -81
- package/studio/client/dist/assets/index-C4aNQ6dz.css +0 -1
- package/studio/client/dist/assets/index-ChOr3yP0.css +0 -1
- package/studio/server/introspect/anatomy/views/legacy.ts +0 -232
- package/studio/server/introspect/core-extractor.ts +0 -139
- package/studio/server/introspect/overlay.test.ts +0 -72
- package/studio/server/introspect/source-overlay/annotations.ts +0 -14
|
@@ -0,0 +1,1742 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { chmod, mkdtemp, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
|
|
6
|
+
import { digest, parseUiLock } from '../lock'
|
|
7
|
+
import { UiError, type UiLock, type UiRegistry } from '../model'
|
|
8
|
+
import { addUi, applyPreset, doctorUi, initUi, listUi } from '../operations'
|
|
9
|
+
import { resolveUiRelease } from '../release'
|
|
10
|
+
import { shadcnInvocation } from '../runner'
|
|
11
|
+
|
|
12
|
+
const commit = 'a'.repeat(40)
|
|
13
|
+
const registry: UiRegistry = {
|
|
14
|
+
name: 'astrale-ui',
|
|
15
|
+
items: [
|
|
16
|
+
{
|
|
17
|
+
name: 'pattern-chart-line-basic',
|
|
18
|
+
type: 'registry:block',
|
|
19
|
+
description: 'A controlled chart.',
|
|
20
|
+
files: [
|
|
21
|
+
{
|
|
22
|
+
path: 'line-basic.tsx',
|
|
23
|
+
type: 'registry:component',
|
|
24
|
+
target: 'components/astrale/pattern/chart/line-basic.tsx',
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
meta: { canonicalAddress: 'pattern/chart/line/basic' },
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
}
|
|
31
|
+
const componentRegistry: UiRegistry = {
|
|
32
|
+
name: 'astrale-ui',
|
|
33
|
+
items: [
|
|
34
|
+
{
|
|
35
|
+
name: 'component-sidebar',
|
|
36
|
+
type: 'registry:component',
|
|
37
|
+
title: 'Component · sidebar',
|
|
38
|
+
dependencies: ['@astrale-os/ui@^0.3.0-beta.0', 'lucide-react@1.32.0'],
|
|
39
|
+
files: [
|
|
40
|
+
{
|
|
41
|
+
path: 'sidebar.tsx',
|
|
42
|
+
type: 'registry:component',
|
|
43
|
+
target: 'components/astrale/component/sidebar/sidebar.tsx',
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
path: 'use-mobile.ts',
|
|
47
|
+
type: 'registry:hook',
|
|
48
|
+
target: 'components/astrale/component/sidebar/use-mobile.ts',
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
meta: { canonicalAddress: 'component/sidebar', ownership: 'consumer-source' },
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
}
|
|
55
|
+
const themeCss = `/* Generated from observatory.astrale-theme.json. Consumer-owned after installation. */
|
|
56
|
+
:root,
|
|
57
|
+
[data-ui-theme='observatory'] {
|
|
58
|
+
--ui-background: #ffffff;
|
|
59
|
+
--ui-foreground: #111111;
|
|
60
|
+
--ui-card: #ffffff;
|
|
61
|
+
--ui-card-foreground: #111111;
|
|
62
|
+
--ui-popover: #ffffff;
|
|
63
|
+
--ui-popover-foreground: #111111;
|
|
64
|
+
--ui-primary: #2244aa;
|
|
65
|
+
--ui-primary-foreground: #ffffff;
|
|
66
|
+
--ui-secondary: #eeeeee;
|
|
67
|
+
--ui-secondary-foreground: #111111;
|
|
68
|
+
--ui-muted: #eeeeee;
|
|
69
|
+
--ui-muted-foreground: #555555;
|
|
70
|
+
--ui-accent: #ddeeff;
|
|
71
|
+
--ui-accent-foreground: #111111;
|
|
72
|
+
--ui-destructive: #aa2222;
|
|
73
|
+
--ui-destructive-foreground: #ffffff;
|
|
74
|
+
--ui-border: #dddddd;
|
|
75
|
+
--ui-input: #dddddd;
|
|
76
|
+
--ui-ring: #2244aa;
|
|
77
|
+
--ui-chart-1: #2244aa;
|
|
78
|
+
--ui-chart-2: #228844;
|
|
79
|
+
--ui-chart-3: #aa7722;
|
|
80
|
+
--ui-chart-4: #aa2222;
|
|
81
|
+
--ui-chart-5: #7722aa;
|
|
82
|
+
--ui-font-body: ui-sans-serif;
|
|
83
|
+
--ui-font-heading: ui-serif;
|
|
84
|
+
--ui-radius: 0.5rem;
|
|
85
|
+
--ui-radius-panel: 0.75rem;
|
|
86
|
+
--ui-control-height: 2.25rem;
|
|
87
|
+
--ui-control-height-sm: 2rem;
|
|
88
|
+
--ui-control-height-lg: 2.5rem;
|
|
89
|
+
--ui-shadow-control: none;
|
|
90
|
+
--ui-shadow-panel: none;
|
|
91
|
+
--ui-motion-fast: 120ms;
|
|
92
|
+
--ui-motion-standard: 180ms;
|
|
93
|
+
}
|
|
94
|
+
[data-ui-theme='observatory'].dark {
|
|
95
|
+
--ui-background: #111111;
|
|
96
|
+
--ui-foreground: #ffffff;
|
|
97
|
+
--ui-card: #191919;
|
|
98
|
+
--ui-card-foreground: #ffffff;
|
|
99
|
+
--ui-popover: #191919;
|
|
100
|
+
--ui-popover-foreground: #ffffff;
|
|
101
|
+
--ui-primary: #88aaff;
|
|
102
|
+
--ui-primary-foreground: #111111;
|
|
103
|
+
--ui-secondary: #292929;
|
|
104
|
+
--ui-secondary-foreground: #ffffff;
|
|
105
|
+
--ui-muted: #292929;
|
|
106
|
+
--ui-muted-foreground: #bbbbbb;
|
|
107
|
+
--ui-accent: #334455;
|
|
108
|
+
--ui-accent-foreground: #ffffff;
|
|
109
|
+
--ui-destructive: #ff7777;
|
|
110
|
+
--ui-destructive-foreground: #111111;
|
|
111
|
+
--ui-border: #333333;
|
|
112
|
+
--ui-input: #333333;
|
|
113
|
+
--ui-ring: #88aaff;
|
|
114
|
+
--ui-chart-1: #88aaff;
|
|
115
|
+
--ui-chart-2: #77dd99;
|
|
116
|
+
--ui-chart-3: #ffcc77;
|
|
117
|
+
--ui-chart-4: #ff7777;
|
|
118
|
+
--ui-chart-5: #cc88ff;
|
|
119
|
+
--ui-font-body: ui-sans-serif;
|
|
120
|
+
--ui-font-heading: ui-serif;
|
|
121
|
+
--ui-radius: 0.5rem;
|
|
122
|
+
--ui-radius-panel: 0.75rem;
|
|
123
|
+
--ui-control-height: 2.25rem;
|
|
124
|
+
--ui-control-height-sm: 2rem;
|
|
125
|
+
--ui-control-height-lg: 2.5rem;
|
|
126
|
+
--ui-shadow-control: none;
|
|
127
|
+
--ui-shadow-panel: none;
|
|
128
|
+
--ui-motion-fast: 120ms;
|
|
129
|
+
--ui-motion-standard: 180ms;
|
|
130
|
+
}
|
|
131
|
+
`
|
|
132
|
+
const themeRegistry: UiRegistry = {
|
|
133
|
+
name: 'astrale-ui',
|
|
134
|
+
items: [
|
|
135
|
+
{
|
|
136
|
+
name: 'theme-observatory',
|
|
137
|
+
type: 'registry:theme',
|
|
138
|
+
description: 'An owned theme.',
|
|
139
|
+
dependencies: ['@astrale-os/ui@^0.3.0-beta.0'],
|
|
140
|
+
files: [
|
|
141
|
+
{
|
|
142
|
+
path: 'observatory.css',
|
|
143
|
+
type: 'registry:file',
|
|
144
|
+
target: 'components/astrale/theme/observatory.css',
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
meta: { canonicalAddress: 'theme/observatory', ownership: 'consumer-source' },
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
}
|
|
151
|
+
const compatibility = {
|
|
152
|
+
version: 1,
|
|
153
|
+
shadcn: '4.18.0',
|
|
154
|
+
base: 'base',
|
|
155
|
+
style: 'nova',
|
|
156
|
+
baseUi: '1.7.0',
|
|
157
|
+
react: '^18.3.1 || ^19.0.0',
|
|
158
|
+
tailwind: '^4.3.3',
|
|
159
|
+
presets: ['astrale', 'compact', 'expressive'],
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const temporary: string[] = []
|
|
163
|
+
|
|
164
|
+
afterEach(async () => {
|
|
165
|
+
await Promise.all(
|
|
166
|
+
temporary.splice(0).map((directory) => rm(directory, { recursive: true, force: true })),
|
|
167
|
+
)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
function mockFetch(seen: string[] = [], suppliedRegistry: UiRegistry = registry): typeof fetch {
|
|
171
|
+
return (async (input: string | URL | Request) => {
|
|
172
|
+
const url = String(input)
|
|
173
|
+
seen.push(url)
|
|
174
|
+
if (url.includes('/git/ref/tags/')) {
|
|
175
|
+
return Response.json({ object: { type: 'commit', sha: commit, url: '' } })
|
|
176
|
+
}
|
|
177
|
+
if (url.endsWith('/tooling/compatibility.json')) return Response.json(compatibility)
|
|
178
|
+
if (url.endsWith('/' + commit + '/registry.json')) {
|
|
179
|
+
return Response.json({
|
|
180
|
+
name: 'astrale-ui',
|
|
181
|
+
include: ['registry/base/registry.json', 'registry/patterns/chart/registry.json'],
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
if (url.endsWith('/registry/base/registry.json')) {
|
|
185
|
+
return Response.json({ items: [{ name: 'astrale-base', type: 'registry:base' }] })
|
|
186
|
+
}
|
|
187
|
+
if (url.endsWith('/registry/patterns/chart/registry.json'))
|
|
188
|
+
return Response.json(suppliedRegistry)
|
|
189
|
+
const item = suppliedRegistry.items.find((candidate) =>
|
|
190
|
+
url.endsWith('/registry/public/r/' + candidate.name + '.json'),
|
|
191
|
+
)
|
|
192
|
+
if (item) return Response.json(builtItem(item))
|
|
193
|
+
return new Response('not found', { status: 404 })
|
|
194
|
+
}) as typeof fetch
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function themeFetch(seen: string[] = []): typeof fetch {
|
|
198
|
+
return (async (input: string | URL | Request) => {
|
|
199
|
+
const url = String(input)
|
|
200
|
+
seen.push(url)
|
|
201
|
+
if (url.includes('/git/ref/tags/')) {
|
|
202
|
+
return Response.json({ object: { type: 'commit', sha: commit, url: '' } })
|
|
203
|
+
}
|
|
204
|
+
if (url.endsWith('/tooling/compatibility.json')) return Response.json(compatibility)
|
|
205
|
+
if (url.endsWith('/' + commit + '/registry.json')) {
|
|
206
|
+
return Response.json({ name: 'astrale-ui', include: ['registry/themes/registry.json'] })
|
|
207
|
+
}
|
|
208
|
+
if (url.endsWith('/registry/themes/registry.json')) return Response.json(themeRegistry)
|
|
209
|
+
if (url.endsWith('/registry/public/r/theme-observatory.json')) {
|
|
210
|
+
return Response.json(builtThemeItem())
|
|
211
|
+
}
|
|
212
|
+
return new Response('not found', { status: 404 })
|
|
213
|
+
}) as typeof fetch
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function builtThemeItem() {
|
|
217
|
+
return {
|
|
218
|
+
...themeRegistry.items[0],
|
|
219
|
+
files: [
|
|
220
|
+
{
|
|
221
|
+
...themeRegistry.items[0]!.files[0],
|
|
222
|
+
path: 'registry/themes/observatory.css',
|
|
223
|
+
content: themeCss,
|
|
224
|
+
},
|
|
225
|
+
],
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function builtItem(item: UiRegistry['items'][number]) {
|
|
230
|
+
return {
|
|
231
|
+
...item,
|
|
232
|
+
files: item.files.map((file, index) => ({
|
|
233
|
+
...file,
|
|
234
|
+
path: `registry/patterns/chart/${file.path}`,
|
|
235
|
+
content: index === 0 ? 'export const Chart = true\n' : 'export const Summary = true\n',
|
|
236
|
+
})),
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function fixture(): Promise<string> {
|
|
241
|
+
const root = await mkdtemp(path.join(tmpdir(), 'astrale-ui-cli-'))
|
|
242
|
+
temporary.push(root)
|
|
243
|
+
await mkdir(path.join(root, 'src'), { recursive: true })
|
|
244
|
+
await writeFile(
|
|
245
|
+
path.join(root, 'package.json'),
|
|
246
|
+
JSON.stringify({
|
|
247
|
+
name: 'fixture',
|
|
248
|
+
private: true,
|
|
249
|
+
dependencies: {
|
|
250
|
+
react: '19.2.8',
|
|
251
|
+
'react-dom': '19.2.8',
|
|
252
|
+
tailwindcss: '4.3.3',
|
|
253
|
+
},
|
|
254
|
+
packageManager: 'pnpm@11.13.1',
|
|
255
|
+
}),
|
|
256
|
+
)
|
|
257
|
+
await writeFile(path.join(root, 'src/index.css'), '/* consumer css */\n')
|
|
258
|
+
return root
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function lockedFixture(): Promise<string> {
|
|
262
|
+
const root = await fixture()
|
|
263
|
+
const manifestPath = path.join(root, 'package.json')
|
|
264
|
+
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
|
265
|
+
manifest.dependencies['@astrale-os/ui'] = '0.3.0-beta.0'
|
|
266
|
+
await writeFile(manifestPath, JSON.stringify(manifest))
|
|
267
|
+
await writeFile(path.join(root, 'astrale-ui.lock.json'), JSON.stringify(lock()))
|
|
268
|
+
return root
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function lock(): UiLock {
|
|
272
|
+
return {
|
|
273
|
+
$schema: 'https://example.invalid/ui-lock.schema.json',
|
|
274
|
+
version: 1,
|
|
275
|
+
package: { name: '@astrale-os/ui', version: '0.3.0-beta.0' },
|
|
276
|
+
registry: { repository: 'astrale-os/ui', ref: 'v0.3.0-beta.0', commit },
|
|
277
|
+
tooling: { shadcn: '4.18.0', base: 'base', style: 'nova', baseUi: '1.7.0' },
|
|
278
|
+
preset: 'astrale',
|
|
279
|
+
items: {},
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
describe('UI release and runner contracts', () => {
|
|
284
|
+
/** @evidence TEST-CLI-UI-BETA-DEFAULT */
|
|
285
|
+
test('resolves the default release from the public beta channel', async () => {
|
|
286
|
+
const seen: string[] = []
|
|
287
|
+
const fallback = mockFetch()
|
|
288
|
+
const fetcher = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
289
|
+
const url = String(input)
|
|
290
|
+
seen.push(url)
|
|
291
|
+
if (url.endsWith('/@astrale-os/ui/beta')) {
|
|
292
|
+
return Response.json({ version: '0.3.0-beta.1' })
|
|
293
|
+
}
|
|
294
|
+
return fallback(input, init)
|
|
295
|
+
}) as typeof fetch
|
|
296
|
+
|
|
297
|
+
const release = await resolveUiRelease(undefined, fetcher)
|
|
298
|
+
|
|
299
|
+
expect(release.version).toBe('0.3.0-beta.1')
|
|
300
|
+
expect(seen[0]).toBe('https://registry.npmjs.org/@astrale-os/ui/beta')
|
|
301
|
+
expect(seen).not.toContain('https://registry.npmjs.org/@astrale-os/ui/latest')
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
test('rejects a public beta channel that does not resolve to a beta release', async () => {
|
|
305
|
+
const fetcher = (async (input: string | URL | Request) => {
|
|
306
|
+
const url = String(input)
|
|
307
|
+
if (url.endsWith('/@astrale-os/ui/beta')) return Response.json({ version: '0.3.0' })
|
|
308
|
+
throw new Error('release snapshot must not be fetched')
|
|
309
|
+
}) as typeof fetch
|
|
310
|
+
|
|
311
|
+
await expect(resolveUiRelease(undefined, fetcher)).rejects.toMatchObject({
|
|
312
|
+
code: 'UI_REGISTRY_UNAVAILABLE',
|
|
313
|
+
message: 'Invalid UI beta release version: 0.3.0',
|
|
314
|
+
})
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
/** @evidence TEST-CLI-UI-ONE-SNAPSHOT */
|
|
318
|
+
test('resolves one commit and reads the full release snapshot from it', async () => {
|
|
319
|
+
const seen: string[] = []
|
|
320
|
+
const release = await resolveUiRelease('0.3.0-beta.0', mockFetch(seen))
|
|
321
|
+
expect(release.commit).toBe(commit)
|
|
322
|
+
expect(release.compatibility.base).toBe('base')
|
|
323
|
+
expect(release.registry.items).toHaveLength(1)
|
|
324
|
+
expect(release.registry.items[0]?.files[0]?.path).toBe('registry/patterns/chart/line-basic.tsx')
|
|
325
|
+
expect(seen.filter((url) => new URL(url).hostname === 'raw.githubusercontent.com')).toEqual(
|
|
326
|
+
expect.arrayContaining([
|
|
327
|
+
expect.stringContaining('/' + commit + '/tooling/compatibility.json'),
|
|
328
|
+
expect.stringContaining('/' + commit + '/registry'),
|
|
329
|
+
]),
|
|
330
|
+
)
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
test('resolves the immutable release commit when the anonymous GitHub API is rate limited', async () => {
|
|
334
|
+
const seen: string[] = []
|
|
335
|
+
const fallback = mockFetch(seen)
|
|
336
|
+
const fetcher = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
337
|
+
const url = String(input)
|
|
338
|
+
if (url.includes('/git/ref/tags/')) {
|
|
339
|
+
seen.push(url)
|
|
340
|
+
return Response.json({ message: 'API rate limit exceeded' }, { status: 403 })
|
|
341
|
+
}
|
|
342
|
+
if (url.includes('/releases/tag/')) {
|
|
343
|
+
seen.push(url)
|
|
344
|
+
return new Response(
|
|
345
|
+
`<a data-hovercard-type="commit" href="/astrale-os/ui/commit/${'b'.repeat(40)}">decoy</a>
|
|
346
|
+
<a href="/astrale-os/ui/tree/v0.3.0-beta.0">repository navigation</a>
|
|
347
|
+
${'x'.repeat(35_000)}
|
|
348
|
+
<a href="/astrale-os/ui/tree/v0.3.0-beta.0">tag</a>
|
|
349
|
+
<a data-hovercard-type="commit" href="/astrale-os/ui/commit/${commit}">commit</a>`,
|
|
350
|
+
{ headers: { 'content-type': 'text/html' } },
|
|
351
|
+
)
|
|
352
|
+
}
|
|
353
|
+
return fallback(input, init)
|
|
354
|
+
}) as typeof fetch
|
|
355
|
+
|
|
356
|
+
const release = await resolveUiRelease('0.3.0-beta.0', fetcher)
|
|
357
|
+
|
|
358
|
+
expect(release.commit).toBe(commit)
|
|
359
|
+
expect(seen).toContain('https://github.com/astrale-os/ui/releases/tag/v0.3.0-beta.0')
|
|
360
|
+
expect(seen).toContain(
|
|
361
|
+
'https://raw.githubusercontent.com/astrale-os/ui/' + commit + '/tooling/compatibility.json',
|
|
362
|
+
)
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
test('rejects a release page without an exact Astrale UI commit target', async () => {
|
|
366
|
+
const fetcher = (async (input: string | URL | Request) => {
|
|
367
|
+
const url = String(input)
|
|
368
|
+
if (url.includes('/git/ref/tags/')) return new Response('limited', { status: 403 })
|
|
369
|
+
if (url.includes('/releases/tag/')) return new Response('<main>release unavailable</main>')
|
|
370
|
+
throw new Error('release snapshot must not be fetched')
|
|
371
|
+
}) as typeof fetch
|
|
372
|
+
|
|
373
|
+
await expect(resolveUiRelease('0.3.0-beta.0', fetcher)).rejects.toMatchObject({
|
|
374
|
+
code: 'UI_REGISTRY_UNAVAILABLE',
|
|
375
|
+
message: 'UI ref v0.3.0-beta.0 did not resolve to a commit.',
|
|
376
|
+
})
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
test('does not replace an authoritative missing tag with release-page HTML', async () => {
|
|
380
|
+
const seen: string[] = []
|
|
381
|
+
const fetcher = (async (input: string | URL | Request) => {
|
|
382
|
+
const url = String(input)
|
|
383
|
+
seen.push(url)
|
|
384
|
+
if (url.includes('/git/ref/tags/')) return new Response('missing', { status: 404 })
|
|
385
|
+
throw new Error('release page must not be fetched')
|
|
386
|
+
}) as typeof fetch
|
|
387
|
+
|
|
388
|
+
await expect(resolveUiRelease('0.3.0-beta.0', fetcher)).rejects.toMatchObject({
|
|
389
|
+
code: 'UI_REGISTRY_UNAVAILABLE',
|
|
390
|
+
message: 'UI ref v0.3.0-beta.0 returned HTTP 404.',
|
|
391
|
+
})
|
|
392
|
+
expect(seen).toEqual(['https://api.github.com/repos/astrale-os/ui/git/ref/tags/v0.3.0-beta.0'])
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
test('bounds declared and streamed release-page HTML before reading a snapshot', async () => {
|
|
396
|
+
for (const oversized of [
|
|
397
|
+
new Response('large', { headers: { 'content-length': '1048577' } }),
|
|
398
|
+
new Response(
|
|
399
|
+
new ReadableStream({
|
|
400
|
+
start(controller) {
|
|
401
|
+
controller.enqueue(new Uint8Array(1_048_577))
|
|
402
|
+
controller.close()
|
|
403
|
+
},
|
|
404
|
+
}),
|
|
405
|
+
),
|
|
406
|
+
]) {
|
|
407
|
+
const seen: string[] = []
|
|
408
|
+
const fetcher = (async (input: string | URL | Request) => {
|
|
409
|
+
const url = String(input)
|
|
410
|
+
seen.push(url)
|
|
411
|
+
if (url.includes('/git/ref/tags/')) return new Response('limited', { status: 403 })
|
|
412
|
+
if (url.includes('/releases/tag/')) return oversized
|
|
413
|
+
throw new Error('release snapshot must not be fetched')
|
|
414
|
+
}) as typeof fetch
|
|
415
|
+
|
|
416
|
+
await expect(resolveUiRelease('0.3.0-beta.0', fetcher)).rejects.toMatchObject({
|
|
417
|
+
code: 'UI_REGISTRY_UNAVAILABLE',
|
|
418
|
+
message: 'UI release v0.3.0-beta.0 exceeds the supported response size.',
|
|
419
|
+
})
|
|
420
|
+
expect(seen).toHaveLength(2)
|
|
421
|
+
}
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
test('admits a release theme as one canonical consumer-owned CSS target', async () => {
|
|
425
|
+
const release = await resolveUiRelease('0.3.0-beta.0', themeFetch())
|
|
426
|
+
expect(release.registry.items).toEqual([
|
|
427
|
+
expect.objectContaining({
|
|
428
|
+
name: 'theme-observatory',
|
|
429
|
+
type: 'registry:theme',
|
|
430
|
+
meta: expect.objectContaining({ canonicalAddress: 'theme/observatory' }),
|
|
431
|
+
files: [
|
|
432
|
+
expect.objectContaining({
|
|
433
|
+
type: 'registry:file',
|
|
434
|
+
path: 'registry/themes/observatory.css',
|
|
435
|
+
target: 'components/astrale/theme/observatory.css',
|
|
436
|
+
}),
|
|
437
|
+
],
|
|
438
|
+
}),
|
|
439
|
+
])
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
test('admits an exact consumer-owned component with item-local dependencies and files', async () => {
|
|
443
|
+
const release = await resolveUiRelease('0.3.0-beta.0', mockFetch([], componentRegistry))
|
|
444
|
+
expect(release.registry.items).toEqual([
|
|
445
|
+
expect.objectContaining({
|
|
446
|
+
name: 'component-sidebar',
|
|
447
|
+
type: 'registry:component',
|
|
448
|
+
dependencies: ['@astrale-os/ui@^0.3.0-beta.0', 'lucide-react@1.32.0'],
|
|
449
|
+
meta: expect.objectContaining({ canonicalAddress: 'component/sidebar' }),
|
|
450
|
+
files: [
|
|
451
|
+
expect.objectContaining({
|
|
452
|
+
type: 'registry:component',
|
|
453
|
+
target: 'components/astrale/component/sidebar/sidebar.tsx',
|
|
454
|
+
}),
|
|
455
|
+
expect.objectContaining({
|
|
456
|
+
type: 'registry:hook',
|
|
457
|
+
target: 'components/astrale/component/sidebar/use-mobile.ts',
|
|
458
|
+
}),
|
|
459
|
+
],
|
|
460
|
+
}),
|
|
461
|
+
])
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
test('rejects a component item that targets another registry ownership category', async () => {
|
|
465
|
+
const invalid = structuredClone(componentRegistry)
|
|
466
|
+
invalid.items[0]!.files[0]!.target = 'components/astrale/pattern/sidebar/sidebar.tsx'
|
|
467
|
+
await expect(resolveUiRelease('0.3.0-beta.0', mockFetch([], invalid))).rejects.toMatchObject({
|
|
468
|
+
code: 'UI_REGISTRY_UNAVAILABLE',
|
|
469
|
+
})
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
test('lists release themes through the public type filter', async () => {
|
|
473
|
+
const themes = await listUi(
|
|
474
|
+
undefined,
|
|
475
|
+
{ type: 'theme', version: '0.3.0-beta.0' },
|
|
476
|
+
{ fetcher: themeFetch() },
|
|
477
|
+
)
|
|
478
|
+
expect(themes.map((item) => item.meta.canonicalAddress)).toEqual(['theme/observatory'])
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
test('constructs exact on-demand commands for every supported package manager', () => {
|
|
482
|
+
expect(shadcnInvocation('pnpm', '4.18.0', ['add', 'item'])).toEqual({
|
|
483
|
+
file: 'pnpm',
|
|
484
|
+
args: ['dlx', 'shadcn@4.18.0', 'add', 'item'],
|
|
485
|
+
})
|
|
486
|
+
expect(shadcnInvocation('npm', '4.18.0', ['add', 'item'])).toEqual({
|
|
487
|
+
file: 'npx',
|
|
488
|
+
args: ['--yes', 'shadcn@4.18.0', 'add', 'item'],
|
|
489
|
+
})
|
|
490
|
+
expect(shadcnInvocation('yarn', '4.18.0', ['add', 'item'])).toEqual({
|
|
491
|
+
file: 'yarn',
|
|
492
|
+
args: ['dlx', 'shadcn@4.18.0', 'add', 'item'],
|
|
493
|
+
})
|
|
494
|
+
expect(shadcnInvocation('bun', '4.18.0', ['add', 'item'])).toEqual({
|
|
495
|
+
file: 'bunx',
|
|
496
|
+
args: ['shadcn@4.18.0', 'add', 'item'],
|
|
497
|
+
})
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
test('rejects an unsafe authoritative root without falling back to a legacy registry', async () => {
|
|
501
|
+
const seen: string[] = []
|
|
502
|
+
const fallback = mockFetch(seen)
|
|
503
|
+
const unsafe = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
504
|
+
const url = String(input)
|
|
505
|
+
if (url.endsWith('/' + commit + '/registry.json')) {
|
|
506
|
+
return Response.json({ name: 'astrale-ui', include: ['../registry.json'] })
|
|
507
|
+
}
|
|
508
|
+
return fallback(input, init)
|
|
509
|
+
}) as typeof fetch
|
|
510
|
+
await expect(resolveUiRelease('0.3.0-beta.0', unsafe)).rejects.toThrow()
|
|
511
|
+
expect(seen.some((url) => url.endsWith('/registry/registry.json'))).toBe(false)
|
|
512
|
+
})
|
|
513
|
+
|
|
514
|
+
test('rejects noncanonical and encoded registry includes before fetching them', async () => {
|
|
515
|
+
const unsafeIncludes = [
|
|
516
|
+
'%2e%2e/other/registry.json',
|
|
517
|
+
'registry/%2Fother/registry.json',
|
|
518
|
+
'registry/other/registry.json?raw=1',
|
|
519
|
+
'registry/other/registry.json#item',
|
|
520
|
+
'https://example.invalid/registry.json',
|
|
521
|
+
'./registry/other/registry.json',
|
|
522
|
+
]
|
|
523
|
+
|
|
524
|
+
for (const include of unsafeIncludes) {
|
|
525
|
+
const seen: string[] = []
|
|
526
|
+
const fallback = mockFetch(seen)
|
|
527
|
+
const fetcher = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
528
|
+
const url = String(input)
|
|
529
|
+
if (url.endsWith('/' + commit + '/registry.json')) {
|
|
530
|
+
return Response.json({ include: [include] })
|
|
531
|
+
}
|
|
532
|
+
return fallback(input, init)
|
|
533
|
+
}) as typeof fetch
|
|
534
|
+
|
|
535
|
+
await expect(resolveUiRelease('0.3.0-beta.0', fetcher)).rejects.toMatchObject({
|
|
536
|
+
code: 'UI_REGISTRY_UNAVAILABLE',
|
|
537
|
+
})
|
|
538
|
+
expect(seen.some((url) => url.includes('example.invalid') || url.includes('/other/'))).toBe(
|
|
539
|
+
false,
|
|
540
|
+
)
|
|
541
|
+
}
|
|
542
|
+
})
|
|
543
|
+
|
|
544
|
+
test('rejects malformed and already-qualified family-local item paths', async () => {
|
|
545
|
+
const unsafePaths = [
|
|
546
|
+
'',
|
|
547
|
+
'/absolute.tsx',
|
|
548
|
+
'C:/windows.tsx',
|
|
549
|
+
'./line-basic.tsx',
|
|
550
|
+
'../line-basic.tsx',
|
|
551
|
+
'%2e%2e/line-basic.tsx',
|
|
552
|
+
'registry/patterns/chart/line-basic.tsx',
|
|
553
|
+
]
|
|
554
|
+
|
|
555
|
+
for (const unsafePath of unsafePaths) {
|
|
556
|
+
const supplied = structuredClone(registry)
|
|
557
|
+
supplied.items[0]!.files[0]!.path = unsafePath
|
|
558
|
+
await expect(resolveUiRelease('0.3.0-beta.0', mockFetch([], supplied))).rejects.toMatchObject(
|
|
559
|
+
{
|
|
560
|
+
code: 'UI_REGISTRY_UNAVAILABLE',
|
|
561
|
+
},
|
|
562
|
+
)
|
|
563
|
+
}
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
test('qualifies every item file relative to its declaring nested registry', async () => {
|
|
567
|
+
const rootItem = {
|
|
568
|
+
...structuredClone(registry.items[0]!),
|
|
569
|
+
name: 'pattern-chart-root',
|
|
570
|
+
files: [
|
|
571
|
+
{
|
|
572
|
+
...structuredClone(registry.items[0]!.files[0]!),
|
|
573
|
+
path: 'registry/root.tsx',
|
|
574
|
+
target: 'components/astrale/pattern/chart/root.tsx',
|
|
575
|
+
},
|
|
576
|
+
],
|
|
577
|
+
meta: { canonicalAddress: 'pattern/chart/root' },
|
|
578
|
+
}
|
|
579
|
+
const chartItem = {
|
|
580
|
+
...structuredClone(registry.items[0]!),
|
|
581
|
+
files: [
|
|
582
|
+
structuredClone(registry.items[0]!.files[0]!),
|
|
583
|
+
{
|
|
584
|
+
...structuredClone(registry.items[0]!.files[0]!),
|
|
585
|
+
path: 'parts/legend.tsx',
|
|
586
|
+
target: 'components/astrale/pattern/chart/parts/legend.tsx',
|
|
587
|
+
},
|
|
588
|
+
],
|
|
589
|
+
}
|
|
590
|
+
const nestedItem = {
|
|
591
|
+
...structuredClone(registry.items[0]!),
|
|
592
|
+
name: 'pattern-chart-nested-line',
|
|
593
|
+
files: [structuredClone(registry.items[0]!.files[0]!)],
|
|
594
|
+
meta: { canonicalAddress: 'pattern/chart/nested-line' },
|
|
595
|
+
}
|
|
596
|
+
const fallback = mockFetch()
|
|
597
|
+
const fetcher = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
598
|
+
const url = String(input)
|
|
599
|
+
if (url.endsWith('/' + commit + '/registry.json')) {
|
|
600
|
+
return Response.json({
|
|
601
|
+
items: [rootItem],
|
|
602
|
+
include: ['registry/patterns/chart/registry.json'],
|
|
603
|
+
})
|
|
604
|
+
}
|
|
605
|
+
if (url.endsWith('/registry/patterns/chart/registry.json')) {
|
|
606
|
+
return Response.json({ items: [chartItem], include: ['nested/registry.json'] })
|
|
607
|
+
}
|
|
608
|
+
if (url.endsWith('/registry/patterns/chart/nested/registry.json')) {
|
|
609
|
+
return Response.json({ items: [nestedItem] })
|
|
610
|
+
}
|
|
611
|
+
return fallback(input, init)
|
|
612
|
+
}) as typeof fetch
|
|
613
|
+
|
|
614
|
+
const release = await resolveUiRelease('0.3.0-beta.0', fetcher)
|
|
615
|
+
expect(release.registry.items.map((item) => item.files.map((file) => file.path))).toEqual([
|
|
616
|
+
['registry/root.tsx'],
|
|
617
|
+
['registry/patterns/chart/line-basic.tsx', 'registry/patterns/chart/parts/legend.tsx'],
|
|
618
|
+
['registry/patterns/chart/nested/line-basic.tsx'],
|
|
619
|
+
])
|
|
620
|
+
})
|
|
621
|
+
|
|
622
|
+
/** @evidence TEST-CLI-UI-BOUNDED-REMOTE-DOCUMENTS */
|
|
623
|
+
test('bounds and normalizes malformed registry responses', async () => {
|
|
624
|
+
const fallback = mockFetch()
|
|
625
|
+
const oversized = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
626
|
+
const url = String(input)
|
|
627
|
+
if (url.endsWith('/tooling/compatibility.json')) {
|
|
628
|
+
return new Response('x'.repeat(1_048_577), {
|
|
629
|
+
headers: { 'content-type': 'application/json' },
|
|
630
|
+
})
|
|
631
|
+
}
|
|
632
|
+
return fallback(input, init)
|
|
633
|
+
}) as typeof fetch
|
|
634
|
+
await expect(resolveUiRelease('0.3.0-beta.0', oversized)).rejects.toMatchObject({
|
|
635
|
+
code: 'UI_REGISTRY_UNAVAILABLE',
|
|
636
|
+
})
|
|
637
|
+
|
|
638
|
+
const malformed = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
639
|
+
const url = String(input)
|
|
640
|
+
if (url.endsWith('/' + commit + '/registry.json')) {
|
|
641
|
+
return new Response('{', { headers: { 'content-type': 'application/json' } })
|
|
642
|
+
}
|
|
643
|
+
return fallback(input, init)
|
|
644
|
+
}) as typeof fetch
|
|
645
|
+
await expect(resolveUiRelease('0.3.0-beta.0', malformed)).rejects.toMatchObject({
|
|
646
|
+
code: 'UI_REGISTRY_UNAVAILABLE',
|
|
647
|
+
})
|
|
648
|
+
})
|
|
649
|
+
|
|
650
|
+
test('rejects include fan-out before fetching an unbounded registry graph', async () => {
|
|
651
|
+
const fallback = mockFetch()
|
|
652
|
+
const fanOut = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
653
|
+
const url = String(input)
|
|
654
|
+
if (url.endsWith('/' + commit + '/registry.json')) {
|
|
655
|
+
return Response.json({
|
|
656
|
+
include: Array.from(
|
|
657
|
+
{ length: 100 },
|
|
658
|
+
(_, index) => 'registry/patterns/family-' + index + '/registry.json',
|
|
659
|
+
),
|
|
660
|
+
})
|
|
661
|
+
}
|
|
662
|
+
return fallback(input, init)
|
|
663
|
+
}) as typeof fetch
|
|
664
|
+
await expect(resolveUiRelease('0.3.0-beta.0', fanOut)).rejects.toMatchObject({
|
|
665
|
+
code: 'UI_REGISTRY_UNAVAILABLE',
|
|
666
|
+
})
|
|
667
|
+
})
|
|
668
|
+
})
|
|
669
|
+
|
|
670
|
+
describe('UI initialization transaction', () => {
|
|
671
|
+
test('initializes the generated Domain frontend stylesheet instead of an unused fallback', async () => {
|
|
672
|
+
const root = await fixture()
|
|
673
|
+
await mkdir(path.join(root, 'frontend/src'), { recursive: true })
|
|
674
|
+
await writeFile(
|
|
675
|
+
path.join(root, 'frontend/package.json'),
|
|
676
|
+
JSON.stringify({ name: 'astrale-frontend', private: true, type: 'module' }),
|
|
677
|
+
)
|
|
678
|
+
await writeFile(path.join(root, 'frontend/src/styles.css'), '/* Domain frontend */\n')
|
|
679
|
+
await mkdir(path.join(root, 'ui'), { recursive: true })
|
|
680
|
+
await writeFile(path.join(root, 'ui/index.ts'), 'export {}\n')
|
|
681
|
+
await writeFile(path.join(root, 'astrale.config.ts'), 'export default {}\n')
|
|
682
|
+
await writeFile(path.join(root, 'pnpm-workspace.yaml'), "packages:\n - 'frontend'\n")
|
|
683
|
+
const rootCssBefore = await readFile(path.join(root, 'src/index.css'), 'utf8')
|
|
684
|
+
|
|
685
|
+
await initUi(
|
|
686
|
+
{ path: path.join(root, 'frontend'), version: '0.3.0-beta.0', install: false },
|
|
687
|
+
{ fetcher: mockFetch() },
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
const css = await readFile(path.join(root, 'frontend/src/styles.css'), 'utf8')
|
|
691
|
+
const components = JSON.parse(await readFile(path.join(root, 'components.json'), 'utf8'))
|
|
692
|
+
expect(css).toContain("@import '@astrale-os/ui/theme.css';")
|
|
693
|
+
expect(css).toContain('/* Domain frontend */')
|
|
694
|
+
expect(components.tailwind.css).toBe('frontend/src/styles.css')
|
|
695
|
+
expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toBe(rootCssBefore)
|
|
696
|
+
expect(
|
|
697
|
+
JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')).dependencies,
|
|
698
|
+
).toHaveProperty('@astrale-os/ui', '0.3.0-beta.0')
|
|
699
|
+
expect(await Bun.file(path.join(root, 'src/astrale-ui.css')).exists()).toBe(false)
|
|
700
|
+
expect(JSON.parse(await readFile(path.join(root, 'components/package.json'), 'utf8'))).toEqual({
|
|
701
|
+
name: 'fixture-ui-registry',
|
|
702
|
+
private: true,
|
|
703
|
+
type: 'module',
|
|
704
|
+
})
|
|
705
|
+
expect(await readFile(path.join(root, 'pnpm-workspace.yaml'), 'utf8')).toContain('components')
|
|
706
|
+
|
|
707
|
+
await writeFile(path.join(root, 'src/app.css'), '/* later root stylesheet */\n')
|
|
708
|
+
const repeated = await initUi(
|
|
709
|
+
{ path: root, version: '0.3.0-beta.0', install: false },
|
|
710
|
+
{ fetcher: mockFetch() },
|
|
711
|
+
)
|
|
712
|
+
expect(repeated.status).toBe('unchanged')
|
|
713
|
+
})
|
|
714
|
+
|
|
715
|
+
test('adds the private registry workspace to an npm-authored Domain manifest', async () => {
|
|
716
|
+
const root = await fixture()
|
|
717
|
+
const manifestPath = path.join(root, 'package.json')
|
|
718
|
+
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
|
719
|
+
manifest.packageManager = 'npm@11.16.0'
|
|
720
|
+
manifest.workspaces = ['frontend']
|
|
721
|
+
await writeFile(manifestPath, JSON.stringify(manifest))
|
|
722
|
+
await writeFile(path.join(root, 'package-lock.json'), '{}')
|
|
723
|
+
await mkdir(path.join(root, 'frontend/src'), { recursive: true })
|
|
724
|
+
await writeFile(
|
|
725
|
+
path.join(root, 'frontend/package.json'),
|
|
726
|
+
JSON.stringify({ name: 'astrale-frontend', private: true }),
|
|
727
|
+
)
|
|
728
|
+
await writeFile(path.join(root, 'frontend/src/styles.css'), '/* Domain frontend */\n')
|
|
729
|
+
await mkdir(path.join(root, 'ui'), { recursive: true })
|
|
730
|
+
await writeFile(path.join(root, 'ui/index.ts'), 'export {}\n')
|
|
731
|
+
await writeFile(path.join(root, 'astrale.config.ts'), 'export default {}\n')
|
|
732
|
+
|
|
733
|
+
const result = await initUi(
|
|
734
|
+
{ path: root, version: '0.3.0-beta.0', install: false },
|
|
735
|
+
{ fetcher: mockFetch() },
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
expect(result.files).toEqual(expect.arrayContaining(['components/package.json']))
|
|
739
|
+
expect(JSON.parse(await readFile(manifestPath, 'utf8')).workspaces).toEqual([
|
|
740
|
+
'frontend',
|
|
741
|
+
'components',
|
|
742
|
+
])
|
|
743
|
+
expect(JSON.parse(await readFile(path.join(root, 'components/package.json'), 'utf8'))).toEqual({
|
|
744
|
+
name: 'fixture-ui-registry',
|
|
745
|
+
private: true,
|
|
746
|
+
type: 'module',
|
|
747
|
+
})
|
|
748
|
+
})
|
|
749
|
+
|
|
750
|
+
test('rejects a Domain registry workspace whose physical parent escapes the project', async () => {
|
|
751
|
+
const root = await fixture()
|
|
752
|
+
const outside = await fixture()
|
|
753
|
+
await mkdir(path.join(root, 'frontend/src'), { recursive: true })
|
|
754
|
+
await writeFile(
|
|
755
|
+
path.join(root, 'frontend/package.json'),
|
|
756
|
+
JSON.stringify({ name: 'astrale-frontend', private: true }),
|
|
757
|
+
)
|
|
758
|
+
await writeFile(path.join(root, 'frontend/src/styles.css'), '/* Domain frontend */\n')
|
|
759
|
+
await mkdir(path.join(root, 'ui'), { recursive: true })
|
|
760
|
+
await writeFile(path.join(root, 'ui/index.ts'), 'export {}\n')
|
|
761
|
+
await writeFile(path.join(root, 'astrale.config.ts'), 'export default {}\n')
|
|
762
|
+
await symlink(outside, path.join(root, 'components'), 'dir')
|
|
763
|
+
const outsideManifest = await readFile(path.join(outside, 'package.json'), 'utf8')
|
|
764
|
+
const rootManifest = await readFile(path.join(root, 'package.json'), 'utf8')
|
|
765
|
+
|
|
766
|
+
await expect(
|
|
767
|
+
initUi({ path: root, version: '0.3.0-beta.0', install: false }, { fetcher: mockFetch() }),
|
|
768
|
+
).rejects.toMatchObject({ code: 'UI_LOCK_INVALID' })
|
|
769
|
+
expect(await readFile(path.join(outside, 'package.json'), 'utf8')).toBe(outsideManifest)
|
|
770
|
+
expect(await readFile(path.join(root, 'package.json'), 'utf8')).toBe(rootManifest)
|
|
771
|
+
expect(await Bun.file(path.join(root, 'astrale-ui.lock.json')).exists()).toBe(false)
|
|
772
|
+
})
|
|
773
|
+
|
|
774
|
+
test('rejects a symlinked pnpm workspace manifest before any Domain mutation', async () => {
|
|
775
|
+
const root = await fixture()
|
|
776
|
+
const outside = await fixture()
|
|
777
|
+
await mkdir(path.join(root, 'frontend/src'), { recursive: true })
|
|
778
|
+
await writeFile(
|
|
779
|
+
path.join(root, 'frontend/package.json'),
|
|
780
|
+
JSON.stringify({ name: 'astrale-frontend', private: true }),
|
|
781
|
+
)
|
|
782
|
+
await writeFile(path.join(root, 'frontend/src/styles.css'), '/* Domain frontend */\n')
|
|
783
|
+
await mkdir(path.join(root, 'ui'), { recursive: true })
|
|
784
|
+
await writeFile(path.join(root, 'ui/index.ts'), 'export {}\n')
|
|
785
|
+
await writeFile(path.join(root, 'astrale.config.ts'), 'export default {}\n')
|
|
786
|
+
const outsideWorkspace = path.join(outside, 'workspace.yaml')
|
|
787
|
+
await writeFile(outsideWorkspace, "packages:\n - 'outside'\n")
|
|
788
|
+
await symlink(outsideWorkspace, path.join(root, 'pnpm-workspace.yaml'), 'file')
|
|
789
|
+
const outsideBefore = await readFile(outsideWorkspace, 'utf8')
|
|
790
|
+
|
|
791
|
+
await expect(
|
|
792
|
+
initUi({ path: root, version: '0.3.0-beta.0', install: false }, { fetcher: mockFetch() }),
|
|
793
|
+
).rejects.toMatchObject({ code: 'UI_LOCK_INVALID' })
|
|
794
|
+
expect(await readFile(outsideWorkspace, 'utf8')).toBe(outsideBefore)
|
|
795
|
+
expect(await Bun.file(path.join(root, 'components/package.json')).exists()).toBe(false)
|
|
796
|
+
expect(await Bun.file(path.join(root, 'astrale-ui.lock.json')).exists()).toBe(false)
|
|
797
|
+
})
|
|
798
|
+
|
|
799
|
+
test('restores every Domain workspace mutation when dependency installation fails', async () => {
|
|
800
|
+
const root = await fixture()
|
|
801
|
+
await mkdir(path.join(root, 'frontend/src'), { recursive: true })
|
|
802
|
+
await writeFile(
|
|
803
|
+
path.join(root, 'frontend/package.json'),
|
|
804
|
+
JSON.stringify({ name: 'astrale-frontend', private: true }),
|
|
805
|
+
)
|
|
806
|
+
await writeFile(path.join(root, 'frontend/src/styles.css'), '/* Domain frontend */\n')
|
|
807
|
+
await mkdir(path.join(root, 'ui'), { recursive: true })
|
|
808
|
+
await writeFile(path.join(root, 'ui/index.ts'), 'export {}\n')
|
|
809
|
+
await writeFile(path.join(root, 'astrale.config.ts'), 'export default {}\n')
|
|
810
|
+
await writeFile(path.join(root, 'pnpm-workspace.yaml'), "packages:\n - 'frontend'\n")
|
|
811
|
+
const manifestBefore = await readFile(path.join(root, 'package.json'), 'utf8')
|
|
812
|
+
const cssBefore = await readFile(path.join(root, 'frontend/src/styles.css'), 'utf8')
|
|
813
|
+
const workspaceBefore = await readFile(path.join(root, 'pnpm-workspace.yaml'), 'utf8')
|
|
814
|
+
|
|
815
|
+
await expect(
|
|
816
|
+
initUi(
|
|
817
|
+
{ path: root, version: '0.3.0-beta.0' },
|
|
818
|
+
{
|
|
819
|
+
fetcher: mockFetch(),
|
|
820
|
+
runner: async () => ({ code: 1, stdout: '', stderr: 'install failed' }),
|
|
821
|
+
},
|
|
822
|
+
),
|
|
823
|
+
).rejects.toMatchObject({ code: 'UI_DEPENDENCY_INSTALL_FAILED' })
|
|
824
|
+
|
|
825
|
+
expect(await readFile(path.join(root, 'package.json'), 'utf8')).toBe(manifestBefore)
|
|
826
|
+
expect(await readFile(path.join(root, 'frontend/src/styles.css'), 'utf8')).toBe(cssBefore)
|
|
827
|
+
expect(await readFile(path.join(root, 'pnpm-workspace.yaml'), 'utf8')).toBe(workspaceBefore)
|
|
828
|
+
expect(await Bun.file(path.join(root, 'components/package.json')).exists()).toBe(false)
|
|
829
|
+
expect(await Bun.file(path.join(root, 'components.json')).exists()).toBe(false)
|
|
830
|
+
expect(await Bun.file(path.join(root, 'astrale-ui.lock.json')).exists()).toBe(false)
|
|
831
|
+
expect(await Bun.file(path.join(root, 'pnpm-lock.yaml')).exists()).toBe(false)
|
|
832
|
+
})
|
|
833
|
+
|
|
834
|
+
test('rejects a registry workspace that could shadow the public UI package', async () => {
|
|
835
|
+
const root = await fixture()
|
|
836
|
+
await mkdir(path.join(root, 'frontend/src'), { recursive: true })
|
|
837
|
+
await writeFile(
|
|
838
|
+
path.join(root, 'frontend/package.json'),
|
|
839
|
+
JSON.stringify({ name: 'astrale-frontend', private: true }),
|
|
840
|
+
)
|
|
841
|
+
await writeFile(path.join(root, 'frontend/src/styles.css'), '/* Domain frontend */\n')
|
|
842
|
+
await mkdir(path.join(root, 'ui'), { recursive: true })
|
|
843
|
+
await writeFile(path.join(root, 'ui/index.ts'), 'export {}\n')
|
|
844
|
+
await writeFile(path.join(root, 'astrale.config.ts'), 'export default {}\n')
|
|
845
|
+
await writeFile(path.join(root, 'pnpm-workspace.yaml'), "packages:\n - 'frontend'\n")
|
|
846
|
+
await mkdir(path.join(root, 'components'), { recursive: true })
|
|
847
|
+
await writeFile(
|
|
848
|
+
path.join(root, 'components/package.json'),
|
|
849
|
+
JSON.stringify({ name: '@astrale-os/ui', private: true, version: '0.3.0-beta.0' }),
|
|
850
|
+
)
|
|
851
|
+
const workspaceBefore = await readFile(path.join(root, 'pnpm-workspace.yaml'), 'utf8')
|
|
852
|
+
|
|
853
|
+
await expect(
|
|
854
|
+
initUi({ path: root, version: '0.3.0-beta.0', install: false }, { fetcher: mockFetch() }),
|
|
855
|
+
).rejects.toMatchObject({ code: 'UI_PROJECT_UNSUPPORTED' })
|
|
856
|
+
expect(await readFile(path.join(root, 'pnpm-workspace.yaml'), 'utf8')).toBe(workspaceBefore)
|
|
857
|
+
expect(
|
|
858
|
+
JSON.parse(await readFile(path.join(root, 'components/package.json'), 'utf8')).name,
|
|
859
|
+
).toBe('@astrale-os/ui')
|
|
860
|
+
})
|
|
861
|
+
|
|
862
|
+
test('rejects configured and discovered stylesheets whose physical parent escapes', async () => {
|
|
863
|
+
const root = await fixture()
|
|
864
|
+
const outside = await fixture()
|
|
865
|
+
await symlink(outside, path.join(root, 'escaped'), 'dir')
|
|
866
|
+
await writeFile(
|
|
867
|
+
path.join(root, 'components.json'),
|
|
868
|
+
JSON.stringify({ tailwind: { css: 'escaped/styles.css' } }),
|
|
869
|
+
)
|
|
870
|
+
|
|
871
|
+
await expect(
|
|
872
|
+
initUi({ path: root, version: '0.3.0-beta.0', install: false }, { fetcher: mockFetch() }),
|
|
873
|
+
).rejects.toMatchObject({ code: 'UI_PROJECT_UNSUPPORTED' })
|
|
874
|
+
expect(await Bun.file(path.join(outside, 'styles.css')).exists()).toBe(false)
|
|
875
|
+
|
|
876
|
+
const automaticRoot = await fixture()
|
|
877
|
+
await rm(path.join(automaticRoot, 'src/index.css'))
|
|
878
|
+
const outsideCss = await readFile(path.join(outside, 'src/index.css'), 'utf8')
|
|
879
|
+
await symlink(outside, path.join(automaticRoot, 'frontend'), 'dir')
|
|
880
|
+
await expect(
|
|
881
|
+
initUi(
|
|
882
|
+
{ path: automaticRoot, version: '0.3.0-beta.0', install: false },
|
|
883
|
+
{ fetcher: mockFetch() },
|
|
884
|
+
),
|
|
885
|
+
).rejects.toMatchObject({ code: 'UI_PROJECT_UNSUPPORTED' })
|
|
886
|
+
expect(await readFile(path.join(outside, 'src/index.css'), 'utf8')).toBe(outsideCss)
|
|
887
|
+
})
|
|
888
|
+
|
|
889
|
+
test('dry-run reports every mutation and writes nothing', async () => {
|
|
890
|
+
const root = await fixture()
|
|
891
|
+
const before = await readFile(path.join(root, 'package.json'), 'utf8')
|
|
892
|
+
const result = await initUi(
|
|
893
|
+
{ path: root, version: '0.3.0-beta.0', dryRun: true },
|
|
894
|
+
{ fetcher: mockFetch() },
|
|
895
|
+
)
|
|
896
|
+
expect(result.status).toBe('planned')
|
|
897
|
+
expect(result.files).toEqual(
|
|
898
|
+
expect.arrayContaining([
|
|
899
|
+
'package.json',
|
|
900
|
+
'src/index.css',
|
|
901
|
+
'components.json',
|
|
902
|
+
'astrale-ui.lock.json',
|
|
903
|
+
]),
|
|
904
|
+
)
|
|
905
|
+
expect(await readFile(path.join(root, 'package.json'), 'utf8')).toBe(before)
|
|
906
|
+
expect(await Bun.file(path.join(root, 'astrale-ui.lock.json')).exists()).toBe(false)
|
|
907
|
+
})
|
|
908
|
+
|
|
909
|
+
/** @evidence TEST-CLI-UI-LOCK-AFTER-SUCCESS */
|
|
910
|
+
test('restores all files and leaves no lock when dependency installation fails', async () => {
|
|
911
|
+
const root = await fixture()
|
|
912
|
+
const packageBefore = await readFile(path.join(root, 'package.json'), 'utf8')
|
|
913
|
+
const cssBefore = await readFile(path.join(root, 'src/index.css'), 'utf8')
|
|
914
|
+
await expect(
|
|
915
|
+
initUi(
|
|
916
|
+
{ path: root, version: '0.3.0-beta.0' },
|
|
917
|
+
{
|
|
918
|
+
fetcher: mockFetch(),
|
|
919
|
+
runner: async () => ({ code: 1, stdout: '', stderr: 'registry unavailable' }),
|
|
920
|
+
},
|
|
921
|
+
),
|
|
922
|
+
).rejects.toMatchObject({ code: 'UI_DEPENDENCY_INSTALL_FAILED' })
|
|
923
|
+
expect(await readFile(path.join(root, 'package.json'), 'utf8')).toBe(packageBefore)
|
|
924
|
+
expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toBe(cssBefore)
|
|
925
|
+
expect(await Bun.file(path.join(root, 'components.json')).exists()).toBe(false)
|
|
926
|
+
expect(await Bun.file(path.join(root, 'astrale-ui.lock.json')).exists()).toBe(false)
|
|
927
|
+
expect(await Bun.file(path.join(root, 'pnpm-lock.yaml')).exists()).toBe(false)
|
|
928
|
+
})
|
|
929
|
+
|
|
930
|
+
test('writes Base Nova config and advances the lock only after configuration succeeds', async () => {
|
|
931
|
+
const root = await fixture()
|
|
932
|
+
await initUi(
|
|
933
|
+
{ path: root, version: '0.3.0-beta.0', preset: 'compact', install: false },
|
|
934
|
+
{ fetcher: mockFetch() },
|
|
935
|
+
)
|
|
936
|
+
const components = JSON.parse(await readFile(path.join(root, 'components.json'), 'utf8'))
|
|
937
|
+
const written = JSON.parse(await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8'))
|
|
938
|
+
expect(components.style).toBe('base-nova')
|
|
939
|
+
expect(written.tooling).toMatchObject({ base: 'base', style: 'nova', baseUi: '1.7.0' })
|
|
940
|
+
expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toContain(
|
|
941
|
+
'@astrale-os/ui/presets/compact.css',
|
|
942
|
+
)
|
|
943
|
+
})
|
|
944
|
+
|
|
945
|
+
test('preserves an existing development-only UI dependency during initialization', async () => {
|
|
946
|
+
const root = await fixture()
|
|
947
|
+
const manifestPath = path.join(root, 'package.json')
|
|
948
|
+
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
|
949
|
+
manifest.devDependencies = { '@astrale-os/ui': '0.3.0-beta.0' }
|
|
950
|
+
await writeFile(manifestPath, JSON.stringify(manifest))
|
|
951
|
+
|
|
952
|
+
await initUi({ path: root, version: '0.3.0-beta.0', install: false }, { fetcher: mockFetch() })
|
|
953
|
+
|
|
954
|
+
const written = JSON.parse(await readFile(manifestPath, 'utf8'))
|
|
955
|
+
expect(written.dependencies['@astrale-os/ui']).toBeUndefined()
|
|
956
|
+
expect(written.devDependencies['@astrale-os/ui']).toBe('0.3.0-beta.0')
|
|
957
|
+
})
|
|
958
|
+
|
|
959
|
+
test('doctor accepts one exact development dependency and rejects drift or duplication', async () => {
|
|
960
|
+
const root = await lockedFixture()
|
|
961
|
+
const manifestPath = path.join(root, 'package.json')
|
|
962
|
+
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
|
963
|
+
delete manifest.dependencies['@astrale-os/ui']
|
|
964
|
+
manifest.devDependencies = { '@astrale-os/ui': '0.3.0-beta.0' }
|
|
965
|
+
await writeFile(manifestPath, JSON.stringify(manifest))
|
|
966
|
+
await writeFile(
|
|
967
|
+
path.join(root, 'components.json'),
|
|
968
|
+
JSON.stringify({ style: 'base-nova', tailwind: { css: 'src/index.css' } }),
|
|
969
|
+
)
|
|
970
|
+
await writeFile(
|
|
971
|
+
path.join(root, 'src/index.css'),
|
|
972
|
+
"@import '@astrale-os/ui/theme.css';\n@import '@astrale-os/ui/presets/astrale.css';\n",
|
|
973
|
+
)
|
|
974
|
+
|
|
975
|
+
expect((await doctorUi(root)).healthy).toBe(true)
|
|
976
|
+
|
|
977
|
+
manifest.devDependencies['@astrale-os/ui'] = '0.3.0-beta.1'
|
|
978
|
+
await writeFile(manifestPath, JSON.stringify(manifest))
|
|
979
|
+
expect((await doctorUi(root)).checks.find(({ check }) => check === 'package')).toMatchObject({
|
|
980
|
+
ok: false,
|
|
981
|
+
detail: 'devDependencies:0.3.0-beta.1',
|
|
982
|
+
})
|
|
983
|
+
|
|
984
|
+
manifest.devDependencies['@astrale-os/ui'] = '0.3.0-beta.0'
|
|
985
|
+
manifest.dependencies['@astrale-os/ui'] = '0.3.0-beta.0'
|
|
986
|
+
await writeFile(manifestPath, JSON.stringify(manifest))
|
|
987
|
+
expect((await doctorUi(root)).checks.find(({ check }) => check === 'package')).toMatchObject({
|
|
988
|
+
ok: false,
|
|
989
|
+
detail: 'declared in dependencies and devDependencies',
|
|
990
|
+
})
|
|
991
|
+
})
|
|
992
|
+
|
|
993
|
+
test('repeated exact init performs no release fetch while requested drift rejects', async () => {
|
|
994
|
+
const root = await fixture()
|
|
995
|
+
const manifestPath = path.join(root, 'package.json')
|
|
996
|
+
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
|
997
|
+
manifest.dependencies['@astrale-os/ui'] = '0.3.0-beta.0'
|
|
998
|
+
await writeFile(manifestPath, JSON.stringify(manifest))
|
|
999
|
+
await writeFile(path.join(root, 'astrale-ui.lock.json'), JSON.stringify(lock()))
|
|
1000
|
+
await writeFile(
|
|
1001
|
+
path.join(root, 'components.json'),
|
|
1002
|
+
JSON.stringify({ style: 'base-nova', tailwind: { css: 'src/index.css' } }),
|
|
1003
|
+
)
|
|
1004
|
+
await writeFile(
|
|
1005
|
+
path.join(root, 'src/index.css'),
|
|
1006
|
+
"@import '@astrale-os/ui/theme.css';\n@import '@astrale-os/ui/presets/astrale.css';\n",
|
|
1007
|
+
)
|
|
1008
|
+
let fetched = false
|
|
1009
|
+
const result = await initUi(
|
|
1010
|
+
{ path: root, preset: 'astrale' },
|
|
1011
|
+
{
|
|
1012
|
+
fetcher: (async () => {
|
|
1013
|
+
fetched = true
|
|
1014
|
+
throw new Error('must not fetch')
|
|
1015
|
+
}) as unknown as typeof fetch,
|
|
1016
|
+
},
|
|
1017
|
+
)
|
|
1018
|
+
expect(result.status).toBe('unchanged')
|
|
1019
|
+
expect(fetched).toBe(false)
|
|
1020
|
+
await expect(initUi({ path: root, preset: 'compact' })).rejects.toMatchObject({
|
|
1021
|
+
code: 'UI_ITEM_CONFLICT',
|
|
1022
|
+
})
|
|
1023
|
+
manifest.dependencies['@astrale-os/ui'] = '0.3.0-beta.1'
|
|
1024
|
+
await writeFile(manifestPath, JSON.stringify(manifest))
|
|
1025
|
+
await expect(initUi({ path: root, preset: 'astrale' })).rejects.toMatchObject({
|
|
1026
|
+
code: 'UI_ITEM_CONFLICT',
|
|
1027
|
+
})
|
|
1028
|
+
})
|
|
1029
|
+
})
|
|
1030
|
+
|
|
1031
|
+
describe('UI source operations', () => {
|
|
1032
|
+
test('add rejects an empty programmatic request before project discovery or tool execution', async () => {
|
|
1033
|
+
await expect(addUi([], {})).rejects.toMatchObject({ code: 'UI_ITEM_NOT_FOUND' })
|
|
1034
|
+
})
|
|
1035
|
+
|
|
1036
|
+
test('rejects hostile lock file records before an operation can escape the project', () => {
|
|
1037
|
+
for (const items of [
|
|
1038
|
+
[],
|
|
1039
|
+
{
|
|
1040
|
+
'pattern/chart/line/basic': {
|
|
1041
|
+
address: 'pattern/chart/line/basic',
|
|
1042
|
+
sourceDigest: 'bad',
|
|
1043
|
+
files: {},
|
|
1044
|
+
},
|
|
1045
|
+
},
|
|
1046
|
+
{
|
|
1047
|
+
'pattern/chart/line/basic': {
|
|
1048
|
+
address: 'pattern/chart/line/basic',
|
|
1049
|
+
sourceDigest: 'b'.repeat(64),
|
|
1050
|
+
files: { '../../outside': 'c'.repeat(64) },
|
|
1051
|
+
},
|
|
1052
|
+
},
|
|
1053
|
+
]) {
|
|
1054
|
+
expect(() => parseUiLock({ ...lock(), items })).toThrow(UiError)
|
|
1055
|
+
}
|
|
1056
|
+
})
|
|
1057
|
+
|
|
1058
|
+
test('add dry-run invokes the exact shadcn version and does not advance the lock', async () => {
|
|
1059
|
+
const root = await lockedFixture()
|
|
1060
|
+
const before = await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8')
|
|
1061
|
+
const calls: Array<{ file: string; args: string[] }> = []
|
|
1062
|
+
const result = await addUi(
|
|
1063
|
+
['pattern/chart/line/basic'],
|
|
1064
|
+
{ project: root, dryRun: true },
|
|
1065
|
+
{
|
|
1066
|
+
fetcher: mockFetch(),
|
|
1067
|
+
runner: async (file, args) => {
|
|
1068
|
+
calls.push({ file, args })
|
|
1069
|
+
return { code: 0, stdout: 'planned', stderr: '' }
|
|
1070
|
+
},
|
|
1071
|
+
},
|
|
1072
|
+
)
|
|
1073
|
+
expect(result.status).toBe('planned')
|
|
1074
|
+
expect(result.sources).toEqual([
|
|
1075
|
+
{
|
|
1076
|
+
address: 'pattern/chart/line/basic',
|
|
1077
|
+
dependencies: [],
|
|
1078
|
+
files: ['components/astrale/pattern/chart/line-basic.tsx'],
|
|
1079
|
+
},
|
|
1080
|
+
])
|
|
1081
|
+
expect(calls[0]).toMatchObject({ file: 'pnpm' })
|
|
1082
|
+
expect(calls[0]?.args).toEqual(expect.arrayContaining(['dlx', 'shadcn@4.18.0', '--dry-run']))
|
|
1083
|
+
expect(await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8')).toBe(before)
|
|
1084
|
+
})
|
|
1085
|
+
|
|
1086
|
+
/** @evidence TEST-CLI-UI-THEME-OWNERSHIP */
|
|
1087
|
+
test('installs and activates a released theme while recording owned source', async () => {
|
|
1088
|
+
const root = await lockedFixture()
|
|
1089
|
+
await writeFile(
|
|
1090
|
+
path.join(root, 'components.json'),
|
|
1091
|
+
JSON.stringify({ style: 'base-nova', tailwind: { css: 'src/index.css' } }),
|
|
1092
|
+
)
|
|
1093
|
+
await writeFile(
|
|
1094
|
+
path.join(root, 'src/index.css'),
|
|
1095
|
+
"@import '@astrale-os/ui/theme.css';\n@import '@astrale-os/ui/presets/astrale.css';\n",
|
|
1096
|
+
)
|
|
1097
|
+
const target = path.join(root, 'components/astrale/theme/observatory.css')
|
|
1098
|
+
let invoked = false
|
|
1099
|
+
const result = await addUi(
|
|
1100
|
+
['theme/observatory'],
|
|
1101
|
+
{ project: root, yes: true },
|
|
1102
|
+
{
|
|
1103
|
+
fetcher: themeFetch(),
|
|
1104
|
+
runner: async () => {
|
|
1105
|
+
invoked = true
|
|
1106
|
+
return { code: 0, stdout: '', stderr: '' }
|
|
1107
|
+
},
|
|
1108
|
+
},
|
|
1109
|
+
)
|
|
1110
|
+
|
|
1111
|
+
expect(invoked).toBe(false)
|
|
1112
|
+
expect(result).toMatchObject({ status: 'installed', items: ['theme/observatory'] })
|
|
1113
|
+
expect(await readFile(target, 'utf8')).toBe(themeCss)
|
|
1114
|
+
expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toContain(
|
|
1115
|
+
"@import '../components/astrale/theme/observatory.css';",
|
|
1116
|
+
)
|
|
1117
|
+
const written = JSON.parse(await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8'))
|
|
1118
|
+
expect(written.items['theme/observatory']).toMatchObject({
|
|
1119
|
+
address: 'theme/observatory',
|
|
1120
|
+
sourceDigest: digest(JSON.stringify(builtThemeItem())),
|
|
1121
|
+
files: { 'components/astrale/theme/observatory.css': digest(themeCss) },
|
|
1122
|
+
})
|
|
1123
|
+
expect((await doctorUi(root)).healthy).toBe(true)
|
|
1124
|
+
})
|
|
1125
|
+
|
|
1126
|
+
/** @evidence TEST-CLI-UI-THEME-OWNERSHIP */
|
|
1127
|
+
test('installs a playground-exported theme without registry or shadcn and preserves edits', async () => {
|
|
1128
|
+
const root = await lockedFixture()
|
|
1129
|
+
const source = path.join(root, 'observatory.css')
|
|
1130
|
+
const target = path.join(root, 'components/astrale/theme/observatory.css')
|
|
1131
|
+
await writeFile(source, themeCss)
|
|
1132
|
+
let invoked = false
|
|
1133
|
+
|
|
1134
|
+
const planned = await addUi(
|
|
1135
|
+
['./observatory.css'],
|
|
1136
|
+
{ project: root, dryRun: true },
|
|
1137
|
+
{
|
|
1138
|
+
fetcher: (async () => {
|
|
1139
|
+
invoked = true
|
|
1140
|
+
throw new Error('must not fetch')
|
|
1141
|
+
}) as unknown as typeof fetch,
|
|
1142
|
+
runner: async () => {
|
|
1143
|
+
invoked = true
|
|
1144
|
+
return { code: 1, stdout: '', stderr: '' }
|
|
1145
|
+
},
|
|
1146
|
+
},
|
|
1147
|
+
)
|
|
1148
|
+
expect(planned).toMatchObject({
|
|
1149
|
+
status: 'planned',
|
|
1150
|
+
items: ['theme/observatory'],
|
|
1151
|
+
activation: {
|
|
1152
|
+
file: 'src/index.css',
|
|
1153
|
+
import: "@import '../components/astrale/theme/observatory.css';",
|
|
1154
|
+
},
|
|
1155
|
+
})
|
|
1156
|
+
expect(await Bun.file(target).exists()).toBe(false)
|
|
1157
|
+
|
|
1158
|
+
await addUi(
|
|
1159
|
+
['./observatory.css'],
|
|
1160
|
+
{ project: root },
|
|
1161
|
+
{
|
|
1162
|
+
fetcher: (async () => {
|
|
1163
|
+
invoked = true
|
|
1164
|
+
throw new Error('must not fetch')
|
|
1165
|
+
}) as unknown as typeof fetch,
|
|
1166
|
+
runner: async () => {
|
|
1167
|
+
invoked = true
|
|
1168
|
+
return { code: 1, stdout: '', stderr: '' }
|
|
1169
|
+
},
|
|
1170
|
+
},
|
|
1171
|
+
)
|
|
1172
|
+
expect(invoked).toBe(false)
|
|
1173
|
+
expect(await readFile(target, 'utf8')).toBe(themeCss)
|
|
1174
|
+
expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toContain(
|
|
1175
|
+
"@import '../components/astrale/theme/observatory.css';",
|
|
1176
|
+
)
|
|
1177
|
+
|
|
1178
|
+
await writeFile(target, 'consumer edit\n')
|
|
1179
|
+
await expect(addUi(['./observatory.css'], { project: root })).rejects.toMatchObject({
|
|
1180
|
+
code: 'UI_LOCAL_CHANGES',
|
|
1181
|
+
})
|
|
1182
|
+
await addUi(['./observatory.css'], { project: root, overwrite: true, yes: true })
|
|
1183
|
+
expect(await readFile(target, 'utf8')).toBe(themeCss)
|
|
1184
|
+
})
|
|
1185
|
+
|
|
1186
|
+
test('rejects malformed and symlinked local themes before project mutation', async () => {
|
|
1187
|
+
const root = await lockedFixture()
|
|
1188
|
+
const cssBefore = await readFile(path.join(root, 'src/index.css'), 'utf8')
|
|
1189
|
+
await writeFile(path.join(root, 'unsafe.css'), "@import 'https://example.invalid/theme.css';\n")
|
|
1190
|
+
await expect(addUi(['./unsafe.css'], { project: root })).rejects.toMatchObject({
|
|
1191
|
+
code: 'UI_ITEM_CONFLICT',
|
|
1192
|
+
})
|
|
1193
|
+
|
|
1194
|
+
const outside = await mkdtemp(path.join(tmpdir(), 'astrale-ui-theme-'))
|
|
1195
|
+
temporary.push(outside)
|
|
1196
|
+
await writeFile(path.join(outside, 'observatory.css'), themeCss)
|
|
1197
|
+
await symlink(path.join(outside, 'observatory.css'), path.join(root, 'linked.css'))
|
|
1198
|
+
await expect(addUi(['./linked.css'], { project: root })).rejects.toMatchObject({
|
|
1199
|
+
code: 'UI_ITEM_NOT_FOUND',
|
|
1200
|
+
})
|
|
1201
|
+
expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toBe(cssBefore)
|
|
1202
|
+
})
|
|
1203
|
+
|
|
1204
|
+
test('rejects a symlinked UI lock before a theme can mutate outside the project', async () => {
|
|
1205
|
+
const root = await lockedFixture()
|
|
1206
|
+
const source = path.join(root, 'observatory.css')
|
|
1207
|
+
await writeFile(source, themeCss)
|
|
1208
|
+
const lockPath = path.join(root, 'astrale-ui.lock.json')
|
|
1209
|
+
const outside = await mkdtemp(path.join(tmpdir(), 'astrale-ui-lock-'))
|
|
1210
|
+
temporary.push(outside)
|
|
1211
|
+
const outsideLock = path.join(outside, 'astrale-ui.lock.json')
|
|
1212
|
+
const lockSource = await readFile(lockPath, 'utf8')
|
|
1213
|
+
await writeFile(outsideLock, lockSource)
|
|
1214
|
+
await rm(lockPath)
|
|
1215
|
+
await symlink(outsideLock, lockPath)
|
|
1216
|
+
const cssBefore = await readFile(path.join(root, 'src/index.css'), 'utf8')
|
|
1217
|
+
|
|
1218
|
+
await expect(addUi(['./observatory.css'], { project: root })).rejects.toMatchObject({
|
|
1219
|
+
code: 'UI_LOCK_INVALID',
|
|
1220
|
+
})
|
|
1221
|
+
expect(await readFile(outsideLock, 'utf8')).toBe(lockSource)
|
|
1222
|
+
expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toBe(cssBefore)
|
|
1223
|
+
expect(
|
|
1224
|
+
await Bun.file(path.join(root, 'components/astrale/theme/observatory.css')).exists(),
|
|
1225
|
+
).toBe(false)
|
|
1226
|
+
})
|
|
1227
|
+
|
|
1228
|
+
test('rolls back a newly copied local theme and activation when the lock commit fails', async () => {
|
|
1229
|
+
const root = await lockedFixture()
|
|
1230
|
+
await writeFile(path.join(root, 'observatory.css'), themeCss)
|
|
1231
|
+
const lockPath = path.join(root, 'astrale-ui.lock.json')
|
|
1232
|
+
const cssPath = path.join(root, 'src/index.css')
|
|
1233
|
+
const target = path.join(root, 'components/astrale/theme/observatory.css')
|
|
1234
|
+
const lockBefore = await readFile(lockPath, 'utf8')
|
|
1235
|
+
const cssBefore = await readFile(cssPath, 'utf8')
|
|
1236
|
+
await chmod(lockPath, 0o444)
|
|
1237
|
+
|
|
1238
|
+
try {
|
|
1239
|
+
await expect(addUi(['./observatory.css'], { project: root })).rejects.toBeInstanceOf(Error)
|
|
1240
|
+
} finally {
|
|
1241
|
+
await chmod(lockPath, 0o644)
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
expect(await Bun.file(target).exists()).toBe(false)
|
|
1245
|
+
expect(await readFile(cssPath, 'utf8')).toBe(cssBefore)
|
|
1246
|
+
expect(await readFile(lockPath, 'utf8')).toBe(lockBefore)
|
|
1247
|
+
})
|
|
1248
|
+
|
|
1249
|
+
test('successful add records installed file digests and doctor detects later edits', async () => {
|
|
1250
|
+
const root = await lockedFixture()
|
|
1251
|
+
await writeFile(
|
|
1252
|
+
path.join(root, 'components.json'),
|
|
1253
|
+
JSON.stringify({ style: 'base-nova', tailwind: { css: 'src/index.css' } }),
|
|
1254
|
+
)
|
|
1255
|
+
await writeFile(
|
|
1256
|
+
path.join(root, 'package.json'),
|
|
1257
|
+
JSON.stringify({
|
|
1258
|
+
name: 'fixture',
|
|
1259
|
+
dependencies: {
|
|
1260
|
+
react: '19.2.8',
|
|
1261
|
+
'react-dom': '19.2.8',
|
|
1262
|
+
tailwindcss: '4.3.3',
|
|
1263
|
+
'@astrale-os/ui': '0.3.0-beta.0',
|
|
1264
|
+
},
|
|
1265
|
+
}),
|
|
1266
|
+
)
|
|
1267
|
+
await writeFile(
|
|
1268
|
+
path.join(root, 'src/index.css'),
|
|
1269
|
+
"@import '@astrale-os/ui/theme.css';\n@import '@astrale-os/ui/presets/astrale.css';\n",
|
|
1270
|
+
)
|
|
1271
|
+
const installed = path.join(root, 'components/astrale/pattern/chart/line-basic.tsx')
|
|
1272
|
+
await addUi(
|
|
1273
|
+
['pattern/chart/line/basic'],
|
|
1274
|
+
{ project: root, yes: true },
|
|
1275
|
+
{
|
|
1276
|
+
fetcher: mockFetch(),
|
|
1277
|
+
runner: async () => {
|
|
1278
|
+
await mkdir(path.dirname(installed), { recursive: true })
|
|
1279
|
+
await writeFile(installed, 'export const Chart = true\n')
|
|
1280
|
+
return { code: 0, stdout: '', stderr: '' }
|
|
1281
|
+
},
|
|
1282
|
+
},
|
|
1283
|
+
)
|
|
1284
|
+
const written = JSON.parse(await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8'))
|
|
1285
|
+
expect(written.items['pattern/chart/line/basic'].files).toEqual({
|
|
1286
|
+
'components/astrale/pattern/chart/line-basic.tsx': digest('export const Chart = true\n'),
|
|
1287
|
+
})
|
|
1288
|
+
expect(written.items['pattern/chart/line/basic'].sourceDigest).toBe(
|
|
1289
|
+
digest(JSON.stringify(builtItem(registry.items[0]!))),
|
|
1290
|
+
)
|
|
1291
|
+
expect((await doctorUi(root)).healthy).toBe(true)
|
|
1292
|
+
await writeFile(installed, 'consumer edit\n')
|
|
1293
|
+
expect((await doctorUi(root)).healthy).toBe(false)
|
|
1294
|
+
await expect(
|
|
1295
|
+
addUi(
|
|
1296
|
+
['pattern/chart/line/basic'],
|
|
1297
|
+
{ project: root },
|
|
1298
|
+
{ fetcher: mockFetch(), runner: async () => ({ code: 0, stdout: '', stderr: '' }) },
|
|
1299
|
+
),
|
|
1300
|
+
).rejects.toBeInstanceOf(UiError)
|
|
1301
|
+
})
|
|
1302
|
+
|
|
1303
|
+
test('records multi-file component targets resolved through the consumer components alias', async () => {
|
|
1304
|
+
const root = await lockedFixture()
|
|
1305
|
+
await writeFile(
|
|
1306
|
+
path.join(root, 'components.json'),
|
|
1307
|
+
JSON.stringify({
|
|
1308
|
+
style: 'base-nova',
|
|
1309
|
+
tailwind: { css: 'src/index.css' },
|
|
1310
|
+
aliases: { components: '@/components' },
|
|
1311
|
+
}),
|
|
1312
|
+
)
|
|
1313
|
+
await writeFile(
|
|
1314
|
+
path.join(root, 'tsconfig.json'),
|
|
1315
|
+
JSON.stringify({
|
|
1316
|
+
compilerOptions: {
|
|
1317
|
+
paths: { '@/*': ['./src/*'], '@/components/*': ['./app/ui/*'] },
|
|
1318
|
+
},
|
|
1319
|
+
}),
|
|
1320
|
+
)
|
|
1321
|
+
await writeFile(
|
|
1322
|
+
path.join(root, 'src/index.css'),
|
|
1323
|
+
"@import '@astrale-os/ui/theme.css';\n@import '@astrale-os/ui/presets/astrale.css';\n",
|
|
1324
|
+
)
|
|
1325
|
+
const sidebar = path.join(root, 'src/components/astrale/component/sidebar/sidebar.tsx')
|
|
1326
|
+
const hook = path.join(root, 'src/components/astrale/component/sidebar/use-mobile.ts')
|
|
1327
|
+
|
|
1328
|
+
const planned = await addUi(
|
|
1329
|
+
['component/sidebar'],
|
|
1330
|
+
{ project: root, dryRun: true, yes: true },
|
|
1331
|
+
{
|
|
1332
|
+
fetcher: mockFetch([], componentRegistry),
|
|
1333
|
+
runner: async () => ({ code: 0, stdout: 'planned', stderr: '' }),
|
|
1334
|
+
},
|
|
1335
|
+
)
|
|
1336
|
+
expect(planned.sources).toEqual([
|
|
1337
|
+
expect.objectContaining({
|
|
1338
|
+
files: [
|
|
1339
|
+
'src/components/astrale/component/sidebar/sidebar.tsx',
|
|
1340
|
+
'src/components/astrale/component/sidebar/use-mobile.ts',
|
|
1341
|
+
],
|
|
1342
|
+
}),
|
|
1343
|
+
])
|
|
1344
|
+
|
|
1345
|
+
await addUi(
|
|
1346
|
+
['component/sidebar'],
|
|
1347
|
+
{ project: root, yes: true },
|
|
1348
|
+
{
|
|
1349
|
+
fetcher: mockFetch([], componentRegistry),
|
|
1350
|
+
runner: async () => {
|
|
1351
|
+
await mkdir(path.dirname(sidebar), { recursive: true })
|
|
1352
|
+
await writeFile(sidebar, 'export const Sidebar = true\n')
|
|
1353
|
+
await writeFile(hook, 'export const useMobile = true\n')
|
|
1354
|
+
return { code: 0, stdout: '', stderr: '' }
|
|
1355
|
+
},
|
|
1356
|
+
},
|
|
1357
|
+
)
|
|
1358
|
+
|
|
1359
|
+
const written = JSON.parse(await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8'))
|
|
1360
|
+
expect(written.items['component/sidebar'].files).toEqual({
|
|
1361
|
+
'src/components/astrale/component/sidebar/sidebar.tsx': digest(
|
|
1362
|
+
'export const Sidebar = true\n',
|
|
1363
|
+
),
|
|
1364
|
+
'src/components/astrale/component/sidebar/use-mobile.ts': digest(
|
|
1365
|
+
'export const useMobile = true\n',
|
|
1366
|
+
),
|
|
1367
|
+
})
|
|
1368
|
+
expect((await doctorUi(root)).healthy).toBe(true)
|
|
1369
|
+
})
|
|
1370
|
+
|
|
1371
|
+
test('resolves a components alias through package imports before tsconfig paths', async () => {
|
|
1372
|
+
const root = await lockedFixture()
|
|
1373
|
+
const manifestPath = path.join(root, 'package.json')
|
|
1374
|
+
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
|
1375
|
+
manifest.imports = { '#app/*': './src/app/*' }
|
|
1376
|
+
await writeFile(manifestPath, JSON.stringify(manifest))
|
|
1377
|
+
await writeFile(
|
|
1378
|
+
path.join(root, 'components.json'),
|
|
1379
|
+
JSON.stringify({
|
|
1380
|
+
style: 'base-nova',
|
|
1381
|
+
tailwind: { css: 'src/index.css' },
|
|
1382
|
+
aliases: { components: '#app/components' },
|
|
1383
|
+
}),
|
|
1384
|
+
)
|
|
1385
|
+
|
|
1386
|
+
const planned = await addUi(
|
|
1387
|
+
['pattern/chart/line/basic'],
|
|
1388
|
+
{ project: root, dryRun: true, yes: true },
|
|
1389
|
+
{
|
|
1390
|
+
fetcher: mockFetch(),
|
|
1391
|
+
runner: async () => ({ code: 0, stdout: 'planned', stderr: '' }),
|
|
1392
|
+
},
|
|
1393
|
+
)
|
|
1394
|
+
|
|
1395
|
+
expect(planned.sources).toEqual([
|
|
1396
|
+
expect.objectContaining({
|
|
1397
|
+
files: ['src/app/components/astrale/pattern/chart/line-basic.tsx'],
|
|
1398
|
+
}),
|
|
1399
|
+
])
|
|
1400
|
+
})
|
|
1401
|
+
|
|
1402
|
+
test('rejects an unresolved package-import alias before invoking shadcn', async () => {
|
|
1403
|
+
const root = await lockedFixture()
|
|
1404
|
+
await writeFile(
|
|
1405
|
+
path.join(root, 'components.json'),
|
|
1406
|
+
JSON.stringify({
|
|
1407
|
+
style: 'base-nova',
|
|
1408
|
+
tailwind: { css: 'src/index.css' },
|
|
1409
|
+
aliases: { components: '#missing/components' },
|
|
1410
|
+
}),
|
|
1411
|
+
)
|
|
1412
|
+
let invoked = false
|
|
1413
|
+
|
|
1414
|
+
await expect(
|
|
1415
|
+
addUi(
|
|
1416
|
+
['pattern/chart/line/basic'],
|
|
1417
|
+
{ project: root, yes: true },
|
|
1418
|
+
{
|
|
1419
|
+
fetcher: mockFetch(),
|
|
1420
|
+
runner: async () => {
|
|
1421
|
+
invoked = true
|
|
1422
|
+
return { code: 0, stdout: '', stderr: '' }
|
|
1423
|
+
},
|
|
1424
|
+
},
|
|
1425
|
+
),
|
|
1426
|
+
).rejects.toMatchObject({ code: 'UI_PROJECT_UNSUPPORTED' })
|
|
1427
|
+
expect(invoked).toBe(false)
|
|
1428
|
+
})
|
|
1429
|
+
|
|
1430
|
+
test('resolves an extended JSONC baseUrl with the pinned shadcn config loader', async () => {
|
|
1431
|
+
const root = await lockedFixture()
|
|
1432
|
+
await writeFile(
|
|
1433
|
+
path.join(root, 'components.json'),
|
|
1434
|
+
JSON.stringify({
|
|
1435
|
+
style: 'base-nova',
|
|
1436
|
+
tailwind: { css: 'src/index.css' },
|
|
1437
|
+
aliases: { components: '@/components' },
|
|
1438
|
+
}),
|
|
1439
|
+
)
|
|
1440
|
+
await mkdir(path.join(root, 'config'), { recursive: true })
|
|
1441
|
+
await writeFile(
|
|
1442
|
+
path.join(root, 'tsconfig.json'),
|
|
1443
|
+
'{\n // shadcn loads this root config.\n "extends": "./config/base.json",\n}\n',
|
|
1444
|
+
)
|
|
1445
|
+
await writeFile(
|
|
1446
|
+
path.join(root, 'config/base.json'),
|
|
1447
|
+
'{\n "compilerOptions": {\n "baseUrl": "..",\n "paths": { "@/*": ["frontend/src/*"], },\n },\n}\n',
|
|
1448
|
+
)
|
|
1449
|
+
|
|
1450
|
+
const planned = await addUi(
|
|
1451
|
+
['pattern/chart/line/basic'],
|
|
1452
|
+
{ project: root, dryRun: true, yes: true },
|
|
1453
|
+
{
|
|
1454
|
+
fetcher: mockFetch(),
|
|
1455
|
+
runner: async () => ({ code: 0, stdout: 'planned', stderr: '' }),
|
|
1456
|
+
},
|
|
1457
|
+
)
|
|
1458
|
+
|
|
1459
|
+
expect(planned.sources).toEqual([
|
|
1460
|
+
expect.objectContaining({
|
|
1461
|
+
files: ['frontend/src/components/astrale/pattern/chart/line-basic.tsx'],
|
|
1462
|
+
}),
|
|
1463
|
+
])
|
|
1464
|
+
})
|
|
1465
|
+
|
|
1466
|
+
test('rejects an alias mapping outside the project before invoking shadcn', async () => {
|
|
1467
|
+
const root = await lockedFixture()
|
|
1468
|
+
await writeFile(
|
|
1469
|
+
path.join(root, 'components.json'),
|
|
1470
|
+
JSON.stringify({
|
|
1471
|
+
style: 'base-nova',
|
|
1472
|
+
tailwind: { css: 'src/index.css' },
|
|
1473
|
+
aliases: { components: '@/components' },
|
|
1474
|
+
}),
|
|
1475
|
+
)
|
|
1476
|
+
await writeFile(
|
|
1477
|
+
path.join(root, 'tsconfig.json'),
|
|
1478
|
+
JSON.stringify({ compilerOptions: { paths: { '@/*': ['../outside/*'] } } }),
|
|
1479
|
+
)
|
|
1480
|
+
let invoked = false
|
|
1481
|
+
|
|
1482
|
+
await expect(
|
|
1483
|
+
addUi(
|
|
1484
|
+
['pattern/chart/line/basic'],
|
|
1485
|
+
{ project: root, yes: true },
|
|
1486
|
+
{
|
|
1487
|
+
fetcher: mockFetch(),
|
|
1488
|
+
runner: async () => {
|
|
1489
|
+
invoked = true
|
|
1490
|
+
return { code: 0, stdout: '', stderr: '' }
|
|
1491
|
+
},
|
|
1492
|
+
},
|
|
1493
|
+
),
|
|
1494
|
+
).rejects.toMatchObject({ code: 'UI_PROJECT_UNSUPPORTED' })
|
|
1495
|
+
expect(invoked).toBe(false)
|
|
1496
|
+
})
|
|
1497
|
+
|
|
1498
|
+
test('restores the exact locked UI dependency after shadcn applies its compatible range', async () => {
|
|
1499
|
+
const root = await lockedFixture()
|
|
1500
|
+
const installed = path.join(root, 'components/astrale/pattern/chart/line-basic.tsx')
|
|
1501
|
+
const calls: Array<{ file: string; args: string[] }> = []
|
|
1502
|
+
|
|
1503
|
+
await addUi(
|
|
1504
|
+
['pattern/chart/line/basic'],
|
|
1505
|
+
{ project: root, yes: true },
|
|
1506
|
+
{
|
|
1507
|
+
fetcher: mockFetch(),
|
|
1508
|
+
runner: async (file, args) => {
|
|
1509
|
+
calls.push({ file, args })
|
|
1510
|
+
if (args[0] === 'dlx') {
|
|
1511
|
+
await mkdir(path.dirname(installed), { recursive: true })
|
|
1512
|
+
await writeFile(installed, 'export const Chart = true\n')
|
|
1513
|
+
const manifestPath = path.join(root, 'package.json')
|
|
1514
|
+
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
|
1515
|
+
manifest.dependencies['@astrale-os/ui'] = '^0.3.0-beta.0'
|
|
1516
|
+
await writeFile(manifestPath, JSON.stringify(manifest))
|
|
1517
|
+
}
|
|
1518
|
+
return { code: 0, stdout: '', stderr: '' }
|
|
1519
|
+
},
|
|
1520
|
+
},
|
|
1521
|
+
)
|
|
1522
|
+
|
|
1523
|
+
expect(calls).toEqual([
|
|
1524
|
+
expect.objectContaining({ file: 'pnpm', args: expect.arrayContaining(['dlx']) }),
|
|
1525
|
+
{ file: 'pnpm', args: ['install'] },
|
|
1526
|
+
])
|
|
1527
|
+
expect(
|
|
1528
|
+
JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')).dependencies[
|
|
1529
|
+
'@astrale-os/ui'
|
|
1530
|
+
],
|
|
1531
|
+
).toBe('0.3.0-beta.0')
|
|
1532
|
+
})
|
|
1533
|
+
|
|
1534
|
+
test('restores a development-only UI dependency without retaining shadcn duplication', async () => {
|
|
1535
|
+
const root = await lockedFixture()
|
|
1536
|
+
const manifestPath = path.join(root, 'package.json')
|
|
1537
|
+
const initial = JSON.parse(await readFile(manifestPath, 'utf8'))
|
|
1538
|
+
delete initial.dependencies['@astrale-os/ui']
|
|
1539
|
+
initial.devDependencies = { '@astrale-os/ui': '0.3.0-beta.0' }
|
|
1540
|
+
await writeFile(manifestPath, JSON.stringify(initial))
|
|
1541
|
+
const installed = path.join(root, 'components/astrale/pattern/chart/line-basic.tsx')
|
|
1542
|
+
|
|
1543
|
+
await addUi(
|
|
1544
|
+
['pattern/chart/line/basic'],
|
|
1545
|
+
{ project: root, yes: true },
|
|
1546
|
+
{
|
|
1547
|
+
fetcher: mockFetch(),
|
|
1548
|
+
runner: async (_file, args) => {
|
|
1549
|
+
if (args[0] === 'dlx') {
|
|
1550
|
+
await mkdir(path.dirname(installed), { recursive: true })
|
|
1551
|
+
await writeFile(installed, 'export const Chart = true\n')
|
|
1552
|
+
const changed = JSON.parse(await readFile(manifestPath, 'utf8'))
|
|
1553
|
+
changed.dependencies['@astrale-os/ui'] = '^0.3.0-beta.0'
|
|
1554
|
+
await writeFile(manifestPath, JSON.stringify(changed))
|
|
1555
|
+
}
|
|
1556
|
+
return { code: 0, stdout: '', stderr: '' }
|
|
1557
|
+
},
|
|
1558
|
+
},
|
|
1559
|
+
)
|
|
1560
|
+
|
|
1561
|
+
const written = JSON.parse(await readFile(manifestPath, 'utf8'))
|
|
1562
|
+
expect(written.dependencies['@astrale-os/ui']).toBeUndefined()
|
|
1563
|
+
expect(written.devDependencies['@astrale-os/ui']).toBe('0.3.0-beta.0')
|
|
1564
|
+
})
|
|
1565
|
+
|
|
1566
|
+
test('rolls back item and package state when restoring the locked dependency fails', async () => {
|
|
1567
|
+
const root = await lockedFixture()
|
|
1568
|
+
const manifestPath = path.join(root, 'package.json')
|
|
1569
|
+
const lockPath = path.join(root, 'astrale-ui.lock.json')
|
|
1570
|
+
const installed = path.join(root, 'components/astrale/pattern/chart/line-basic.tsx')
|
|
1571
|
+
const manifestBefore = await readFile(manifestPath, 'utf8')
|
|
1572
|
+
const lockBefore = await readFile(lockPath, 'utf8')
|
|
1573
|
+
|
|
1574
|
+
await expect(
|
|
1575
|
+
addUi(
|
|
1576
|
+
['pattern/chart/line/basic'],
|
|
1577
|
+
{ project: root, yes: true },
|
|
1578
|
+
{
|
|
1579
|
+
fetcher: mockFetch(),
|
|
1580
|
+
runner: async (_file, args) => {
|
|
1581
|
+
if (args[0] === 'dlx') {
|
|
1582
|
+
await mkdir(path.dirname(installed), { recursive: true })
|
|
1583
|
+
await writeFile(installed, 'export const Chart = true\n')
|
|
1584
|
+
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
|
1585
|
+
manifest.dependencies['@astrale-os/ui'] = '^0.3.0-beta.0'
|
|
1586
|
+
await writeFile(manifestPath, JSON.stringify(manifest))
|
|
1587
|
+
return { code: 0, stdout: '', stderr: '' }
|
|
1588
|
+
}
|
|
1589
|
+
await writeFile(path.join(root, 'pnpm-lock.yaml'), 'partial lock\n')
|
|
1590
|
+
return { code: 1, stdout: '', stderr: 'registry timeout' }
|
|
1591
|
+
},
|
|
1592
|
+
},
|
|
1593
|
+
),
|
|
1594
|
+
).rejects.toMatchObject({ code: 'UI_DEPENDENCY_INSTALL_FAILED' })
|
|
1595
|
+
|
|
1596
|
+
expect(await readFile(manifestPath, 'utf8')).toBe(manifestBefore)
|
|
1597
|
+
expect(await readFile(lockPath, 'utf8')).toBe(lockBefore)
|
|
1598
|
+
expect(await Bun.file(installed).exists()).toBe(false)
|
|
1599
|
+
expect(await Bun.file(path.join(root, 'pnpm-lock.yaml')).exists()).toBe(false)
|
|
1600
|
+
})
|
|
1601
|
+
|
|
1602
|
+
test('preflights symlink targets without invoking shadcn', async () => {
|
|
1603
|
+
const root = await lockedFixture()
|
|
1604
|
+
const outside = await mkdtemp(path.join(tmpdir(), 'astrale-ui-outside-'))
|
|
1605
|
+
temporary.push(outside)
|
|
1606
|
+
await symlink(outside, path.join(root, 'components'))
|
|
1607
|
+
let invoked = false
|
|
1608
|
+
await expect(
|
|
1609
|
+
addUi(
|
|
1610
|
+
['pattern/chart/line/basic'],
|
|
1611
|
+
{ project: root },
|
|
1612
|
+
{
|
|
1613
|
+
fetcher: mockFetch(),
|
|
1614
|
+
runner: async () => {
|
|
1615
|
+
invoked = true
|
|
1616
|
+
return { code: 0, stdout: '', stderr: '' }
|
|
1617
|
+
},
|
|
1618
|
+
},
|
|
1619
|
+
),
|
|
1620
|
+
).rejects.toMatchObject({ code: 'UI_LOCK_INVALID' })
|
|
1621
|
+
expect(invoked).toBe(false)
|
|
1622
|
+
})
|
|
1623
|
+
|
|
1624
|
+
test('restores alias-resolved files and package state after a partial shadcn failure', async () => {
|
|
1625
|
+
const root = await lockedFixture()
|
|
1626
|
+
await writeFile(
|
|
1627
|
+
path.join(root, 'components.json'),
|
|
1628
|
+
JSON.stringify({
|
|
1629
|
+
style: 'base-nova',
|
|
1630
|
+
tailwind: { css: 'src/index.css' },
|
|
1631
|
+
aliases: { components: '@/components' },
|
|
1632
|
+
}),
|
|
1633
|
+
)
|
|
1634
|
+
await writeFile(
|
|
1635
|
+
path.join(root, 'tsconfig.json'),
|
|
1636
|
+
JSON.stringify({ compilerOptions: { paths: { '@/*': ['./src/*'] } } }),
|
|
1637
|
+
)
|
|
1638
|
+
const first = path.join(root, 'src/components/astrale/pattern/chart/line-basic.tsx')
|
|
1639
|
+
const second = path.join(root, 'src/components/astrale/pattern/chart/summary.tsx')
|
|
1640
|
+
await mkdir(path.dirname(first), { recursive: true })
|
|
1641
|
+
await writeFile(first, 'consumer original\n')
|
|
1642
|
+
const twoFileRegistry: UiRegistry = {
|
|
1643
|
+
...registry,
|
|
1644
|
+
items: [
|
|
1645
|
+
{
|
|
1646
|
+
...registry.items[0]!,
|
|
1647
|
+
files: [
|
|
1648
|
+
registry.items[0]!.files[0]!,
|
|
1649
|
+
{
|
|
1650
|
+
path: 'summary.tsx',
|
|
1651
|
+
type: 'registry:component',
|
|
1652
|
+
target: 'components/astrale/pattern/chart/summary.tsx',
|
|
1653
|
+
},
|
|
1654
|
+
],
|
|
1655
|
+
},
|
|
1656
|
+
],
|
|
1657
|
+
}
|
|
1658
|
+
const lockBefore = await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8')
|
|
1659
|
+
await expect(
|
|
1660
|
+
addUi(
|
|
1661
|
+
['pattern/chart/line/basic'],
|
|
1662
|
+
{ project: root },
|
|
1663
|
+
{
|
|
1664
|
+
fetcher: mockFetch([], twoFileRegistry),
|
|
1665
|
+
runner: async () => {
|
|
1666
|
+
await writeFile(first, 'partial overwrite\n')
|
|
1667
|
+
await writeFile(second, 'partial create\n')
|
|
1668
|
+
return { code: 1, stdout: '', stderr: 'interrupted' }
|
|
1669
|
+
},
|
|
1670
|
+
},
|
|
1671
|
+
),
|
|
1672
|
+
).rejects.toMatchObject({ code: 'UI_TOOL_FAILED' })
|
|
1673
|
+
expect(await readFile(first, 'utf8')).toBe('consumer original\n')
|
|
1674
|
+
expect(await Bun.file(second).exists()).toBe(false)
|
|
1675
|
+
expect(await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8')).toBe(lockBefore)
|
|
1676
|
+
})
|
|
1677
|
+
|
|
1678
|
+
test('overwrite requires explicit yes confirmation before invoking shadcn', async () => {
|
|
1679
|
+
const root = await lockedFixture()
|
|
1680
|
+
let invoked = false
|
|
1681
|
+
await expect(
|
|
1682
|
+
addUi(
|
|
1683
|
+
['pattern/chart/line/basic'],
|
|
1684
|
+
{ project: root, overwrite: true },
|
|
1685
|
+
{
|
|
1686
|
+
fetcher: mockFetch(),
|
|
1687
|
+
runner: async () => {
|
|
1688
|
+
invoked = true
|
|
1689
|
+
return { code: 0, stdout: '', stderr: '' }
|
|
1690
|
+
},
|
|
1691
|
+
},
|
|
1692
|
+
),
|
|
1693
|
+
).rejects.toMatchObject({ code: 'UI_LOCAL_CHANGES' })
|
|
1694
|
+
expect(invoked).toBe(false)
|
|
1695
|
+
})
|
|
1696
|
+
|
|
1697
|
+
/** @evidence TEST-CLI-UI-EXACT-ITEM-SOURCE */
|
|
1698
|
+
test('rejects a built item that differs from the admitted release index before invoking shadcn', async () => {
|
|
1699
|
+
const root = await lockedFixture()
|
|
1700
|
+
const fallback = mockFetch()
|
|
1701
|
+
let invoked = false
|
|
1702
|
+
const malformed = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
1703
|
+
const url = String(input)
|
|
1704
|
+
if (url.endsWith('/registry/public/r/pattern-chart-line-basic.json')) {
|
|
1705
|
+
return Response.json(registry.items[0])
|
|
1706
|
+
}
|
|
1707
|
+
return fallback(input, init)
|
|
1708
|
+
}) as typeof fetch
|
|
1709
|
+
await expect(
|
|
1710
|
+
addUi(
|
|
1711
|
+
['pattern/chart/line/basic'],
|
|
1712
|
+
{ project: root },
|
|
1713
|
+
{
|
|
1714
|
+
fetcher: malformed,
|
|
1715
|
+
runner: async () => {
|
|
1716
|
+
invoked = true
|
|
1717
|
+
return { code: 0, stdout: '', stderr: '' }
|
|
1718
|
+
},
|
|
1719
|
+
},
|
|
1720
|
+
),
|
|
1721
|
+
).rejects.toMatchObject({ code: 'UI_REGISTRY_UNAVAILABLE' })
|
|
1722
|
+
expect(invoked).toBe(false)
|
|
1723
|
+
})
|
|
1724
|
+
|
|
1725
|
+
test('preset dry-run is read-only and apply changes CSS plus lock without source rewrites', async () => {
|
|
1726
|
+
const root = await fixture()
|
|
1727
|
+
await writeFile(path.join(root, 'astrale-ui.lock.json'), JSON.stringify(lock()))
|
|
1728
|
+
await writeFile(
|
|
1729
|
+
path.join(root, 'src/index.css'),
|
|
1730
|
+
"@import '@astrale-os/ui/theme.css';\n@import '@astrale-os/ui/presets/astrale.css';\n",
|
|
1731
|
+
)
|
|
1732
|
+
const before = await readFile(path.join(root, 'src/index.css'), 'utf8')
|
|
1733
|
+
await applyPreset('expressive', { project: root, dryRun: true })
|
|
1734
|
+
expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toBe(before)
|
|
1735
|
+
await applyPreset('expressive', { project: root })
|
|
1736
|
+
expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toContain(
|
|
1737
|
+
'@astrale-os/ui/presets/expressive.css',
|
|
1738
|
+
)
|
|
1739
|
+
const written = JSON.parse(await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8'))
|
|
1740
|
+
expect(written.preset).toBe('expressive')
|
|
1741
|
+
})
|
|
1742
|
+
})
|