@astrale-os/adapter-cloudflare 0.1.8 → 0.1.10
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/dist/assets-pack.d.ts +1 -1
- package/dist/assets-pack.js +1 -1
- package/dist/build.d.ts +15 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +15 -0
- package/dist/build.js.map +1 -0
- package/dist/client.d.ts +9 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +10 -1
- package/dist/client.js.map +1 -1
- package/dist/cloudflare.d.ts +15 -3
- package/dist/cloudflare.d.ts.map +1 -1
- package/dist/cloudflare.js +52 -18
- package/dist/cloudflare.js.map +1 -1
- package/dist/codegen/worker.d.ts +26 -6
- package/dist/codegen/worker.d.ts.map +1 -1
- package/dist/codegen/worker.js +67 -54
- package/dist/codegen/worker.js.map +1 -1
- package/dist/codegen/wrangler.d.ts +11 -2
- package/dist/codegen/wrangler.d.ts.map +1 -1
- package/dist/codegen/wrangler.js +11 -5
- package/dist/codegen/wrangler.js.map +1 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/params.d.ts +30 -30
- package/dist/params.d.ts.map +1 -1
- package/dist/parse-output.d.ts +1 -1
- package/dist/parse-output.js +1 -1
- package/package.json +6 -2
- package/src/assets-pack.ts +1 -1
- package/src/build.ts +15 -0
- package/src/client.ts +11 -1
- package/src/cloudflare.ts +53 -18
- package/src/codegen/worker.ts +76 -59
- package/src/codegen/wrangler.ts +15 -5
- package/src/index.ts +6 -3
- package/src/params.ts +32 -31
- package/src/parse-output.ts +1 -1
- package/template/.agents/skills/astrale-cli/SKILL.md +26 -12
- package/template/.agents/skills/astrale-domain/SKILL.md +46 -29
- package/template/.env.example +6 -0
- package/template/README.md +25 -10
- package/template/astrale.config.ts +27 -33
- package/template/client/README.md +80 -63
- package/template/client/__tests__/app.test.tsx +188 -99
- package/template/client/__tests__/harness.ts +67 -12
- package/template/client/__tests__/kernel.test.ts +65 -50
- package/template/client/__tests__/seam.test.tsx +111 -0
- package/template/client/index.html +1 -1
- package/template/client/package.json +1 -0
- package/template/client/src/app.tsx +40 -83
- package/template/client/src/main.tsx +2 -2
- package/template/client/src/monitor/components/MonitorCard.tsx +50 -0
- package/template/client/src/monitor/components/index.ts +1 -0
- package/template/client/src/monitor/hooks/index.ts +3 -0
- package/template/client/src/monitor/hooks/useCheck.mutation.ts +16 -0
- package/template/client/src/monitor/hooks/useMonitor.query.ts +64 -0
- package/template/client/src/monitor/index.ts +6 -0
- package/template/client/src/monitor/monitor.api.ts +11 -0
- package/template/client/src/monitor/monitor.mappers.ts +38 -0
- package/template/client/src/monitor/monitor.types.ts +23 -0
- package/template/client/src/monitor/ui/MonitorDetails.UI.tsx +38 -0
- package/template/client/src/monitor/ui/StatusBadge.UI.tsx +14 -0
- package/template/client/src/monitor/ui/index.ts +8 -0
- package/template/client/src/shell/client.ts +67 -0
- package/template/client/src/shell/index.ts +20 -0
- package/template/client/src/shell/invoke.ts +35 -0
- package/template/client/src/shell/transformers.ts +72 -0
- package/template/client/src/shell/use-async.ts +56 -0
- package/template/client/src/shell/use-capability.ts +61 -0
- package/template/client/src/shell/use-node.ts +61 -0
- package/template/client/src/shell/use-shell.ts +91 -0
- package/template/client/src/shell/view-router.tsx +98 -0
- package/template/client/src/styles.css +177 -4
- package/template/client/src/ui/format.ts +24 -0
- package/template/client/src/ui/index.ts +9 -0
- package/template/client/src/ui/surface.tsx +56 -0
- package/template/client/src/ui/value.tsx +32 -0
- package/template/client/src/views/monitor.tsx +30 -0
- package/template/client/tsconfig.json +3 -2
- package/template/client/vite.config.ts +14 -15
- package/template/client/vitest.config.ts +12 -5
- package/template/core/monitor/health.ts +19 -0
- package/template/core/monitor/index.ts +9 -0
- package/template/core/monitor/keys.ts +29 -0
- package/template/core/monitor/node.ts +51 -0
- package/template/deps.ts +25 -0
- package/template/domain.ts +33 -0
- package/template/env.ts +4 -0
- package/template/integrations/prober/http.ts +43 -0
- package/template/integrations/prober/mock.ts +22 -0
- package/template/integrations/prober/port.ts +28 -0
- package/template/integrations/prober/registry.ts +66 -0
- package/template/package.json +2 -3
- package/template/pnpm-lock.yaml +2766 -0
- package/template/runtime/index.ts +79 -0
- package/template/runtime/monitor/check.ts +29 -0
- package/template/runtime/monitor/dependsOn.ts +16 -0
- package/template/runtime/monitor/index.ts +12 -0
- package/template/runtime/monitor/seed.ts +74 -0
- package/template/runtime/monitor/shared.ts +17 -0
- package/template/runtime/monitor/watch.ts +37 -0
- package/template/schema/index.ts +13 -4
- package/template/schema/monitor.ts +80 -0
- package/template/tsconfig.json +13 -2
- package/template/views/index.ts +9 -2
- package/template/views/monitor.ts +22 -0
- package/dist/astrale.d.ts +0 -27
- package/dist/astrale.d.ts.map +0 -1
- package/dist/astrale.js +0 -222
- package/dist/astrale.js.map +0 -1
- package/src/astrale.ts +0 -259
- package/template/client/src/lib/kernel.ts +0 -135
- package/template/client/src/lib/shell.ts +0 -197
- package/template/client/src/lib/use-node.ts +0 -66
- package/template/client/src/lib/use-shell.ts +0 -85
- package/template/methods/index.ts +0 -66
- package/template/methods/note.ts +0 -131
- package/template/schema/compiled.ts +0 -14
- package/template/schema/note.ts +0 -64
- package/template/views/note.ts +0 -21
|
@@ -1,21 +1,32 @@
|
|
|
1
|
-
import { act, cleanup, render, screen } from '@testing-library/react'
|
|
2
|
-
import { afterEach, describe, expect, it } from 'vitest'
|
|
1
|
+
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
3
3
|
|
|
4
|
-
import { App } from '
|
|
5
|
-
import {
|
|
4
|
+
import { App } from '@/app'
|
|
5
|
+
import { installFakeKernel, installFakeParent, monitorNode, ok } from './harness'
|
|
6
6
|
|
|
7
7
|
const KERNEL_URL = 'https://k.example.test/api'
|
|
8
|
-
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The router picks a view from `window.location.pathname`, so every test mounts
|
|
11
|
+
* at a known path. `replaceState` rewrites the happy-dom location without a
|
|
12
|
+
* reload. Default to the Monitor detail path; the fallback test overrides.
|
|
13
|
+
*/
|
|
14
|
+
function mountAt(path: string) {
|
|
15
|
+
window.history.replaceState({}, '', path)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
mountAt('/ui/monitor')
|
|
20
|
+
})
|
|
9
21
|
|
|
10
22
|
afterEach(() => {
|
|
11
23
|
cleanup()
|
|
12
24
|
})
|
|
13
25
|
|
|
14
26
|
/**
|
|
15
|
-
* Let the handshake + any kernel
|
|
27
|
+
* Let the handshake + any kernel fetches settle, flushing the resulting React
|
|
16
28
|
* state updates inside `act`. The handshake completes over a real MessagePort
|
|
17
|
-
* (async hops), so a microtask isn't enough — a short timer pump is.
|
|
18
|
-
* it in `act` keeps the updates warning-free and committed before we assert.
|
|
29
|
+
* (async hops), so a microtask isn't enough — a short timer pump is.
|
|
19
30
|
*/
|
|
20
31
|
async function flush(ms = 20) {
|
|
21
32
|
await act(async () => {
|
|
@@ -23,35 +34,36 @@ async function flush(ms = 20) {
|
|
|
23
34
|
})
|
|
24
35
|
}
|
|
25
36
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
expect(req.headers['accept']).toBe(KERNEL_TYPE)
|
|
36
|
-
// Envelope body shape.
|
|
37
|
-
expect(req.body.method).toBe(`@${nodeId}::get`)
|
|
38
|
-
expect(req.body.params).toEqual({})
|
|
39
|
-
expect(req.body.id).toBeTruthy()
|
|
37
|
+
function handshake(targetNodeId?: string) {
|
|
38
|
+
return installFakeParent({
|
|
39
|
+
windowId: 'win-1',
|
|
40
|
+
kernelUrl: KERNEL_URL,
|
|
41
|
+
functionId: 'ui-monitor',
|
|
42
|
+
delegationToken: 'tok-A',
|
|
43
|
+
tokenExpiresAt: Date.now() + 3_600_000,
|
|
44
|
+
targetNodeId,
|
|
45
|
+
})
|
|
40
46
|
}
|
|
41
47
|
|
|
42
|
-
describe('
|
|
43
|
-
it('
|
|
44
|
-
const parent =
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
describe('monitor view (/ui/monitor)', () => {
|
|
49
|
+
it('loads the target node and renders name, url, status and latency', async () => {
|
|
50
|
+
const parent = handshake('mon-1')
|
|
51
|
+
const kernel = installFakeKernel((body) => {
|
|
52
|
+
if (body.method === '@mon-1::get') {
|
|
53
|
+
return ok(
|
|
54
|
+
monitorNode({
|
|
55
|
+
id: 'mon-1',
|
|
56
|
+
name: 'API health',
|
|
57
|
+
url: 'https://api.example.test/health',
|
|
58
|
+
status: 'up',
|
|
59
|
+
statusCode: '200',
|
|
60
|
+
latencyMs: '42',
|
|
61
|
+
lastCheckedAt: '2026-06-14T10:00:00Z',
|
|
62
|
+
}),
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
return { error: { code: 3002, message: `unexpected: ${body.method}` } }
|
|
51
66
|
})
|
|
52
|
-
const kernel = installFakeKernel(() =>
|
|
53
|
-
ok(noteNode({ id: 'note-1', name: 'My first note', body: 'Hello from the kernel.' })),
|
|
54
|
-
)
|
|
55
67
|
|
|
56
68
|
try {
|
|
57
69
|
render(<App />)
|
|
@@ -60,113 +72,176 @@ describe('ui-note view — handshake + real node render', () => {
|
|
|
60
72
|
// Parent observed the child's handshakeAck.
|
|
61
73
|
await expect(parent.ack).resolves.toBeUndefined()
|
|
62
74
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
expect(screen.getByText('
|
|
75
|
+
expect(screen.getByText('API health')).toBeTruthy()
|
|
76
|
+
// ExternalLink strips the scheme.
|
|
77
|
+
expect(screen.getByText('api.example.test/health')).toBeTruthy()
|
|
78
|
+
const badge = screen.getByText('UP')
|
|
79
|
+
expect(badge.className).toContain('status-up')
|
|
80
|
+
expect(screen.getByText('42ms')).toBeTruthy()
|
|
81
|
+
expect(screen.getByText('200')).toBeTruthy()
|
|
66
82
|
|
|
67
|
-
// Exactly one kernel call,
|
|
83
|
+
// Exactly one kernel call, for the node load.
|
|
68
84
|
expect(kernel.calls).toHaveLength(1)
|
|
69
|
-
|
|
85
|
+
expect(kernel.calls[0]!.body.method).toBe('@mon-1::get')
|
|
70
86
|
} finally {
|
|
71
87
|
kernel.restore()
|
|
72
88
|
parent.restore()
|
|
73
89
|
}
|
|
74
90
|
})
|
|
75
91
|
|
|
76
|
-
it('renders
|
|
77
|
-
const parent =
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
delegationToken: 'tok-A',
|
|
82
|
-
tokenExpiresAt: Date.now() + 3_600_000,
|
|
83
|
-
targetNodeId: 'missing-1',
|
|
84
|
-
})
|
|
85
|
-
const kernel = installFakeKernel(() => ({
|
|
86
|
-
error: { code: 3002, message: 'Path not found' },
|
|
87
|
-
}))
|
|
92
|
+
it('renders a DOWN badge when the monitor status is down', async () => {
|
|
93
|
+
const parent = handshake('mon-d')
|
|
94
|
+
const kernel = installFakeKernel(() =>
|
|
95
|
+
ok(monitorNode({ id: 'mon-d', name: 'Down site', url: 'https://x.test', status: 'down' })),
|
|
96
|
+
)
|
|
88
97
|
|
|
89
98
|
try {
|
|
90
99
|
render(<App />)
|
|
91
100
|
await flush()
|
|
92
101
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
expect(
|
|
96
|
-
expect(kernel.calls).toHaveLength(1)
|
|
102
|
+
const badge = screen.getByText('DOWN')
|
|
103
|
+
expect(badge).toBeTruthy()
|
|
104
|
+
expect(badge.className).toContain('status-down')
|
|
97
105
|
} finally {
|
|
98
106
|
kernel.restore()
|
|
99
107
|
parent.restore()
|
|
100
108
|
}
|
|
101
109
|
})
|
|
102
110
|
|
|
103
|
-
it('
|
|
104
|
-
const parent =
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
+
it('clicks "Check now", issues @<id>::check, and reflects the refreshed status', async () => {
|
|
112
|
+
const parent = handshake('mon-1')
|
|
113
|
+
// First ::get is down; ::check mutates server-side; the post-check ::get is up.
|
|
114
|
+
let checked = false
|
|
115
|
+
const kernel = installFakeKernel((body) => {
|
|
116
|
+
if (body.method === '@mon-1::check') {
|
|
117
|
+
checked = true
|
|
118
|
+
return ok(null)
|
|
119
|
+
}
|
|
120
|
+
// @mon-1::get — reflect the pre/post-check status.
|
|
121
|
+
return ok(
|
|
122
|
+
monitorNode({
|
|
123
|
+
id: 'mon-1',
|
|
124
|
+
name: 'API health',
|
|
125
|
+
url: 'https://api.example.test/health',
|
|
126
|
+
status: checked ? 'up' : 'down',
|
|
127
|
+
statusCode: checked ? '200' : '503',
|
|
128
|
+
}),
|
|
129
|
+
)
|
|
111
130
|
})
|
|
112
|
-
const kernel = installFakeKernel((body) =>
|
|
113
|
-
// Respond per-target so the swap is observable.
|
|
114
|
-
body.method === '@note-2::get'
|
|
115
|
-
? ok(noteNode({ id: 'note-2', name: 'Second note', body: 'Swapped in.' }))
|
|
116
|
-
: ok(noteNode({ id: 'note-1', name: 'First note', body: 'Original.' })),
|
|
117
|
-
)
|
|
118
131
|
|
|
119
132
|
try {
|
|
120
133
|
render(<App />)
|
|
121
134
|
await flush()
|
|
122
|
-
|
|
135
|
+
|
|
136
|
+
// Initial load: DOWN.
|
|
137
|
+
expect(screen.getByText('DOWN')).toBeTruthy()
|
|
123
138
|
expect(kernel.calls).toHaveLength(1)
|
|
139
|
+
expect(kernel.calls[0]!.body.method).toBe('@mon-1::get')
|
|
124
140
|
|
|
125
|
-
//
|
|
126
|
-
|
|
141
|
+
// Click "Check now".
|
|
142
|
+
await act(async () => {
|
|
143
|
+
fireEvent.click(screen.getByText('Check now'))
|
|
144
|
+
})
|
|
127
145
|
await flush()
|
|
128
146
|
|
|
129
|
-
|
|
130
|
-
expect(
|
|
147
|
+
// A ::check call was issued, then the node was re-fetched.
|
|
148
|
+
expect(kernel.calls.map((c) => c.body.method)).toEqual([
|
|
149
|
+
'@mon-1::get',
|
|
150
|
+
'@mon-1::check',
|
|
151
|
+
'@mon-1::get',
|
|
152
|
+
])
|
|
131
153
|
|
|
132
|
-
//
|
|
133
|
-
expect(
|
|
134
|
-
|
|
154
|
+
// The refreshed status renders.
|
|
155
|
+
expect(screen.getByText('UP')).toBeTruthy()
|
|
156
|
+
expect(screen.getByText('200')).toBeTruthy()
|
|
135
157
|
} finally {
|
|
136
158
|
kernel.restore()
|
|
137
159
|
parent.restore()
|
|
138
160
|
}
|
|
139
161
|
})
|
|
140
162
|
|
|
141
|
-
it('
|
|
142
|
-
const parent =
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
targetNodeId: 'note-1',
|
|
163
|
+
it('surfaces a check failure without losing the loaded record', async () => {
|
|
164
|
+
const parent = handshake('mon-1')
|
|
165
|
+
const kernel = installFakeKernel((body) => {
|
|
166
|
+
if (body.method === '@mon-1::check') {
|
|
167
|
+
return { error: { code: 2004, message: 'Permission denied' } }
|
|
168
|
+
}
|
|
169
|
+
return ok(monitorNode({ id: 'mon-1', name: 'API health', url: 'https://x.test', status: 'up' }))
|
|
149
170
|
})
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
render(<App />)
|
|
174
|
+
await flush()
|
|
175
|
+
|
|
176
|
+
await act(async () => {
|
|
177
|
+
fireEvent.click(screen.getByText('Check now'))
|
|
178
|
+
})
|
|
179
|
+
await flush()
|
|
180
|
+
|
|
181
|
+
// The proper client maps the error code to a typed error, so the operator
|
|
182
|
+
// sees the clean server message (no `<code>:` prefix).
|
|
183
|
+
expect(screen.getByText(/Check failed:/)).toBeTruthy()
|
|
184
|
+
expect(screen.getByText(/Permission denied/)).toBeTruthy()
|
|
185
|
+
// The record is still shown.
|
|
186
|
+
expect(screen.getByText('API health')).toBeTruthy()
|
|
187
|
+
} finally {
|
|
188
|
+
kernel.restore()
|
|
189
|
+
parent.restore()
|
|
190
|
+
}
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('renders the kernel error on the node-load error path', async () => {
|
|
194
|
+
const parent = handshake('missing-1')
|
|
195
|
+
const kernel = installFakeKernel(() => ({ error: { code: 3002, message: 'Path not found' } }))
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
render(<App />)
|
|
199
|
+
await flush()
|
|
200
|
+
|
|
201
|
+
expect(screen.getByText(/Failed to load the Monitor/)).toBeTruthy()
|
|
202
|
+
// The proper client maps NOT_FOUND (3002) to a typed error → clean message.
|
|
203
|
+
expect(screen.getByText(/Path not found/)).toBeTruthy()
|
|
204
|
+
} finally {
|
|
205
|
+
kernel.restore()
|
|
206
|
+
parent.restore()
|
|
207
|
+
}
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('re-fetches on a setTarget hot-swap', async () => {
|
|
211
|
+
const parent = handshake('mon-1')
|
|
150
212
|
const kernel = installFakeKernel((body) =>
|
|
151
|
-
|
|
213
|
+
body.method === '@mon-2::get'
|
|
214
|
+
? ok(monitorNode({ id: 'mon-2', name: 'Second monitor', url: 'https://b.test', status: 'down' }))
|
|
215
|
+
: ok(monitorNode({ id: 'mon-1', name: 'First monitor', url: 'https://a.test', status: 'up' })),
|
|
152
216
|
)
|
|
153
217
|
|
|
154
218
|
try {
|
|
155
219
|
render(<App />)
|
|
156
220
|
await flush()
|
|
157
|
-
expect(
|
|
158
|
-
|
|
159
|
-
|
|
221
|
+
expect(screen.getByText('First monitor')).toBeTruthy()
|
|
222
|
+
expect(screen.getByText('UP')).toBeTruthy()
|
|
223
|
+
|
|
224
|
+
// Parent pushes a new target node.
|
|
225
|
+
parent.setTarget('mon-2')
|
|
226
|
+
await flush()
|
|
227
|
+
|
|
228
|
+
expect(screen.getByText('Second monitor')).toBeTruthy()
|
|
229
|
+
expect(screen.getByText('DOWN')).toBeTruthy()
|
|
230
|
+
} finally {
|
|
231
|
+
kernel.restore()
|
|
232
|
+
parent.restore()
|
|
233
|
+
}
|
|
234
|
+
})
|
|
160
235
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
236
|
+
it('explains a missing target instead of a blank screen', async () => {
|
|
237
|
+
const parent = handshake(undefined)
|
|
238
|
+
const kernel = installFakeKernel(() => ok(null))
|
|
239
|
+
try {
|
|
240
|
+
render(<App />)
|
|
164
241
|
await flush()
|
|
165
242
|
|
|
166
|
-
|
|
167
|
-
expect(kernel.calls).toHaveLength(
|
|
168
|
-
expect(kernel.calls[1]!.body.method).toBe('@note-9::get')
|
|
169
|
-
expect(kernel.calls[1]!.headers['authorization']).toBe('tok-B')
|
|
243
|
+
expect(screen.getByText(/No target Monitor/)).toBeTruthy()
|
|
244
|
+
expect(kernel.calls).toHaveLength(0)
|
|
170
245
|
} finally {
|
|
171
246
|
kernel.restore()
|
|
172
247
|
parent.restore()
|
|
@@ -174,15 +249,29 @@ describe('ui-note view — handshake + real node render', () => {
|
|
|
174
249
|
})
|
|
175
250
|
|
|
176
251
|
it('falls back to a standalone preview with no parent', async () => {
|
|
177
|
-
// No fake parent
|
|
178
|
-
// window.parent === window in this realm.
|
|
252
|
+
// No fake parent → the shell sees window.parent === window and stands alone.
|
|
179
253
|
const kernel = installFakeKernel(() => ok(null))
|
|
180
254
|
try {
|
|
181
255
|
render(<App />)
|
|
182
256
|
await flush()
|
|
183
257
|
|
|
184
258
|
expect(screen.getByText(/No parent shell/)).toBeTruthy()
|
|
185
|
-
|
|
259
|
+
expect(kernel.calls).toHaveLength(0)
|
|
260
|
+
} finally {
|
|
261
|
+
kernel.restore()
|
|
262
|
+
}
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
describe('router fallback', () => {
|
|
267
|
+
it('renders "No view registered" for an unregistered path', async () => {
|
|
268
|
+
mountAt('/ui/nope')
|
|
269
|
+
const kernel = installFakeKernel(() => ok(null))
|
|
270
|
+
try {
|
|
271
|
+
render(<App />)
|
|
272
|
+
await flush()
|
|
273
|
+
|
|
274
|
+
expect(screen.getByText(/No view registered for \/ui\/nope/)).toBeTruthy()
|
|
186
275
|
expect(kernel.calls).toHaveLength(0)
|
|
187
276
|
} finally {
|
|
188
277
|
kernel.restore()
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Test harness — a FAKE shell parent + a FAKE kernel, so the client's
|
|
3
|
-
* handshake (
|
|
4
|
-
* can be exercised with no real GUI/kernel.
|
|
2
|
+
* Test harness — a FAKE shell parent + a FAKE kernel, so the client's shell
|
|
3
|
+
* handshake (now `@astrale-os/shell`, `createShell({ mode: 'sandboxed' })`) and
|
|
4
|
+
* its kernel calls can be exercised with no real GUI/kernel.
|
|
5
|
+
*
|
|
6
|
+
* `fakeKernelSession()` is the unit-test counterpart: a `KernelClient` whose
|
|
7
|
+
* `call` hits the fake kernel directly (no handshake), for hooks tested in
|
|
8
|
+
* isolation.
|
|
5
9
|
*
|
|
6
10
|
* The fake parent mirrors `runParentHandshake`
|
|
7
11
|
* (`shell/packages/shell/src/application/windowing/handshake.ts`):
|
|
@@ -17,6 +21,8 @@
|
|
|
17
21
|
* the child believes it is framed.
|
|
18
22
|
*/
|
|
19
23
|
|
|
24
|
+
import type { KernelClient } from '@/shell'
|
|
25
|
+
|
|
20
26
|
const INIT_REQUEST_TYPE = 'astrale-shell/init-request'
|
|
21
27
|
const INIT_RESPONSE_TYPE = 'astrale-shell/init-response'
|
|
22
28
|
|
|
@@ -196,26 +202,75 @@ export function ok(result: unknown): { result: unknown } {
|
|
|
196
202
|
return { result }
|
|
197
203
|
}
|
|
198
204
|
|
|
199
|
-
/**
|
|
200
|
-
|
|
205
|
+
/**
|
|
206
|
+
* A `KernelClient` for unit tests that exercise feature hooks WITHOUT a
|
|
207
|
+
* handshake. Its `call` POSTs the kernel JSON envelope (`{ method, params, id }`)
|
|
208
|
+
* so the `installFakeKernel` fetch stub records it and `body.method`/
|
|
209
|
+
* `body.params` assertions hold — same wire the real `shell.kernel` speaks to a
|
|
210
|
+
* JSON kernel. The credential surface (`mintDelegation`/`authToken`/`url`) is
|
|
211
|
+
* stubbed minimally — the isolated hooks under test only ever touch `call`.
|
|
212
|
+
*/
|
|
213
|
+
export function fakeKernelSession(kernelUrl = 'https://k.example.test/api'): KernelClient {
|
|
214
|
+
let idSeq = 0
|
|
215
|
+
return {
|
|
216
|
+
async call(method: string, params: unknown = {}): Promise<unknown> {
|
|
217
|
+
idSeq += 1
|
|
218
|
+
const res = await fetch(kernelUrl, {
|
|
219
|
+
method: 'POST',
|
|
220
|
+
headers: {
|
|
221
|
+
'content-type': 'application/vnd.astrale.kernel+json',
|
|
222
|
+
accept: 'application/vnd.astrale.kernel+json',
|
|
223
|
+
authorization: 'tok-A',
|
|
224
|
+
},
|
|
225
|
+
body: JSON.stringify({ method, params, id: `c${idSeq}` }),
|
|
226
|
+
})
|
|
227
|
+
const obj = (await res.json()) as Record<string, unknown>
|
|
228
|
+
if (obj.error && typeof obj.error === 'object') {
|
|
229
|
+
const err = obj.error as { code?: unknown; message?: unknown }
|
|
230
|
+
const code = typeof err.code === 'number' ? err.code : 5000
|
|
231
|
+
const message = typeof err.message === 'string' ? err.message : 'Unknown error'
|
|
232
|
+
throw new Error(`${code}: ${message}`)
|
|
233
|
+
}
|
|
234
|
+
return obj.result
|
|
235
|
+
},
|
|
236
|
+
mintDelegation: async () => '<fake>',
|
|
237
|
+
authToken: () => '<fake>',
|
|
238
|
+
url: kernelUrl,
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Build a minimal kernel Monitor node whose props carry url/status/statusCode/
|
|
244
|
+
* latencyMs/lastCheckedAt, with the same domain-qualified key format the kernel
|
|
245
|
+
* uses (`<domain>:class.Monitor.property.<name>`). The name uses the kernel
|
|
246
|
+
* `Named.name` key.
|
|
247
|
+
*/
|
|
248
|
+
export function monitorNode(opts: {
|
|
201
249
|
id: string
|
|
202
250
|
path?: string
|
|
203
251
|
name?: string
|
|
204
|
-
|
|
205
|
-
|
|
252
|
+
url?: string
|
|
253
|
+
status?: string
|
|
254
|
+
statusCode?: string
|
|
255
|
+
latencyMs?: string
|
|
256
|
+
lastCheckedAt?: string
|
|
206
257
|
domain?: string
|
|
207
258
|
}): { id: string; path: string; class: { raw: string }; props: Record<string, unknown> } {
|
|
208
|
-
const domain = opts.domain ?? '
|
|
259
|
+
const domain = opts.domain ?? 'monitors.astrale.ai'
|
|
260
|
+
const base = `${domain}:class.Monitor.property`
|
|
209
261
|
const props: Record<string, unknown> = {}
|
|
210
262
|
if (opts.name !== undefined) {
|
|
211
263
|
props['kernel.astrale.ai:interface.Named.property.name'] = opts.name
|
|
212
264
|
}
|
|
213
|
-
if (opts.
|
|
214
|
-
if (opts.
|
|
265
|
+
if (opts.url !== undefined) props[`${base}.url`] = opts.url
|
|
266
|
+
if (opts.status !== undefined) props[`${base}.status`] = opts.status
|
|
267
|
+
if (opts.statusCode !== undefined) props[`${base}.statusCode`] = opts.statusCode
|
|
268
|
+
if (opts.latencyMs !== undefined) props[`${base}.latencyMs`] = opts.latencyMs
|
|
269
|
+
if (opts.lastCheckedAt !== undefined) props[`${base}.lastCheckedAt`] = opts.lastCheckedAt
|
|
215
270
|
return {
|
|
216
271
|
id: opts.id,
|
|
217
|
-
path: opts.path ?? `/
|
|
218
|
-
class: { raw: `/:${domain}:class.
|
|
272
|
+
path: opts.path ?? `/monitors/${opts.id}`,
|
|
273
|
+
class: { raw: `/:${domain}:class.Monitor` },
|
|
219
274
|
props,
|
|
220
275
|
}
|
|
221
276
|
}
|
|
@@ -1,68 +1,83 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { qualifiedString, readProp, readPropBySuffix } from '@/shell'
|
|
4
|
+
import { monitorFromNode, normalizeStatus } from '@/monitor'
|
|
5
|
+
import { relativeTime } from '@/ui'
|
|
6
|
+
import { monitorNode } from './harness'
|
|
5
7
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
restore?.()
|
|
12
|
-
restore = undefined
|
|
8
|
+
describe('prop readers', () => {
|
|
9
|
+
it('readProp returns the string value at the exact key', () => {
|
|
10
|
+
expect(readProp({ a: 'x', b: 1 }, 'a')).toBe('x')
|
|
11
|
+
expect(readProp({ a: 'x' }, 'missing')).toBeUndefined()
|
|
12
|
+
expect(readProp({ a: 1 }, 'a')).toBeUndefined() // non-string
|
|
13
13
|
})
|
|
14
14
|
|
|
15
|
-
it('
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
expect(req.method).toBe('POST')
|
|
23
|
-
expect(req.url).toBe('https://k.test/api/') // slash added
|
|
24
|
-
expect(req.headers['authorization']).toBe('tok-X')
|
|
25
|
-
expect(req.headers['content-type']).toBe(TYPE)
|
|
26
|
-
expect(req.headers['accept']).toBe(TYPE)
|
|
27
|
-
expect(req.body).toMatchObject({ method: '@n1::get', params: {} })
|
|
28
|
-
expect(req.body.id).toBeTruthy()
|
|
15
|
+
it('readPropBySuffix matches the first key ending with the suffix', () => {
|
|
16
|
+
const props = {
|
|
17
|
+
'monitors.astrale.ai:class.Monitor.property.url': 'https://x.test',
|
|
18
|
+
'kernel.astrale.ai:interface.Named.property.name': 'n',
|
|
19
|
+
}
|
|
20
|
+
expect(readPropBySuffix(props, '.property.url')).toBe('https://x.test')
|
|
21
|
+
expect(readPropBySuffix(props, '.property.status')).toBeUndefined()
|
|
29
22
|
})
|
|
30
23
|
|
|
31
|
-
it('
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
24
|
+
it('qualifiedString prefers a domain key over the kernel one for the same suffix', () => {
|
|
25
|
+
const props = {
|
|
26
|
+
'kernel.astrale.ai:interface.Named.property.name': 'kernel name',
|
|
27
|
+
'monitors.astrale.ai:class.Monitor.property.name': 'domain name',
|
|
28
|
+
}
|
|
29
|
+
expect(qualifiedString(props, 'name')).toBe('domain name')
|
|
36
30
|
})
|
|
31
|
+
})
|
|
37
32
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
33
|
+
describe('monitorFromNode mapper', () => {
|
|
34
|
+
it('projects name, url, normalized status and coerced numbers', () => {
|
|
35
|
+
const record = monitorFromNode(
|
|
36
|
+
monitorNode({
|
|
37
|
+
id: 'mon-1',
|
|
38
|
+
name: 'API health',
|
|
39
|
+
url: 'https://api.test/health',
|
|
40
|
+
status: 'up',
|
|
41
|
+
statusCode: '200',
|
|
42
|
+
latencyMs: '42',
|
|
43
|
+
lastCheckedAt: '2026-06-14T10:00:00Z',
|
|
44
|
+
}),
|
|
43
45
|
)
|
|
46
|
+
expect(record).toMatchObject({
|
|
47
|
+
id: 'mon-1',
|
|
48
|
+
name: 'API health',
|
|
49
|
+
url: 'https://api.test/health',
|
|
50
|
+
status: 'up',
|
|
51
|
+
statusCode: 200,
|
|
52
|
+
latencyMs: 42,
|
|
53
|
+
lastCheckedAt: '2026-06-14T10:00:00Z',
|
|
54
|
+
})
|
|
44
55
|
})
|
|
45
56
|
|
|
46
|
-
it('
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
57
|
+
it('falls back to the path segment for the name and unknown for a missing status', () => {
|
|
58
|
+
const record = monitorFromNode(monitorNode({ id: 'mon-9', path: '/monitors/edge' }))
|
|
59
|
+
expect(record.name).toBe('edge')
|
|
60
|
+
expect(record.status).toBe('unknown')
|
|
61
|
+
expect(record.url).toBe('')
|
|
62
|
+
expect(record.statusCode).toBeUndefined()
|
|
50
63
|
})
|
|
51
64
|
})
|
|
52
65
|
|
|
53
|
-
describe('
|
|
54
|
-
it('
|
|
55
|
-
expect(
|
|
56
|
-
expect(
|
|
57
|
-
expect(
|
|
66
|
+
describe('normalizeStatus', () => {
|
|
67
|
+
it('passes through up/down and maps everything else to unknown', () => {
|
|
68
|
+
expect(normalizeStatus('up')).toBe('up')
|
|
69
|
+
expect(normalizeStatus('down')).toBe('down')
|
|
70
|
+
expect(normalizeStatus('weird')).toBe('unknown')
|
|
71
|
+
expect(normalizeStatus(undefined)).toBe('unknown')
|
|
58
72
|
})
|
|
73
|
+
})
|
|
59
74
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
expect(
|
|
66
|
-
expect(
|
|
75
|
+
describe('relativeTime', () => {
|
|
76
|
+
const now = Date.parse('2026-06-14T12:00:00Z')
|
|
77
|
+
it('renders coarse buckets and a dash for missing input', () => {
|
|
78
|
+
expect(relativeTime(undefined, now)).toBe('—')
|
|
79
|
+
expect(relativeTime('2026-06-14T11:55:00Z', now)).toBe('5m ago')
|
|
80
|
+
expect(relativeTime('2026-06-14T10:00:00Z', now)).toBe('2h ago')
|
|
81
|
+
expect(relativeTime('not-a-date', now)).toBe('not-a-date')
|
|
67
82
|
})
|
|
68
83
|
})
|