@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.
Files changed (123) hide show
  1. package/dist/assets-pack.d.ts +1 -1
  2. package/dist/assets-pack.js +1 -1
  3. package/dist/build.d.ts +15 -0
  4. package/dist/build.d.ts.map +1 -0
  5. package/dist/build.js +15 -0
  6. package/dist/build.js.map +1 -0
  7. package/dist/client.d.ts +9 -0
  8. package/dist/client.d.ts.map +1 -1
  9. package/dist/client.js +10 -1
  10. package/dist/client.js.map +1 -1
  11. package/dist/cloudflare.d.ts +15 -3
  12. package/dist/cloudflare.d.ts.map +1 -1
  13. package/dist/cloudflare.js +52 -18
  14. package/dist/cloudflare.js.map +1 -1
  15. package/dist/codegen/worker.d.ts +26 -6
  16. package/dist/codegen/worker.d.ts.map +1 -1
  17. package/dist/codegen/worker.js +67 -54
  18. package/dist/codegen/worker.js.map +1 -1
  19. package/dist/codegen/wrangler.d.ts +11 -2
  20. package/dist/codegen/wrangler.d.ts.map +1 -1
  21. package/dist/codegen/wrangler.js +11 -5
  22. package/dist/codegen/wrangler.js.map +1 -1
  23. package/dist/index.d.ts +6 -3
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +5 -2
  26. package/dist/index.js.map +1 -1
  27. package/dist/params.d.ts +30 -30
  28. package/dist/params.d.ts.map +1 -1
  29. package/dist/parse-output.d.ts +1 -1
  30. package/dist/parse-output.js +1 -1
  31. package/package.json +6 -2
  32. package/src/assets-pack.ts +1 -1
  33. package/src/build.ts +15 -0
  34. package/src/client.ts +11 -1
  35. package/src/cloudflare.ts +53 -18
  36. package/src/codegen/worker.ts +76 -59
  37. package/src/codegen/wrangler.ts +15 -5
  38. package/src/index.ts +6 -3
  39. package/src/params.ts +32 -31
  40. package/src/parse-output.ts +1 -1
  41. package/template/.agents/skills/astrale-cli/SKILL.md +26 -12
  42. package/template/.agents/skills/astrale-domain/SKILL.md +46 -29
  43. package/template/.env.example +6 -0
  44. package/template/README.md +25 -10
  45. package/template/astrale.config.ts +27 -33
  46. package/template/client/README.md +80 -63
  47. package/template/client/__tests__/app.test.tsx +188 -99
  48. package/template/client/__tests__/harness.ts +67 -12
  49. package/template/client/__tests__/kernel.test.ts +65 -50
  50. package/template/client/__tests__/seam.test.tsx +111 -0
  51. package/template/client/index.html +1 -1
  52. package/template/client/package.json +1 -0
  53. package/template/client/src/app.tsx +40 -83
  54. package/template/client/src/main.tsx +2 -2
  55. package/template/client/src/monitor/components/MonitorCard.tsx +50 -0
  56. package/template/client/src/monitor/components/index.ts +1 -0
  57. package/template/client/src/monitor/hooks/index.ts +3 -0
  58. package/template/client/src/monitor/hooks/useCheck.mutation.ts +16 -0
  59. package/template/client/src/monitor/hooks/useMonitor.query.ts +64 -0
  60. package/template/client/src/monitor/index.ts +6 -0
  61. package/template/client/src/monitor/monitor.api.ts +11 -0
  62. package/template/client/src/monitor/monitor.mappers.ts +38 -0
  63. package/template/client/src/monitor/monitor.types.ts +23 -0
  64. package/template/client/src/monitor/ui/MonitorDetails.UI.tsx +38 -0
  65. package/template/client/src/monitor/ui/StatusBadge.UI.tsx +14 -0
  66. package/template/client/src/monitor/ui/index.ts +8 -0
  67. package/template/client/src/shell/client.ts +67 -0
  68. package/template/client/src/shell/index.ts +20 -0
  69. package/template/client/src/shell/invoke.ts +35 -0
  70. package/template/client/src/shell/transformers.ts +72 -0
  71. package/template/client/src/shell/use-async.ts +56 -0
  72. package/template/client/src/shell/use-capability.ts +61 -0
  73. package/template/client/src/shell/use-node.ts +61 -0
  74. package/template/client/src/shell/use-shell.ts +91 -0
  75. package/template/client/src/shell/view-router.tsx +98 -0
  76. package/template/client/src/styles.css +177 -4
  77. package/template/client/src/ui/format.ts +24 -0
  78. package/template/client/src/ui/index.ts +9 -0
  79. package/template/client/src/ui/surface.tsx +56 -0
  80. package/template/client/src/ui/value.tsx +32 -0
  81. package/template/client/src/views/monitor.tsx +30 -0
  82. package/template/client/tsconfig.json +3 -2
  83. package/template/client/vite.config.ts +14 -15
  84. package/template/client/vitest.config.ts +12 -5
  85. package/template/core/monitor/health.ts +19 -0
  86. package/template/core/monitor/index.ts +9 -0
  87. package/template/core/monitor/keys.ts +29 -0
  88. package/template/core/monitor/node.ts +51 -0
  89. package/template/deps.ts +25 -0
  90. package/template/domain.ts +33 -0
  91. package/template/env.ts +4 -0
  92. package/template/integrations/prober/http.ts +43 -0
  93. package/template/integrations/prober/mock.ts +22 -0
  94. package/template/integrations/prober/port.ts +28 -0
  95. package/template/integrations/prober/registry.ts +66 -0
  96. package/template/package.json +2 -3
  97. package/template/pnpm-lock.yaml +2766 -0
  98. package/template/runtime/index.ts +79 -0
  99. package/template/runtime/monitor/check.ts +29 -0
  100. package/template/runtime/monitor/dependsOn.ts +16 -0
  101. package/template/runtime/monitor/index.ts +12 -0
  102. package/template/runtime/monitor/seed.ts +74 -0
  103. package/template/runtime/monitor/shared.ts +17 -0
  104. package/template/runtime/monitor/watch.ts +37 -0
  105. package/template/schema/index.ts +13 -4
  106. package/template/schema/monitor.ts +80 -0
  107. package/template/tsconfig.json +13 -2
  108. package/template/views/index.ts +9 -2
  109. package/template/views/monitor.ts +22 -0
  110. package/dist/astrale.d.ts +0 -27
  111. package/dist/astrale.d.ts.map +0 -1
  112. package/dist/astrale.js +0 -222
  113. package/dist/astrale.js.map +0 -1
  114. package/src/astrale.ts +0 -259
  115. package/template/client/src/lib/kernel.ts +0 -135
  116. package/template/client/src/lib/shell.ts +0 -197
  117. package/template/client/src/lib/use-node.ts +0 -66
  118. package/template/client/src/lib/use-shell.ts +0 -85
  119. package/template/methods/index.ts +0 -66
  120. package/template/methods/note.ts +0 -131
  121. package/template/schema/compiled.ts +0 -14
  122. package/template/schema/note.ts +0 -64
  123. 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 '../src/app'
5
- import { type CapturedRequest, installFakeKernel, installFakeParent, noteNode, ok } from './harness'
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
- const KERNEL_TYPE = 'application/vnd.astrale.kernel+json'
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 fetch settle, flushing the resulting React
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. Wrapping
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
- /** Common assertions on a captured kernel request for `@<id>::get`. */
27
- function expectGetCall(req: CapturedRequest, nodeId: string, token: string) {
28
- // POST to the kernel URL with a trailing slash added.
29
- expect(req.method).toBe('POST')
30
- expect(req.url).toBe(`${KERNEL_URL}/`)
31
- // Bare delegation token (no "Bearer ").
32
- expect(req.headers['authorization']).toBe(token)
33
- // Kernel JSON envelope content type on both content-type and accept.
34
- expect(req.headers['content-type']).toBe(KERNEL_TYPE)
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('ui-note view — handshake + real node render', () => {
43
- it('completes the handshake, calls @<id>::get, and renders the Note', async () => {
44
- const parent = installFakeParent({
45
- windowId: 'win-1',
46
- kernelUrl: KERNEL_URL,
47
- functionId: 'ui-note',
48
- delegationToken: 'tok-A',
49
- tokenExpiresAt: Date.now() + 3_600_000,
50
- targetNodeId: 'note-1',
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
- // The Note renders its title (Named.name) and body.
64
- expect(screen.getByText('My first note')).toBeTruthy()
65
- expect(screen.getByText('Hello from the kernel.')).toBeTruthy()
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, with the verified wire shape.
83
+ // Exactly one kernel call, for the node load.
68
84
  expect(kernel.calls).toHaveLength(1)
69
- expectGetCall(kernel.calls[0]!, 'note-1', 'tok-A')
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 the kernel error envelope on the error path', async () => {
77
- const parent = installFakeParent({
78
- windowId: 'win-2',
79
- kernelUrl: KERNEL_URL,
80
- functionId: 'ui-note',
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
- expect(screen.getByText(/Failed to load the Note/)).toBeTruthy()
94
- // The "<code>: <message>" surfaces from kernelCall.
95
- expect(screen.getByText(/3002: Path not found/)).toBeTruthy()
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('re-fetches on a setTarget hot-swap', async () => {
104
- const parent = installFakeParent({
105
- windowId: 'win-3',
106
- kernelUrl: KERNEL_URL,
107
- functionId: 'ui-note',
108
- delegationToken: 'tok-A',
109
- tokenExpiresAt: Date.now() + 3_600_000,
110
- targetNodeId: 'note-1',
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
- expect(screen.getByText('First note')).toBeTruthy()
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
- // Parent pushes a new target node.
126
- parent.setTarget('note-2')
141
+ // Click "Check now".
142
+ await act(async () => {
143
+ fireEvent.click(screen.getByText('Check now'))
144
+ })
127
145
  await flush()
128
146
 
129
- expect(screen.getByText('Second note')).toBeTruthy()
130
- expect(screen.getByText('Swapped in.')).toBeTruthy()
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
- // A second kernel call for the new node.
133
- expect(kernel.calls).toHaveLength(2)
134
- expectGetCall(kernel.calls[1]!, 'note-2', 'tok-A')
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('uses the refreshed token for calls after ctrl:tokenRefresh', async () => {
142
- const parent = installFakeParent({
143
- windowId: 'win-4',
144
- kernelUrl: KERNEL_URL,
145
- functionId: 'ui-note',
146
- delegationToken: 'tok-A',
147
- tokenExpiresAt: Date.now() + 1_000,
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
- ok(noteNode({ id: body.method.slice(1, -5), name: 'Note', body: 'b' })),
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(kernel.calls).toHaveLength(1)
158
- // First call used the handshake token.
159
- expect(kernel.calls[0]!.headers['authorization']).toBe('tok-A')
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
- // Parent pushes a fresh token, then triggers a new fetch via setTarget.
162
- parent.tokenRefresh('tok-B', Date.now() + 3_600_000)
163
- parent.setTarget('note-9')
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
- // The post-refresh call carries the NEW token.
167
- expect(kernel.calls).toHaveLength(2)
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 installed connectShell rejects fast because
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
- // Never hit the kernel in standalone mode.
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 inline
3
- * handshake (`src/lib/shell.ts`) and JSON kernel client (`src/lib/kernel.ts`)
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
- /** Build a minimal kernel Note node whose props carry title/body. */
200
- export function noteNode(opts: {
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
- title?: string
205
- body?: string
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 ?? 'notes.astrale.ai'
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.title !== undefined) props[`${domain}:class.Note.property.title`] = opts.title
214
- if (opts.body !== undefined) props[`${domain}:class.Note.property.body`] = opts.body
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 ?? `/notes/${opts.id}`,
218
- class: { raw: `/:${domain}:class.Note` },
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 { afterEach, describe, expect, it } from 'vitest'
1
+ import { describe, expect, it } from 'vitest'
2
2
 
3
- import { kernelCall, readProp, readPropBySuffix } from '../src/lib/kernel'
4
- import { installFakeKernel } from './harness'
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
- const TYPE = 'application/vnd.astrale.kernel+json'
7
-
8
- describe('kernelCall inline JSON envelope client', () => {
9
- let restore: (() => void) | undefined
10
- afterEach(() => {
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('POSTs the envelope with a trailing slash, bare token, and kernel content type', async () => {
16
- const k = installFakeKernel(() => ({ result: { ok: true } }))
17
- restore = k.restore
18
- const result = await kernelCall('https://k.test/api', 'tok-X', '@n1::get', {})
19
- expect(result).toEqual({ ok: true })
20
-
21
- const req = k.calls[0]!
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('does not double the trailing slash when the URL already ends in one', async () => {
32
- const k = installFakeKernel(() => ({ result: 1 }))
33
- restore = k.restore
34
- await kernelCall('https://k.test/api/', 'tok', '@n::get')
35
- expect(k.calls[0]!.url).toBe('https://k.test/api/')
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
- it('throws "<code>: <message>" on a kernel error envelope', async () => {
39
- const k = installFakeKernel(() => ({ error: { code: 2004, message: 'Permission denied' } }))
40
- restore = k.restore
41
- await expect(kernelCall('https://k.test/api', 't', '@n::get')).rejects.toThrow(
42
- '2004: Permission denied',
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('throws on an unexpected redirect envelope', async () => {
47
- const k = installFakeKernel(() => ({ redirect: { url: 'https://other.test/api' } }))
48
- restore = k.restore
49
- await expect(kernelCall('https://k.test/api', 't', '@n::get')).rejects.toThrow(/redirect/)
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('prop readers', () => {
54
- it('readProp returns the string value at the exact key', () => {
55
- expect(readProp({ a: 'x', b: 1 }, 'a')).toBe('x')
56
- expect(readProp({ a: 'x' }, 'missing')).toBeUndefined()
57
- expect(readProp({ a: 1 }, 'a')).toBeUndefined() // non-string
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
- it('readPropBySuffix matches the first key ending with the suffix', () => {
61
- const props = {
62
- 'notes.astrale.ai:class.Note.property.body': 'the body',
63
- 'kernel.astrale.ai:interface.Named.property.name': 'n',
64
- }
65
- expect(readPropBySuffix(props, '.property.body')).toBe('the body')
66
- expect(readPropBySuffix(props, '.property.title')).toBeUndefined()
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
  })