@astrale-os/cli 1.0.0-beta.2 → 1.0.0-beta.20
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 +16 -1
- package/dist/astrale.js +51105 -58057
- package/dist/public/connect-core.js +2085 -30702
- package/dist/public/keys/index.js +884 -29476
- package/dist/public/paths/index.js +13101 -41452
- 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 +7 -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/package.json +15 -20
- 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 +46 -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 +7 -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 +3 -14
- 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 +62 -10
- 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 +49 -0
- package/src/commands/call-describe.ts +60 -68
- package/src/commands/call.ts +71 -59
- package/src/commands/domain/install.ts +54 -35
- 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 +14 -15
- package/src/commands/identity/__tests__/register.test.ts +3 -3
- package/src/commands/identity/register.ts +5 -4
- 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 +48 -35
- package/src/commands/logs.ts +123 -33
- package/src/commands/mutate.ts +6 -5
- package/src/commands/query.ts +35 -32
- 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 +13 -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 +31 -11
- package/src/commands/view.ts +82 -37
- package/src/connection/.spec/api.d.ts +7 -8
- package/src/connection/.spec/architecture.md +5 -4
- 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__/credential.test.ts +20 -1
- package/src/connection/__tests__/errors.test.ts +27 -20
- package/src/connection/__tests__/exchange.test.ts +30 -1
- 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 +3 -3
- package/src/connection/__tests__/target.test.ts +1 -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 +47 -23
- 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 +19 -8
- package/src/errors.ts +3 -2
- package/src/graph/.spec/api.d.ts +12 -5
- package/src/graph/.spec/layout.ts +9 -1
- package/src/graph/__tests__/mutation.test.ts +2 -2
- package/src/graph/__tests__/query.test.ts +25 -6
- package/src/graph/class.ts +30 -0
- package/src/graph/index.ts +1 -0
- package/src/graph/query.ts +21 -32
- package/src/identity/.spec/api.d.ts +2 -2
- package/src/identity/__tests__/registration.test.ts +1 -1
- package/src/identity/registration.ts +3 -3
- 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__/command-dx.test.ts +1 -1
- 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__/studio-server-deps.test.ts +1 -1
- package/src/lib/__tests__/update.test.ts +264 -11
- package/src/lib/__tests__/view-assets.test.ts +14 -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/command-dx.ts +9 -9
- 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/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 +163 -6
- 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/telemetry/__tests__/recorder.test.ts +1 -0
- package/src/telemetry/recorder.ts +9 -1
- package/src/ui/.spec/api.d.ts +14 -0
- package/src/ui/.spec/architecture.md +10 -0
- package/src/ui/.spec/laws.ts +36 -0
- package/src/ui/.spec/layout.ts +16 -0
- package/src/ui/__tests__/ui.test.ts +981 -0
- package/src/ui/index.ts +13 -0
- package/src/ui/lock.ts +87 -0
- package/src/ui/model.ts +83 -0
- package/src/ui/operations.ts +710 -0
- package/src/ui/project.ts +219 -0
- package/src/ui/release.ts +321 -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-7YHdeCxp.js +81 -0
- package/studio/client/dist/assets/index-C0FAnwNw.css +2 -0
- package/studio/client/dist/assets/rolldown-runtime-Dd_uD5pT.js +1 -0
- package/studio/client/dist/assets/schema-studio-BilUNb6Z.css +1 -0
- package/studio/client/dist/assets/schema-studio-CABpTfvH.js +8 -0
- package/studio/client/dist/index.html +3 -2
- package/studio/package.json +7 -13
- 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 +32 -36
- package/studio/server/agent/prompts/anchors.ts +20 -49
- package/studio/server/agent/prompts/system.test.ts +3 -2
- package/studio/server/agent/prompts/system.ts +3 -3
- package/studio/server/agent/routes.test.ts +1 -1
- package/studio/server/agent/run/coordinator.test.ts +1 -1
- package/studio/server/api/canvas.ts +0 -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 +23 -12
- package/studio/server/cache.ts +12 -15
- package/studio/server/domain.test.ts +50 -68
- package/studio/server/domain.ts +82 -63
- package/studio/server/handoff/copy.ts +1 -1
- 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 +9 -14
- package/studio/server/introspect/anatomy-extras.test.ts +26 -42
- 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 +94 -389
- package/studio/server/introspect/canonical-schema.ts +227 -457
- package/studio/server/introspect/core-extractor.ts +18 -127
- package/studio/server/introspect/core.ts +1 -2
- package/studio/server/introspect/diff.test.ts +11 -7
- package/studio/server/introspect/diff.ts +18 -55
- package/studio/server/introspect/extractor.ts +22 -117
- package/studio/server/introspect/overlay-tsmorph.test.ts +119 -240
- package/studio/server/introspect/overlay.test.ts +26 -54
- package/studio/server/introspect/overlay.ts +10 -37
- package/studio/server/introspect/revision.test.ts +1 -1
- package/studio/server/introspect/revision.ts +1 -1
- package/studio/server/introspect/runtime.test.ts +65 -183
- package/studio/server/introspect/runtime.ts +8 -25
- package/studio/server/introspect/schema-ir-json.test.ts +70 -0
- package/studio/server/introspect/schema-ir-json.ts +38 -68
- 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/state/baseline.test.ts +14 -9
- package/studio/server/state/baseline.ts +3 -5
- package/studio/server/state/visibility.ts +1 -5
- package/studio/server/views/model.ts +3 -3
- package/studio/server/views/target.test.ts +17 -74
- package/studio/server/views/target.ts +24 -50
- package/studio/server/watch.test.ts +11 -11
- package/studio/server/watch.ts +8 -4
- package/studio/server/workspace-watch.ts +2 -2
- package/studio/shared/contracts/schema.ts +38 -107
- package/studio/shared/contracts/surface.test.ts +13 -15
- package/studio/shared/contracts/workspace.ts +2 -11
- package/studio/shared/schema/identity.test.ts +20 -44
- package/studio/shared/schema/identity.ts +9 -23
- package/viewer/dist/main.js +30 -51
- 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
|
@@ -0,0 +1,981 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { 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 } 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 compatibility = {
|
|
32
|
+
version: 1,
|
|
33
|
+
shadcn: '4.18.0',
|
|
34
|
+
base: 'base',
|
|
35
|
+
style: 'nova',
|
|
36
|
+
baseUi: '1.7.0',
|
|
37
|
+
react: '^18.3.1 || ^19.0.0',
|
|
38
|
+
tailwind: '^4.3.3',
|
|
39
|
+
presets: ['astrale', 'compact', 'expressive'],
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const temporary: string[] = []
|
|
43
|
+
|
|
44
|
+
afterEach(async () => {
|
|
45
|
+
await Promise.all(
|
|
46
|
+
temporary.splice(0).map((directory) => rm(directory, { recursive: true, force: true })),
|
|
47
|
+
)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
function mockFetch(seen: string[] = [], suppliedRegistry: UiRegistry = registry): typeof fetch {
|
|
51
|
+
return (async (input: string | URL | Request) => {
|
|
52
|
+
const url = String(input)
|
|
53
|
+
seen.push(url)
|
|
54
|
+
if (url.includes('/git/ref/tags/')) {
|
|
55
|
+
return Response.json({ object: { type: 'commit', sha: commit, url: '' } })
|
|
56
|
+
}
|
|
57
|
+
if (url.endsWith('/tooling/compatibility.json')) return Response.json(compatibility)
|
|
58
|
+
if (url.endsWith('/' + commit + '/registry.json')) {
|
|
59
|
+
return Response.json({
|
|
60
|
+
name: 'astrale-ui',
|
|
61
|
+
include: ['registry/base/registry.json', 'registry/patterns/chart/registry.json'],
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
if (url.endsWith('/registry/base/registry.json')) {
|
|
65
|
+
return Response.json({ items: [{ name: 'astrale-base', type: 'registry:base' }] })
|
|
66
|
+
}
|
|
67
|
+
if (url.endsWith('/registry/patterns/chart/registry.json'))
|
|
68
|
+
return Response.json(suppliedRegistry)
|
|
69
|
+
const item = suppliedRegistry.items.find((candidate) =>
|
|
70
|
+
url.endsWith('/registry/public/r/' + candidate.name + '.json'),
|
|
71
|
+
)
|
|
72
|
+
if (item) return Response.json(builtItem(item))
|
|
73
|
+
return new Response('not found', { status: 404 })
|
|
74
|
+
}) as typeof fetch
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function builtItem(item: UiRegistry['items'][number]) {
|
|
78
|
+
return {
|
|
79
|
+
...item,
|
|
80
|
+
files: item.files.map((file, index) => ({
|
|
81
|
+
...file,
|
|
82
|
+
path: `registry/patterns/chart/${file.path}`,
|
|
83
|
+
content: index === 0 ? 'export const Chart = true\n' : 'export const Summary = true\n',
|
|
84
|
+
})),
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function fixture(): Promise<string> {
|
|
89
|
+
const root = await mkdtemp(path.join(tmpdir(), 'astrale-ui-cli-'))
|
|
90
|
+
temporary.push(root)
|
|
91
|
+
await mkdir(path.join(root, 'src'), { recursive: true })
|
|
92
|
+
await writeFile(
|
|
93
|
+
path.join(root, 'package.json'),
|
|
94
|
+
JSON.stringify({
|
|
95
|
+
name: 'fixture',
|
|
96
|
+
private: true,
|
|
97
|
+
dependencies: {
|
|
98
|
+
react: '19.2.8',
|
|
99
|
+
'react-dom': '19.2.8',
|
|
100
|
+
tailwindcss: '4.3.3',
|
|
101
|
+
},
|
|
102
|
+
packageManager: 'pnpm@11.13.1',
|
|
103
|
+
}),
|
|
104
|
+
)
|
|
105
|
+
await writeFile(path.join(root, 'src/index.css'), '/* consumer css */\n')
|
|
106
|
+
return root
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function lockedFixture(): Promise<string> {
|
|
110
|
+
const root = await fixture()
|
|
111
|
+
const manifestPath = path.join(root, 'package.json')
|
|
112
|
+
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
|
113
|
+
manifest.dependencies['@astrale-os/ui'] = '0.3.0-beta.0'
|
|
114
|
+
await writeFile(manifestPath, JSON.stringify(manifest))
|
|
115
|
+
await writeFile(path.join(root, 'astrale-ui.lock.json'), JSON.stringify(lock()))
|
|
116
|
+
return root
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function lock(): UiLock {
|
|
120
|
+
return {
|
|
121
|
+
$schema: 'https://example.invalid/ui-lock.schema.json',
|
|
122
|
+
version: 1,
|
|
123
|
+
package: { name: '@astrale-os/ui', version: '0.3.0-beta.0' },
|
|
124
|
+
registry: { repository: 'astrale-os/ui', ref: 'v0.3.0-beta.0', commit },
|
|
125
|
+
tooling: { shadcn: '4.18.0', base: 'base', style: 'nova', baseUi: '1.7.0' },
|
|
126
|
+
preset: 'astrale',
|
|
127
|
+
items: {},
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
describe('UI release and runner contracts', () => {
|
|
132
|
+
test('resolves the default release from the public beta channel', async () => {
|
|
133
|
+
const seen: string[] = []
|
|
134
|
+
const fetcher = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
135
|
+
const url = String(input)
|
|
136
|
+
if (url.endsWith('/@astrale-os/ui/beta')) {
|
|
137
|
+
seen.push(url)
|
|
138
|
+
return Response.json({ version: '0.3.0-beta.1' })
|
|
139
|
+
}
|
|
140
|
+
return mockFetch(seen)(input, init)
|
|
141
|
+
}) as typeof fetch
|
|
142
|
+
|
|
143
|
+
const release = await resolveUiRelease(undefined, fetcher)
|
|
144
|
+
|
|
145
|
+
expect(release.version).toBe('0.3.0-beta.1')
|
|
146
|
+
expect(seen[0]).toBe('https://registry.npmjs.org/@astrale-os/ui/beta')
|
|
147
|
+
expect(seen).not.toContain('https://registry.npmjs.org/@astrale-os/ui/latest')
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
test('rejects a public beta channel that does not resolve to a beta release', async () => {
|
|
151
|
+
const fetcher = (async (input: string | URL | Request) => {
|
|
152
|
+
const url = String(input)
|
|
153
|
+
if (url.endsWith('/@astrale-os/ui/beta')) return Response.json({ version: '0.3.0' })
|
|
154
|
+
throw new Error('release snapshot must not be fetched')
|
|
155
|
+
}) as typeof fetch
|
|
156
|
+
|
|
157
|
+
await expect(resolveUiRelease(undefined, fetcher)).rejects.toMatchObject({
|
|
158
|
+
code: 'UI_REGISTRY_UNAVAILABLE',
|
|
159
|
+
message: 'Invalid UI beta release version: 0.3.0',
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
/** @evidence TEST-CLI-UI-ONE-SNAPSHOT */
|
|
164
|
+
test('resolves one commit and reads the full release snapshot from it', async () => {
|
|
165
|
+
const seen: string[] = []
|
|
166
|
+
const release = await resolveUiRelease('0.3.0-beta.0', mockFetch(seen))
|
|
167
|
+
expect(release.commit).toBe(commit)
|
|
168
|
+
expect(release.compatibility.base).toBe('base')
|
|
169
|
+
expect(release.registry.items).toHaveLength(1)
|
|
170
|
+
expect(release.registry.items[0]?.files[0]?.path).toBe('registry/patterns/chart/line-basic.tsx')
|
|
171
|
+
expect(seen.filter((url) => new URL(url).hostname === 'raw.githubusercontent.com')).toEqual(
|
|
172
|
+
expect.arrayContaining([
|
|
173
|
+
expect.stringContaining('/' + commit + '/tooling/compatibility.json'),
|
|
174
|
+
expect.stringContaining('/' + commit + '/registry'),
|
|
175
|
+
]),
|
|
176
|
+
)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test('constructs exact on-demand commands for every supported package manager', () => {
|
|
180
|
+
expect(shadcnInvocation('pnpm', '4.18.0', ['add', 'item'])).toEqual({
|
|
181
|
+
file: 'pnpm',
|
|
182
|
+
args: ['dlx', 'shadcn@4.18.0', 'add', 'item'],
|
|
183
|
+
})
|
|
184
|
+
expect(shadcnInvocation('npm', '4.18.0', ['add', 'item'])).toEqual({
|
|
185
|
+
file: 'npx',
|
|
186
|
+
args: ['--yes', 'shadcn@4.18.0', 'add', 'item'],
|
|
187
|
+
})
|
|
188
|
+
expect(shadcnInvocation('yarn', '4.18.0', ['add', 'item'])).toEqual({
|
|
189
|
+
file: 'yarn',
|
|
190
|
+
args: ['dlx', 'shadcn@4.18.0', 'add', 'item'],
|
|
191
|
+
})
|
|
192
|
+
expect(shadcnInvocation('bun', '4.18.0', ['add', 'item'])).toEqual({
|
|
193
|
+
file: 'bunx',
|
|
194
|
+
args: ['shadcn@4.18.0', 'add', 'item'],
|
|
195
|
+
})
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
test('rejects an unsafe authoritative root without falling back to a legacy registry', async () => {
|
|
199
|
+
const seen: string[] = []
|
|
200
|
+
const fallback = mockFetch(seen)
|
|
201
|
+
const unsafe = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
202
|
+
const url = String(input)
|
|
203
|
+
if (url.endsWith('/' + commit + '/registry.json')) {
|
|
204
|
+
return Response.json({ name: 'astrale-ui', include: ['../registry.json'] })
|
|
205
|
+
}
|
|
206
|
+
return fallback(input, init)
|
|
207
|
+
}) as typeof fetch
|
|
208
|
+
await expect(resolveUiRelease('0.3.0-beta.0', unsafe)).rejects.toThrow()
|
|
209
|
+
expect(seen.some((url) => url.endsWith('/registry/registry.json'))).toBe(false)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
test('rejects noncanonical and encoded registry includes before fetching them', async () => {
|
|
213
|
+
const unsafeIncludes = [
|
|
214
|
+
'%2e%2e/other/registry.json',
|
|
215
|
+
'registry/%2Fother/registry.json',
|
|
216
|
+
'registry/other/registry.json?raw=1',
|
|
217
|
+
'registry/other/registry.json#item',
|
|
218
|
+
'https://example.invalid/registry.json',
|
|
219
|
+
'./registry/other/registry.json',
|
|
220
|
+
]
|
|
221
|
+
|
|
222
|
+
for (const include of unsafeIncludes) {
|
|
223
|
+
const seen: string[] = []
|
|
224
|
+
const fallback = mockFetch(seen)
|
|
225
|
+
const fetcher = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
226
|
+
const url = String(input)
|
|
227
|
+
if (url.endsWith('/' + commit + '/registry.json')) {
|
|
228
|
+
return Response.json({ include: [include] })
|
|
229
|
+
}
|
|
230
|
+
return fallback(input, init)
|
|
231
|
+
}) as typeof fetch
|
|
232
|
+
|
|
233
|
+
await expect(resolveUiRelease('0.3.0-beta.0', fetcher)).rejects.toMatchObject({
|
|
234
|
+
code: 'UI_REGISTRY_UNAVAILABLE',
|
|
235
|
+
})
|
|
236
|
+
expect(seen.some((url) => url.includes('example.invalid') || url.includes('/other/'))).toBe(
|
|
237
|
+
false,
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
test('rejects malformed and already-qualified family-local item paths', async () => {
|
|
243
|
+
const unsafePaths = [
|
|
244
|
+
'',
|
|
245
|
+
'/absolute.tsx',
|
|
246
|
+
'C:/windows.tsx',
|
|
247
|
+
'./line-basic.tsx',
|
|
248
|
+
'../line-basic.tsx',
|
|
249
|
+
'%2e%2e/line-basic.tsx',
|
|
250
|
+
'registry/patterns/chart/line-basic.tsx',
|
|
251
|
+
]
|
|
252
|
+
|
|
253
|
+
for (const unsafePath of unsafePaths) {
|
|
254
|
+
const supplied = structuredClone(registry)
|
|
255
|
+
supplied.items[0]!.files[0]!.path = unsafePath
|
|
256
|
+
await expect(resolveUiRelease('0.3.0-beta.0', mockFetch([], supplied))).rejects.toMatchObject(
|
|
257
|
+
{
|
|
258
|
+
code: 'UI_REGISTRY_UNAVAILABLE',
|
|
259
|
+
},
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
test('qualifies every item file relative to its declaring nested registry', async () => {
|
|
265
|
+
const rootItem = {
|
|
266
|
+
...structuredClone(registry.items[0]!),
|
|
267
|
+
name: 'pattern-chart-root',
|
|
268
|
+
files: [
|
|
269
|
+
{
|
|
270
|
+
...structuredClone(registry.items[0]!.files[0]!),
|
|
271
|
+
path: 'registry/root.tsx',
|
|
272
|
+
target: 'components/astrale/pattern/chart/root.tsx',
|
|
273
|
+
},
|
|
274
|
+
],
|
|
275
|
+
meta: { canonicalAddress: 'pattern/chart/root' },
|
|
276
|
+
}
|
|
277
|
+
const chartItem = {
|
|
278
|
+
...structuredClone(registry.items[0]!),
|
|
279
|
+
files: [
|
|
280
|
+
structuredClone(registry.items[0]!.files[0]!),
|
|
281
|
+
{
|
|
282
|
+
...structuredClone(registry.items[0]!.files[0]!),
|
|
283
|
+
path: 'parts/legend.tsx',
|
|
284
|
+
target: 'components/astrale/pattern/chart/parts/legend.tsx',
|
|
285
|
+
},
|
|
286
|
+
],
|
|
287
|
+
}
|
|
288
|
+
const nestedItem = {
|
|
289
|
+
...structuredClone(registry.items[0]!),
|
|
290
|
+
name: 'pattern-chart-nested-line',
|
|
291
|
+
files: [structuredClone(registry.items[0]!.files[0]!)],
|
|
292
|
+
meta: { canonicalAddress: 'pattern/chart/nested-line' },
|
|
293
|
+
}
|
|
294
|
+
const fallback = mockFetch()
|
|
295
|
+
const fetcher = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
296
|
+
const url = String(input)
|
|
297
|
+
if (url.endsWith('/' + commit + '/registry.json')) {
|
|
298
|
+
return Response.json({
|
|
299
|
+
items: [rootItem],
|
|
300
|
+
include: ['registry/patterns/chart/registry.json'],
|
|
301
|
+
})
|
|
302
|
+
}
|
|
303
|
+
if (url.endsWith('/registry/patterns/chart/registry.json')) {
|
|
304
|
+
return Response.json({ items: [chartItem], include: ['nested/registry.json'] })
|
|
305
|
+
}
|
|
306
|
+
if (url.endsWith('/registry/patterns/chart/nested/registry.json')) {
|
|
307
|
+
return Response.json({ items: [nestedItem] })
|
|
308
|
+
}
|
|
309
|
+
return fallback(input, init)
|
|
310
|
+
}) as typeof fetch
|
|
311
|
+
|
|
312
|
+
const release = await resolveUiRelease('0.3.0-beta.0', fetcher)
|
|
313
|
+
expect(release.registry.items.map((item) => item.files.map((file) => file.path))).toEqual([
|
|
314
|
+
['registry/root.tsx'],
|
|
315
|
+
['registry/patterns/chart/line-basic.tsx', 'registry/patterns/chart/parts/legend.tsx'],
|
|
316
|
+
['registry/patterns/chart/nested/line-basic.tsx'],
|
|
317
|
+
])
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
/** @evidence TEST-CLI-UI-BOUNDED-REMOTE-DOCUMENTS */
|
|
321
|
+
test('bounds and normalizes malformed registry responses', async () => {
|
|
322
|
+
const fallback = mockFetch()
|
|
323
|
+
const oversized = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
324
|
+
const url = String(input)
|
|
325
|
+
if (url.endsWith('/tooling/compatibility.json')) {
|
|
326
|
+
return new Response('x'.repeat(1_048_577), {
|
|
327
|
+
headers: { 'content-type': 'application/json' },
|
|
328
|
+
})
|
|
329
|
+
}
|
|
330
|
+
return fallback(input, init)
|
|
331
|
+
}) as typeof fetch
|
|
332
|
+
await expect(resolveUiRelease('0.3.0-beta.0', oversized)).rejects.toMatchObject({
|
|
333
|
+
code: 'UI_REGISTRY_UNAVAILABLE',
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
const malformed = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
337
|
+
const url = String(input)
|
|
338
|
+
if (url.endsWith('/' + commit + '/registry.json')) {
|
|
339
|
+
return new Response('{', { headers: { 'content-type': 'application/json' } })
|
|
340
|
+
}
|
|
341
|
+
return fallback(input, init)
|
|
342
|
+
}) as typeof fetch
|
|
343
|
+
await expect(resolveUiRelease('0.3.0-beta.0', malformed)).rejects.toMatchObject({
|
|
344
|
+
code: 'UI_REGISTRY_UNAVAILABLE',
|
|
345
|
+
})
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
test('rejects include fan-out before fetching an unbounded registry graph', async () => {
|
|
349
|
+
const fallback = mockFetch()
|
|
350
|
+
const fanOut = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
351
|
+
const url = String(input)
|
|
352
|
+
if (url.endsWith('/' + commit + '/registry.json')) {
|
|
353
|
+
return Response.json({
|
|
354
|
+
include: Array.from(
|
|
355
|
+
{ length: 100 },
|
|
356
|
+
(_, index) => 'registry/patterns/family-' + index + '/registry.json',
|
|
357
|
+
),
|
|
358
|
+
})
|
|
359
|
+
}
|
|
360
|
+
return fallback(input, init)
|
|
361
|
+
}) as typeof fetch
|
|
362
|
+
await expect(resolveUiRelease('0.3.0-beta.0', fanOut)).rejects.toMatchObject({
|
|
363
|
+
code: 'UI_REGISTRY_UNAVAILABLE',
|
|
364
|
+
})
|
|
365
|
+
})
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
describe('UI initialization transaction', () => {
|
|
369
|
+
test('initializes the generated Domain frontend stylesheet instead of an unused fallback', async () => {
|
|
370
|
+
const root = await fixture()
|
|
371
|
+
await mkdir(path.join(root, 'frontend/src'), { recursive: true })
|
|
372
|
+
await writeFile(
|
|
373
|
+
path.join(root, 'frontend/package.json'),
|
|
374
|
+
JSON.stringify({ name: 'astrale-frontend', private: true, type: 'module' }),
|
|
375
|
+
)
|
|
376
|
+
await writeFile(path.join(root, 'frontend/src/styles.css'), '/* Domain frontend */\n')
|
|
377
|
+
await mkdir(path.join(root, 'ui'), { recursive: true })
|
|
378
|
+
await writeFile(path.join(root, 'ui/index.ts'), 'export {}\n')
|
|
379
|
+
await writeFile(path.join(root, 'astrale.config.ts'), 'export default {}\n')
|
|
380
|
+
await writeFile(path.join(root, 'pnpm-workspace.yaml'), "packages:\n - 'frontend'\n")
|
|
381
|
+
const rootCssBefore = await readFile(path.join(root, 'src/index.css'), 'utf8')
|
|
382
|
+
|
|
383
|
+
await initUi(
|
|
384
|
+
{ path: path.join(root, 'frontend'), version: '0.3.0-beta.0', install: false },
|
|
385
|
+
{ fetcher: mockFetch() },
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
const css = await readFile(path.join(root, 'frontend/src/styles.css'), 'utf8')
|
|
389
|
+
const components = JSON.parse(await readFile(path.join(root, 'components.json'), 'utf8'))
|
|
390
|
+
expect(css).toContain("@import '@astrale-os/ui/theme.css';")
|
|
391
|
+
expect(css).toContain('/* Domain frontend */')
|
|
392
|
+
expect(components.tailwind.css).toBe('frontend/src/styles.css')
|
|
393
|
+
expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toBe(rootCssBefore)
|
|
394
|
+
expect(
|
|
395
|
+
JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')).dependencies,
|
|
396
|
+
).toHaveProperty('@astrale-os/ui', '0.3.0-beta.0')
|
|
397
|
+
expect(await Bun.file(path.join(root, 'src/astrale-ui.css')).exists()).toBe(false)
|
|
398
|
+
expect(JSON.parse(await readFile(path.join(root, 'components/package.json'), 'utf8'))).toEqual({
|
|
399
|
+
name: 'fixture-ui-registry',
|
|
400
|
+
private: true,
|
|
401
|
+
type: 'module',
|
|
402
|
+
})
|
|
403
|
+
expect(await readFile(path.join(root, 'pnpm-workspace.yaml'), 'utf8')).toContain('components')
|
|
404
|
+
|
|
405
|
+
await writeFile(path.join(root, 'src/app.css'), '/* later root stylesheet */\n')
|
|
406
|
+
const repeated = await initUi(
|
|
407
|
+
{ path: root, version: '0.3.0-beta.0', install: false },
|
|
408
|
+
{ fetcher: mockFetch() },
|
|
409
|
+
)
|
|
410
|
+
expect(repeated.status).toBe('unchanged')
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
test('adds the private registry workspace to an npm-authored Domain manifest', async () => {
|
|
414
|
+
const root = await fixture()
|
|
415
|
+
const manifestPath = path.join(root, 'package.json')
|
|
416
|
+
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
|
417
|
+
manifest.packageManager = 'npm@11.16.0'
|
|
418
|
+
manifest.workspaces = ['frontend']
|
|
419
|
+
await writeFile(manifestPath, JSON.stringify(manifest))
|
|
420
|
+
await writeFile(path.join(root, 'package-lock.json'), '{}')
|
|
421
|
+
await mkdir(path.join(root, 'frontend/src'), { recursive: true })
|
|
422
|
+
await writeFile(
|
|
423
|
+
path.join(root, 'frontend/package.json'),
|
|
424
|
+
JSON.stringify({ name: 'astrale-frontend', private: true }),
|
|
425
|
+
)
|
|
426
|
+
await writeFile(path.join(root, 'frontend/src/styles.css'), '/* Domain frontend */\n')
|
|
427
|
+
await mkdir(path.join(root, 'ui'), { recursive: true })
|
|
428
|
+
await writeFile(path.join(root, 'ui/index.ts'), 'export {}\n')
|
|
429
|
+
await writeFile(path.join(root, 'astrale.config.ts'), 'export default {}\n')
|
|
430
|
+
|
|
431
|
+
const result = await initUi(
|
|
432
|
+
{ path: root, version: '0.3.0-beta.0', install: false },
|
|
433
|
+
{ fetcher: mockFetch() },
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
expect(result.files).toEqual(expect.arrayContaining(['components/package.json']))
|
|
437
|
+
expect(JSON.parse(await readFile(manifestPath, 'utf8')).workspaces).toEqual([
|
|
438
|
+
'frontend',
|
|
439
|
+
'components',
|
|
440
|
+
])
|
|
441
|
+
expect(JSON.parse(await readFile(path.join(root, 'components/package.json'), 'utf8'))).toEqual({
|
|
442
|
+
name: 'fixture-ui-registry',
|
|
443
|
+
private: true,
|
|
444
|
+
type: 'module',
|
|
445
|
+
})
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
test('rejects a Domain registry workspace whose physical parent escapes the project', async () => {
|
|
449
|
+
const root = await fixture()
|
|
450
|
+
const outside = await fixture()
|
|
451
|
+
await mkdir(path.join(root, 'frontend/src'), { recursive: true })
|
|
452
|
+
await writeFile(
|
|
453
|
+
path.join(root, 'frontend/package.json'),
|
|
454
|
+
JSON.stringify({ name: 'astrale-frontend', private: true }),
|
|
455
|
+
)
|
|
456
|
+
await writeFile(path.join(root, 'frontend/src/styles.css'), '/* Domain frontend */\n')
|
|
457
|
+
await mkdir(path.join(root, 'ui'), { recursive: true })
|
|
458
|
+
await writeFile(path.join(root, 'ui/index.ts'), 'export {}\n')
|
|
459
|
+
await writeFile(path.join(root, 'astrale.config.ts'), 'export default {}\n')
|
|
460
|
+
await symlink(outside, path.join(root, 'components'), 'dir')
|
|
461
|
+
const outsideManifest = await readFile(path.join(outside, 'package.json'), 'utf8')
|
|
462
|
+
const rootManifest = await readFile(path.join(root, 'package.json'), 'utf8')
|
|
463
|
+
|
|
464
|
+
await expect(
|
|
465
|
+
initUi({ path: root, version: '0.3.0-beta.0', install: false }, { fetcher: mockFetch() }),
|
|
466
|
+
).rejects.toMatchObject({ code: 'UI_LOCK_INVALID' })
|
|
467
|
+
expect(await readFile(path.join(outside, 'package.json'), 'utf8')).toBe(outsideManifest)
|
|
468
|
+
expect(await readFile(path.join(root, 'package.json'), 'utf8')).toBe(rootManifest)
|
|
469
|
+
expect(await Bun.file(path.join(root, 'astrale-ui.lock.json')).exists()).toBe(false)
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
test('rejects a symlinked pnpm workspace manifest before any Domain mutation', async () => {
|
|
473
|
+
const root = await fixture()
|
|
474
|
+
const outside = await fixture()
|
|
475
|
+
await mkdir(path.join(root, 'frontend/src'), { recursive: true })
|
|
476
|
+
await writeFile(
|
|
477
|
+
path.join(root, 'frontend/package.json'),
|
|
478
|
+
JSON.stringify({ name: 'astrale-frontend', private: true }),
|
|
479
|
+
)
|
|
480
|
+
await writeFile(path.join(root, 'frontend/src/styles.css'), '/* Domain frontend */\n')
|
|
481
|
+
await mkdir(path.join(root, 'ui'), { recursive: true })
|
|
482
|
+
await writeFile(path.join(root, 'ui/index.ts'), 'export {}\n')
|
|
483
|
+
await writeFile(path.join(root, 'astrale.config.ts'), 'export default {}\n')
|
|
484
|
+
const outsideWorkspace = path.join(outside, 'workspace.yaml')
|
|
485
|
+
await writeFile(outsideWorkspace, "packages:\n - 'outside'\n")
|
|
486
|
+
await symlink(outsideWorkspace, path.join(root, 'pnpm-workspace.yaml'), 'file')
|
|
487
|
+
const outsideBefore = await readFile(outsideWorkspace, 'utf8')
|
|
488
|
+
|
|
489
|
+
await expect(
|
|
490
|
+
initUi({ path: root, version: '0.3.0-beta.0', install: false }, { fetcher: mockFetch() }),
|
|
491
|
+
).rejects.toMatchObject({ code: 'UI_LOCK_INVALID' })
|
|
492
|
+
expect(await readFile(outsideWorkspace, 'utf8')).toBe(outsideBefore)
|
|
493
|
+
expect(await Bun.file(path.join(root, 'components/package.json')).exists()).toBe(false)
|
|
494
|
+
expect(await Bun.file(path.join(root, 'astrale-ui.lock.json')).exists()).toBe(false)
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
test('restores every Domain workspace mutation when dependency installation fails', async () => {
|
|
498
|
+
const root = await fixture()
|
|
499
|
+
await mkdir(path.join(root, 'frontend/src'), { recursive: true })
|
|
500
|
+
await writeFile(
|
|
501
|
+
path.join(root, 'frontend/package.json'),
|
|
502
|
+
JSON.stringify({ name: 'astrale-frontend', private: true }),
|
|
503
|
+
)
|
|
504
|
+
await writeFile(path.join(root, 'frontend/src/styles.css'), '/* Domain frontend */\n')
|
|
505
|
+
await mkdir(path.join(root, 'ui'), { recursive: true })
|
|
506
|
+
await writeFile(path.join(root, 'ui/index.ts'), 'export {}\n')
|
|
507
|
+
await writeFile(path.join(root, 'astrale.config.ts'), 'export default {}\n')
|
|
508
|
+
await writeFile(path.join(root, 'pnpm-workspace.yaml'), "packages:\n - 'frontend'\n")
|
|
509
|
+
const manifestBefore = await readFile(path.join(root, 'package.json'), 'utf8')
|
|
510
|
+
const cssBefore = await readFile(path.join(root, 'frontend/src/styles.css'), 'utf8')
|
|
511
|
+
const workspaceBefore = await readFile(path.join(root, 'pnpm-workspace.yaml'), 'utf8')
|
|
512
|
+
|
|
513
|
+
await expect(
|
|
514
|
+
initUi(
|
|
515
|
+
{ path: root, version: '0.3.0-beta.0' },
|
|
516
|
+
{
|
|
517
|
+
fetcher: mockFetch(),
|
|
518
|
+
runner: async () => ({ code: 1, stdout: '', stderr: 'install failed' }),
|
|
519
|
+
},
|
|
520
|
+
),
|
|
521
|
+
).rejects.toMatchObject({ code: 'UI_DEPENDENCY_INSTALL_FAILED' })
|
|
522
|
+
|
|
523
|
+
expect(await readFile(path.join(root, 'package.json'), 'utf8')).toBe(manifestBefore)
|
|
524
|
+
expect(await readFile(path.join(root, 'frontend/src/styles.css'), 'utf8')).toBe(cssBefore)
|
|
525
|
+
expect(await readFile(path.join(root, 'pnpm-workspace.yaml'), 'utf8')).toBe(workspaceBefore)
|
|
526
|
+
expect(await Bun.file(path.join(root, 'components/package.json')).exists()).toBe(false)
|
|
527
|
+
expect(await Bun.file(path.join(root, 'components.json')).exists()).toBe(false)
|
|
528
|
+
expect(await Bun.file(path.join(root, 'astrale-ui.lock.json')).exists()).toBe(false)
|
|
529
|
+
expect(await Bun.file(path.join(root, 'pnpm-lock.yaml')).exists()).toBe(false)
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
test('rejects a registry workspace that could shadow the public UI package', async () => {
|
|
533
|
+
const root = await fixture()
|
|
534
|
+
await mkdir(path.join(root, 'frontend/src'), { recursive: true })
|
|
535
|
+
await writeFile(
|
|
536
|
+
path.join(root, 'frontend/package.json'),
|
|
537
|
+
JSON.stringify({ name: 'astrale-frontend', private: true }),
|
|
538
|
+
)
|
|
539
|
+
await writeFile(path.join(root, 'frontend/src/styles.css'), '/* Domain frontend */\n')
|
|
540
|
+
await mkdir(path.join(root, 'ui'), { recursive: true })
|
|
541
|
+
await writeFile(path.join(root, 'ui/index.ts'), 'export {}\n')
|
|
542
|
+
await writeFile(path.join(root, 'astrale.config.ts'), 'export default {}\n')
|
|
543
|
+
await writeFile(path.join(root, 'pnpm-workspace.yaml'), "packages:\n - 'frontend'\n")
|
|
544
|
+
await mkdir(path.join(root, 'components'), { recursive: true })
|
|
545
|
+
await writeFile(
|
|
546
|
+
path.join(root, 'components/package.json'),
|
|
547
|
+
JSON.stringify({ name: '@astrale-os/ui', private: true, version: '0.3.0-beta.0' }),
|
|
548
|
+
)
|
|
549
|
+
const workspaceBefore = await readFile(path.join(root, 'pnpm-workspace.yaml'), 'utf8')
|
|
550
|
+
|
|
551
|
+
await expect(
|
|
552
|
+
initUi({ path: root, version: '0.3.0-beta.0', install: false }, { fetcher: mockFetch() }),
|
|
553
|
+
).rejects.toMatchObject({ code: 'UI_PROJECT_UNSUPPORTED' })
|
|
554
|
+
expect(await readFile(path.join(root, 'pnpm-workspace.yaml'), 'utf8')).toBe(workspaceBefore)
|
|
555
|
+
expect(
|
|
556
|
+
JSON.parse(await readFile(path.join(root, 'components/package.json'), 'utf8')).name,
|
|
557
|
+
).toBe('@astrale-os/ui')
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
test('rejects configured and discovered stylesheets whose physical parent escapes', async () => {
|
|
561
|
+
const root = await fixture()
|
|
562
|
+
const outside = await fixture()
|
|
563
|
+
await symlink(outside, path.join(root, 'escaped'), 'dir')
|
|
564
|
+
await writeFile(
|
|
565
|
+
path.join(root, 'components.json'),
|
|
566
|
+
JSON.stringify({ tailwind: { css: 'escaped/styles.css' } }),
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
await expect(
|
|
570
|
+
initUi({ path: root, version: '0.3.0-beta.0', install: false }, { fetcher: mockFetch() }),
|
|
571
|
+
).rejects.toMatchObject({ code: 'UI_PROJECT_UNSUPPORTED' })
|
|
572
|
+
expect(await Bun.file(path.join(outside, 'styles.css')).exists()).toBe(false)
|
|
573
|
+
|
|
574
|
+
const automaticRoot = await fixture()
|
|
575
|
+
await rm(path.join(automaticRoot, 'src/index.css'))
|
|
576
|
+
const outsideCss = await readFile(path.join(outside, 'src/index.css'), 'utf8')
|
|
577
|
+
await symlink(outside, path.join(automaticRoot, 'frontend'), 'dir')
|
|
578
|
+
await expect(
|
|
579
|
+
initUi(
|
|
580
|
+
{ path: automaticRoot, version: '0.3.0-beta.0', install: false },
|
|
581
|
+
{ fetcher: mockFetch() },
|
|
582
|
+
),
|
|
583
|
+
).rejects.toMatchObject({ code: 'UI_PROJECT_UNSUPPORTED' })
|
|
584
|
+
expect(await readFile(path.join(outside, 'src/index.css'), 'utf8')).toBe(outsideCss)
|
|
585
|
+
})
|
|
586
|
+
|
|
587
|
+
test('dry-run reports every mutation and writes nothing', async () => {
|
|
588
|
+
const root = await fixture()
|
|
589
|
+
const before = await readFile(path.join(root, 'package.json'), 'utf8')
|
|
590
|
+
const result = await initUi(
|
|
591
|
+
{ path: root, version: '0.3.0-beta.0', dryRun: true },
|
|
592
|
+
{ fetcher: mockFetch() },
|
|
593
|
+
)
|
|
594
|
+
expect(result.status).toBe('planned')
|
|
595
|
+
expect(result.files).toEqual(
|
|
596
|
+
expect.arrayContaining([
|
|
597
|
+
'package.json',
|
|
598
|
+
'src/index.css',
|
|
599
|
+
'components.json',
|
|
600
|
+
'astrale-ui.lock.json',
|
|
601
|
+
]),
|
|
602
|
+
)
|
|
603
|
+
expect(await readFile(path.join(root, 'package.json'), 'utf8')).toBe(before)
|
|
604
|
+
expect(await Bun.file(path.join(root, 'astrale-ui.lock.json')).exists()).toBe(false)
|
|
605
|
+
})
|
|
606
|
+
|
|
607
|
+
/** @evidence TEST-CLI-UI-LOCK-AFTER-SUCCESS */
|
|
608
|
+
test('restores all files and leaves no lock when dependency installation fails', async () => {
|
|
609
|
+
const root = await fixture()
|
|
610
|
+
const packageBefore = await readFile(path.join(root, 'package.json'), 'utf8')
|
|
611
|
+
const cssBefore = await readFile(path.join(root, 'src/index.css'), 'utf8')
|
|
612
|
+
await expect(
|
|
613
|
+
initUi(
|
|
614
|
+
{ path: root, version: '0.3.0-beta.0' },
|
|
615
|
+
{
|
|
616
|
+
fetcher: mockFetch(),
|
|
617
|
+
runner: async () => ({ code: 1, stdout: '', stderr: 'registry unavailable' }),
|
|
618
|
+
},
|
|
619
|
+
),
|
|
620
|
+
).rejects.toMatchObject({ code: 'UI_DEPENDENCY_INSTALL_FAILED' })
|
|
621
|
+
expect(await readFile(path.join(root, 'package.json'), 'utf8')).toBe(packageBefore)
|
|
622
|
+
expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toBe(cssBefore)
|
|
623
|
+
expect(await Bun.file(path.join(root, 'components.json')).exists()).toBe(false)
|
|
624
|
+
expect(await Bun.file(path.join(root, 'astrale-ui.lock.json')).exists()).toBe(false)
|
|
625
|
+
expect(await Bun.file(path.join(root, 'pnpm-lock.yaml')).exists()).toBe(false)
|
|
626
|
+
})
|
|
627
|
+
|
|
628
|
+
test('writes Base Nova config and advances the lock only after configuration succeeds', async () => {
|
|
629
|
+
const root = await fixture()
|
|
630
|
+
await initUi(
|
|
631
|
+
{ path: root, version: '0.3.0-beta.0', preset: 'compact', install: false },
|
|
632
|
+
{ fetcher: mockFetch() },
|
|
633
|
+
)
|
|
634
|
+
const components = JSON.parse(await readFile(path.join(root, 'components.json'), 'utf8'))
|
|
635
|
+
const written = JSON.parse(await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8'))
|
|
636
|
+
expect(components.style).toBe('base-nova')
|
|
637
|
+
expect(written.tooling).toMatchObject({ base: 'base', style: 'nova', baseUi: '1.7.0' })
|
|
638
|
+
expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toContain(
|
|
639
|
+
'@astrale-os/ui/presets/compact.css',
|
|
640
|
+
)
|
|
641
|
+
})
|
|
642
|
+
|
|
643
|
+
test('repeated exact init performs no release fetch while requested drift rejects', async () => {
|
|
644
|
+
const root = await fixture()
|
|
645
|
+
await writeFile(path.join(root, 'astrale-ui.lock.json'), JSON.stringify(lock()))
|
|
646
|
+
await writeFile(
|
|
647
|
+
path.join(root, 'components.json'),
|
|
648
|
+
JSON.stringify({ style: 'base-nova', tailwind: { css: 'src/index.css' } }),
|
|
649
|
+
)
|
|
650
|
+
await writeFile(
|
|
651
|
+
path.join(root, 'src/index.css'),
|
|
652
|
+
"@import '@astrale-os/ui/theme.css';\n@import '@astrale-os/ui/presets/astrale.css';\n",
|
|
653
|
+
)
|
|
654
|
+
let fetched = false
|
|
655
|
+
const result = await initUi(
|
|
656
|
+
{ path: root, preset: 'astrale' },
|
|
657
|
+
{
|
|
658
|
+
fetcher: (async () => {
|
|
659
|
+
fetched = true
|
|
660
|
+
throw new Error('must not fetch')
|
|
661
|
+
}) as unknown as typeof fetch,
|
|
662
|
+
},
|
|
663
|
+
)
|
|
664
|
+
expect(result.status).toBe('unchanged')
|
|
665
|
+
expect(fetched).toBe(false)
|
|
666
|
+
await expect(initUi({ path: root, preset: 'compact' })).rejects.toMatchObject({
|
|
667
|
+
code: 'UI_ITEM_CONFLICT',
|
|
668
|
+
})
|
|
669
|
+
})
|
|
670
|
+
})
|
|
671
|
+
|
|
672
|
+
describe('UI source operations', () => {
|
|
673
|
+
test('add rejects an empty programmatic request before project discovery or tool execution', async () => {
|
|
674
|
+
await expect(addUi([], {})).rejects.toMatchObject({ code: 'UI_ITEM_NOT_FOUND' })
|
|
675
|
+
})
|
|
676
|
+
|
|
677
|
+
test('rejects hostile lock file records before an operation can escape the project', () => {
|
|
678
|
+
for (const items of [
|
|
679
|
+
[],
|
|
680
|
+
{
|
|
681
|
+
'pattern/chart/line/basic': {
|
|
682
|
+
address: 'pattern/chart/line/basic',
|
|
683
|
+
sourceDigest: 'bad',
|
|
684
|
+
files: {},
|
|
685
|
+
},
|
|
686
|
+
},
|
|
687
|
+
{
|
|
688
|
+
'pattern/chart/line/basic': {
|
|
689
|
+
address: 'pattern/chart/line/basic',
|
|
690
|
+
sourceDigest: 'b'.repeat(64),
|
|
691
|
+
files: { '../../outside': 'c'.repeat(64) },
|
|
692
|
+
},
|
|
693
|
+
},
|
|
694
|
+
]) {
|
|
695
|
+
expect(() => parseUiLock({ ...lock(), items })).toThrow(UiError)
|
|
696
|
+
}
|
|
697
|
+
})
|
|
698
|
+
|
|
699
|
+
test('add dry-run invokes the exact shadcn version and does not advance the lock', async () => {
|
|
700
|
+
const root = await lockedFixture()
|
|
701
|
+
const before = await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8')
|
|
702
|
+
const calls: Array<{ file: string; args: string[] }> = []
|
|
703
|
+
const result = await addUi(
|
|
704
|
+
['pattern/chart/line/basic'],
|
|
705
|
+
{ project: root, dryRun: true },
|
|
706
|
+
{
|
|
707
|
+
fetcher: mockFetch(),
|
|
708
|
+
runner: async (file, args) => {
|
|
709
|
+
calls.push({ file, args })
|
|
710
|
+
return { code: 0, stdout: 'planned', stderr: '' }
|
|
711
|
+
},
|
|
712
|
+
},
|
|
713
|
+
)
|
|
714
|
+
expect(result.status).toBe('planned')
|
|
715
|
+
expect(result.sources).toEqual([
|
|
716
|
+
{
|
|
717
|
+
address: 'pattern/chart/line/basic',
|
|
718
|
+
dependencies: [],
|
|
719
|
+
files: ['components/astrale/pattern/chart/line-basic.tsx'],
|
|
720
|
+
},
|
|
721
|
+
])
|
|
722
|
+
expect(calls[0]).toMatchObject({ file: 'pnpm' })
|
|
723
|
+
expect(calls[0]?.args).toEqual(expect.arrayContaining(['dlx', 'shadcn@4.18.0', '--dry-run']))
|
|
724
|
+
expect(await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8')).toBe(before)
|
|
725
|
+
})
|
|
726
|
+
|
|
727
|
+
test('successful add records installed file digests and doctor detects later edits', async () => {
|
|
728
|
+
const root = await lockedFixture()
|
|
729
|
+
await writeFile(
|
|
730
|
+
path.join(root, 'components.json'),
|
|
731
|
+
JSON.stringify({ style: 'base-nova', tailwind: { css: 'src/index.css' } }),
|
|
732
|
+
)
|
|
733
|
+
await writeFile(
|
|
734
|
+
path.join(root, 'package.json'),
|
|
735
|
+
JSON.stringify({
|
|
736
|
+
name: 'fixture',
|
|
737
|
+
dependencies: {
|
|
738
|
+
react: '19.2.8',
|
|
739
|
+
'react-dom': '19.2.8',
|
|
740
|
+
tailwindcss: '4.3.3',
|
|
741
|
+
'@astrale-os/ui': '0.3.0-beta.0',
|
|
742
|
+
},
|
|
743
|
+
}),
|
|
744
|
+
)
|
|
745
|
+
await writeFile(
|
|
746
|
+
path.join(root, 'src/index.css'),
|
|
747
|
+
"@import '@astrale-os/ui/theme.css';\n@import '@astrale-os/ui/presets/astrale.css';\n",
|
|
748
|
+
)
|
|
749
|
+
const installed = path.join(root, 'components/astrale/pattern/chart/line-basic.tsx')
|
|
750
|
+
await addUi(
|
|
751
|
+
['pattern/chart/line/basic'],
|
|
752
|
+
{ project: root, yes: true },
|
|
753
|
+
{
|
|
754
|
+
fetcher: mockFetch(),
|
|
755
|
+
runner: async () => {
|
|
756
|
+
await mkdir(path.dirname(installed), { recursive: true })
|
|
757
|
+
await writeFile(installed, 'export const Chart = true\n')
|
|
758
|
+
return { code: 0, stdout: '', stderr: '' }
|
|
759
|
+
},
|
|
760
|
+
},
|
|
761
|
+
)
|
|
762
|
+
const written = JSON.parse(await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8'))
|
|
763
|
+
expect(written.items['pattern/chart/line/basic'].files).toEqual({
|
|
764
|
+
'components/astrale/pattern/chart/line-basic.tsx': digest('export const Chart = true\n'),
|
|
765
|
+
})
|
|
766
|
+
expect(written.items['pattern/chart/line/basic'].sourceDigest).toBe(
|
|
767
|
+
digest(JSON.stringify(builtItem(registry.items[0]!))),
|
|
768
|
+
)
|
|
769
|
+
expect((await doctorUi(root)).healthy).toBe(true)
|
|
770
|
+
await writeFile(installed, 'consumer edit\n')
|
|
771
|
+
expect((await doctorUi(root)).healthy).toBe(false)
|
|
772
|
+
await expect(
|
|
773
|
+
addUi(
|
|
774
|
+
['pattern/chart/line/basic'],
|
|
775
|
+
{ project: root },
|
|
776
|
+
{ fetcher: mockFetch(), runner: async () => ({ code: 0, stdout: '', stderr: '' }) },
|
|
777
|
+
),
|
|
778
|
+
).rejects.toBeInstanceOf(UiError)
|
|
779
|
+
})
|
|
780
|
+
|
|
781
|
+
test('restores the exact locked UI dependency after shadcn applies its compatible range', async () => {
|
|
782
|
+
const root = await lockedFixture()
|
|
783
|
+
const installed = path.join(root, 'components/astrale/pattern/chart/line-basic.tsx')
|
|
784
|
+
const calls: Array<{ file: string; args: string[] }> = []
|
|
785
|
+
|
|
786
|
+
await addUi(
|
|
787
|
+
['pattern/chart/line/basic'],
|
|
788
|
+
{ project: root, yes: true },
|
|
789
|
+
{
|
|
790
|
+
fetcher: mockFetch(),
|
|
791
|
+
runner: async (file, args) => {
|
|
792
|
+
calls.push({ file, args })
|
|
793
|
+
if (args[0] === 'dlx') {
|
|
794
|
+
await mkdir(path.dirname(installed), { recursive: true })
|
|
795
|
+
await writeFile(installed, 'export const Chart = true\n')
|
|
796
|
+
const manifestPath = path.join(root, 'package.json')
|
|
797
|
+
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
|
798
|
+
manifest.dependencies['@astrale-os/ui'] = '^0.3.0-beta.0'
|
|
799
|
+
await writeFile(manifestPath, JSON.stringify(manifest))
|
|
800
|
+
}
|
|
801
|
+
return { code: 0, stdout: '', stderr: '' }
|
|
802
|
+
},
|
|
803
|
+
},
|
|
804
|
+
)
|
|
805
|
+
|
|
806
|
+
expect(calls).toEqual([
|
|
807
|
+
expect.objectContaining({ file: 'pnpm', args: expect.arrayContaining(['dlx']) }),
|
|
808
|
+
{ file: 'pnpm', args: ['install'] },
|
|
809
|
+
])
|
|
810
|
+
expect(
|
|
811
|
+
JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')).dependencies[
|
|
812
|
+
'@astrale-os/ui'
|
|
813
|
+
],
|
|
814
|
+
).toBe('0.3.0-beta.0')
|
|
815
|
+
})
|
|
816
|
+
|
|
817
|
+
test('rolls back item and package state when restoring the locked dependency fails', async () => {
|
|
818
|
+
const root = await lockedFixture()
|
|
819
|
+
const manifestPath = path.join(root, 'package.json')
|
|
820
|
+
const lockPath = path.join(root, 'astrale-ui.lock.json')
|
|
821
|
+
const installed = path.join(root, 'components/astrale/pattern/chart/line-basic.tsx')
|
|
822
|
+
const manifestBefore = await readFile(manifestPath, 'utf8')
|
|
823
|
+
const lockBefore = await readFile(lockPath, 'utf8')
|
|
824
|
+
|
|
825
|
+
await expect(
|
|
826
|
+
addUi(
|
|
827
|
+
['pattern/chart/line/basic'],
|
|
828
|
+
{ project: root, yes: true },
|
|
829
|
+
{
|
|
830
|
+
fetcher: mockFetch(),
|
|
831
|
+
runner: async (_file, args) => {
|
|
832
|
+
if (args[0] === 'dlx') {
|
|
833
|
+
await mkdir(path.dirname(installed), { recursive: true })
|
|
834
|
+
await writeFile(installed, 'export const Chart = true\n')
|
|
835
|
+
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
|
836
|
+
manifest.dependencies['@astrale-os/ui'] = '^0.3.0-beta.0'
|
|
837
|
+
await writeFile(manifestPath, JSON.stringify(manifest))
|
|
838
|
+
return { code: 0, stdout: '', stderr: '' }
|
|
839
|
+
}
|
|
840
|
+
await writeFile(path.join(root, 'pnpm-lock.yaml'), 'partial lock\n')
|
|
841
|
+
return { code: 1, stdout: '', stderr: 'registry timeout' }
|
|
842
|
+
},
|
|
843
|
+
},
|
|
844
|
+
),
|
|
845
|
+
).rejects.toMatchObject({ code: 'UI_DEPENDENCY_INSTALL_FAILED' })
|
|
846
|
+
|
|
847
|
+
expect(await readFile(manifestPath, 'utf8')).toBe(manifestBefore)
|
|
848
|
+
expect(await readFile(lockPath, 'utf8')).toBe(lockBefore)
|
|
849
|
+
expect(await Bun.file(installed).exists()).toBe(false)
|
|
850
|
+
expect(await Bun.file(path.join(root, 'pnpm-lock.yaml')).exists()).toBe(false)
|
|
851
|
+
})
|
|
852
|
+
|
|
853
|
+
test('preflights symlink targets without invoking shadcn', async () => {
|
|
854
|
+
const root = await lockedFixture()
|
|
855
|
+
const outside = await mkdtemp(path.join(tmpdir(), 'astrale-ui-outside-'))
|
|
856
|
+
temporary.push(outside)
|
|
857
|
+
await symlink(outside, path.join(root, 'components'))
|
|
858
|
+
let invoked = false
|
|
859
|
+
await expect(
|
|
860
|
+
addUi(
|
|
861
|
+
['pattern/chart/line/basic'],
|
|
862
|
+
{ project: root },
|
|
863
|
+
{
|
|
864
|
+
fetcher: mockFetch(),
|
|
865
|
+
runner: async () => {
|
|
866
|
+
invoked = true
|
|
867
|
+
return { code: 0, stdout: '', stderr: '' }
|
|
868
|
+
},
|
|
869
|
+
},
|
|
870
|
+
),
|
|
871
|
+
).rejects.toMatchObject({ code: 'UI_LOCK_INVALID' })
|
|
872
|
+
expect(invoked).toBe(false)
|
|
873
|
+
})
|
|
874
|
+
|
|
875
|
+
test('restores declared files and package state after a partial shadcn failure', async () => {
|
|
876
|
+
const root = await lockedFixture()
|
|
877
|
+
const first = path.join(root, 'components/astrale/pattern/chart/line-basic.tsx')
|
|
878
|
+
const second = path.join(root, 'components/astrale/pattern/chart/summary.tsx')
|
|
879
|
+
await mkdir(path.dirname(first), { recursive: true })
|
|
880
|
+
await writeFile(first, 'consumer original\n')
|
|
881
|
+
const twoFileRegistry: UiRegistry = {
|
|
882
|
+
...registry,
|
|
883
|
+
items: [
|
|
884
|
+
{
|
|
885
|
+
...registry.items[0]!,
|
|
886
|
+
files: [
|
|
887
|
+
registry.items[0]!.files[0]!,
|
|
888
|
+
{
|
|
889
|
+
path: 'summary.tsx',
|
|
890
|
+
type: 'registry:component',
|
|
891
|
+
target: 'components/astrale/pattern/chart/summary.tsx',
|
|
892
|
+
},
|
|
893
|
+
],
|
|
894
|
+
},
|
|
895
|
+
],
|
|
896
|
+
}
|
|
897
|
+
const lockBefore = await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8')
|
|
898
|
+
await expect(
|
|
899
|
+
addUi(
|
|
900
|
+
['pattern/chart/line/basic'],
|
|
901
|
+
{ project: root },
|
|
902
|
+
{
|
|
903
|
+
fetcher: mockFetch([], twoFileRegistry),
|
|
904
|
+
runner: async () => {
|
|
905
|
+
await writeFile(first, 'partial overwrite\n')
|
|
906
|
+
await writeFile(second, 'partial create\n')
|
|
907
|
+
return { code: 1, stdout: '', stderr: 'interrupted' }
|
|
908
|
+
},
|
|
909
|
+
},
|
|
910
|
+
),
|
|
911
|
+
).rejects.toMatchObject({ code: 'UI_TOOL_FAILED' })
|
|
912
|
+
expect(await readFile(first, 'utf8')).toBe('consumer original\n')
|
|
913
|
+
expect(await Bun.file(second).exists()).toBe(false)
|
|
914
|
+
expect(await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8')).toBe(lockBefore)
|
|
915
|
+
})
|
|
916
|
+
|
|
917
|
+
test('overwrite requires explicit yes confirmation before invoking shadcn', async () => {
|
|
918
|
+
const root = await lockedFixture()
|
|
919
|
+
let invoked = false
|
|
920
|
+
await expect(
|
|
921
|
+
addUi(
|
|
922
|
+
['pattern/chart/line/basic'],
|
|
923
|
+
{ project: root, overwrite: true },
|
|
924
|
+
{
|
|
925
|
+
fetcher: mockFetch(),
|
|
926
|
+
runner: async () => {
|
|
927
|
+
invoked = true
|
|
928
|
+
return { code: 0, stdout: '', stderr: '' }
|
|
929
|
+
},
|
|
930
|
+
},
|
|
931
|
+
),
|
|
932
|
+
).rejects.toMatchObject({ code: 'UI_LOCAL_CHANGES' })
|
|
933
|
+
expect(invoked).toBe(false)
|
|
934
|
+
})
|
|
935
|
+
|
|
936
|
+
/** @evidence TEST-CLI-UI-EXACT-ITEM-SOURCE */
|
|
937
|
+
test('rejects a built item that differs from the admitted release index before invoking shadcn', async () => {
|
|
938
|
+
const root = await lockedFixture()
|
|
939
|
+
const fallback = mockFetch()
|
|
940
|
+
let invoked = false
|
|
941
|
+
const malformed = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
942
|
+
const url = String(input)
|
|
943
|
+
if (url.endsWith('/registry/public/r/pattern-chart-line-basic.json')) {
|
|
944
|
+
return Response.json(registry.items[0])
|
|
945
|
+
}
|
|
946
|
+
return fallback(input, init)
|
|
947
|
+
}) as typeof fetch
|
|
948
|
+
await expect(
|
|
949
|
+
addUi(
|
|
950
|
+
['pattern/chart/line/basic'],
|
|
951
|
+
{ project: root },
|
|
952
|
+
{
|
|
953
|
+
fetcher: malformed,
|
|
954
|
+
runner: async () => {
|
|
955
|
+
invoked = true
|
|
956
|
+
return { code: 0, stdout: '', stderr: '' }
|
|
957
|
+
},
|
|
958
|
+
},
|
|
959
|
+
),
|
|
960
|
+
).rejects.toMatchObject({ code: 'UI_REGISTRY_UNAVAILABLE' })
|
|
961
|
+
expect(invoked).toBe(false)
|
|
962
|
+
})
|
|
963
|
+
|
|
964
|
+
test('preset dry-run is read-only and apply changes CSS plus lock without source rewrites', async () => {
|
|
965
|
+
const root = await fixture()
|
|
966
|
+
await writeFile(path.join(root, 'astrale-ui.lock.json'), JSON.stringify(lock()))
|
|
967
|
+
await writeFile(
|
|
968
|
+
path.join(root, 'src/index.css'),
|
|
969
|
+
"@import '@astrale-os/ui/theme.css';\n@import '@astrale-os/ui/presets/astrale.css';\n",
|
|
970
|
+
)
|
|
971
|
+
const before = await readFile(path.join(root, 'src/index.css'), 'utf8')
|
|
972
|
+
await applyPreset('expressive', { project: root, dryRun: true })
|
|
973
|
+
expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toBe(before)
|
|
974
|
+
await applyPreset('expressive', { project: root })
|
|
975
|
+
expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toContain(
|
|
976
|
+
'@astrale-os/ui/presets/expressive.css',
|
|
977
|
+
)
|
|
978
|
+
const written = JSON.parse(await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8'))
|
|
979
|
+
expect(written.preset).toBe('expressive')
|
|
980
|
+
})
|
|
981
|
+
})
|