@astrale-os/adapter-cloudflare 0.1.9 → 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 (80) hide show
  1. package/package.json +1 -1
  2. package/template/.agents/skills/astrale-cli/SKILL.md +1 -1
  3. package/template/.agents/skills/astrale-domain/SKILL.md +5 -5
  4. package/template/.env.example +5 -7
  5. package/template/README.md +2 -2
  6. package/template/client/README.md +79 -62
  7. package/template/client/__tests__/app.test.tsx +188 -99
  8. package/template/client/__tests__/harness.ts +67 -12
  9. package/template/client/__tests__/kernel.test.ts +65 -50
  10. package/template/client/__tests__/seam.test.tsx +111 -0
  11. package/template/client/index.html +1 -1
  12. package/template/client/package.json +1 -0
  13. package/template/client/src/app.tsx +40 -83
  14. package/template/client/src/main.tsx +2 -2
  15. package/template/client/src/monitor/components/MonitorCard.tsx +50 -0
  16. package/template/client/src/monitor/components/index.ts +1 -0
  17. package/template/client/src/monitor/hooks/index.ts +3 -0
  18. package/template/client/src/monitor/hooks/useCheck.mutation.ts +16 -0
  19. package/template/client/src/monitor/hooks/useMonitor.query.ts +64 -0
  20. package/template/client/src/monitor/index.ts +6 -0
  21. package/template/client/src/monitor/monitor.api.ts +11 -0
  22. package/template/client/src/monitor/monitor.mappers.ts +38 -0
  23. package/template/client/src/monitor/monitor.types.ts +23 -0
  24. package/template/client/src/monitor/ui/MonitorDetails.UI.tsx +38 -0
  25. package/template/client/src/monitor/ui/StatusBadge.UI.tsx +14 -0
  26. package/template/client/src/monitor/ui/index.ts +8 -0
  27. package/template/client/src/shell/client.ts +67 -0
  28. package/template/client/src/shell/index.ts +20 -0
  29. package/template/client/src/shell/invoke.ts +35 -0
  30. package/template/client/src/shell/transformers.ts +72 -0
  31. package/template/client/src/shell/use-async.ts +56 -0
  32. package/template/client/src/shell/use-capability.ts +61 -0
  33. package/template/client/src/shell/use-node.ts +61 -0
  34. package/template/client/src/shell/use-shell.ts +91 -0
  35. package/template/client/src/shell/view-router.tsx +98 -0
  36. package/template/client/src/styles.css +177 -4
  37. package/template/client/src/ui/format.ts +24 -0
  38. package/template/client/src/ui/index.ts +9 -0
  39. package/template/client/src/ui/surface.tsx +56 -0
  40. package/template/client/src/ui/value.tsx +32 -0
  41. package/template/client/src/views/monitor.tsx +30 -0
  42. package/template/client/tsconfig.json +2 -1
  43. package/template/client/vite.config.ts +12 -13
  44. package/template/client/vitest.config.ts +12 -5
  45. package/template/core/monitor/health.ts +19 -0
  46. package/template/core/monitor/index.ts +9 -0
  47. package/template/core/monitor/keys.ts +29 -0
  48. package/template/core/monitor/node.ts +51 -0
  49. package/template/deps.ts +8 -8
  50. package/template/domain.ts +1 -1
  51. package/template/env.ts +2 -9
  52. package/template/integrations/prober/http.ts +43 -0
  53. package/template/integrations/prober/mock.ts +22 -0
  54. package/template/integrations/prober/port.ts +28 -0
  55. package/template/integrations/prober/registry.ts +66 -0
  56. package/template/package.json +1 -1
  57. package/template/pnpm-lock.yaml +2766 -0
  58. package/template/runtime/index.ts +36 -19
  59. package/template/runtime/monitor/check.ts +29 -0
  60. package/template/runtime/monitor/dependsOn.ts +16 -0
  61. package/template/runtime/monitor/index.ts +12 -0
  62. package/template/runtime/monitor/seed.ts +74 -0
  63. package/template/runtime/monitor/shared.ts +17 -0
  64. package/template/runtime/monitor/watch.ts +37 -0
  65. package/template/schema/index.ts +11 -4
  66. package/template/schema/monitor.ts +80 -0
  67. package/template/views/index.ts +9 -2
  68. package/template/views/monitor.ts +22 -0
  69. package/template/client/src/lib/kernel.ts +0 -135
  70. package/template/client/src/lib/shell.ts +0 -197
  71. package/template/client/src/lib/use-node.ts +0 -66
  72. package/template/client/src/lib/use-shell.ts +0 -85
  73. package/template/core/keys.ts +0 -28
  74. package/template/core/note.ts +0 -148
  75. package/template/integrations/summary/heuristic.ts +0 -25
  76. package/template/integrations/summary/http.ts +0 -69
  77. package/template/integrations/summary/port.ts +0 -21
  78. package/template/integrations/summary/registry.ts +0 -52
  79. package/template/schema/note.ts +0 -67
  80. package/template/views/note.ts +0 -21
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrale-os/adapter-cloudflare",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "description": "Deploy an Astrale domain as a standalone Cloudflare Worker",
5
5
  "keywords": [
6
6
  "adapter",
@@ -91,7 +91,7 @@ Examples:
91
91
 
92
92
  ```bash
93
93
  astrale call /:blog.acme.com:class.Author:list
94
- astrale call /:blog.acme.com:interface.NoteOps:createNote title=Hello
94
+ astrale call /:blog.acme.com:interface.MonitorOps:watch url=https://astrale.ai
95
95
  astrale call /blog.acme.com/alice::deactivate
96
96
  astrale call @f00d...::deactivate
97
97
  ```
@@ -42,8 +42,8 @@ domain.ts # THE manifest — wires it ALL: defineDomain({ schema, meth
42
42
  # a renamed module is a compile error here, not a missing route.
43
43
  astrale.config.ts # binds the domain to its deploy adapter (deploy(domain, …)) — node-only
44
44
  schema/ # classes/interfaces/edges — the contract (zod props, fn signatures)
45
- # + compiled.ts (D = compileDomain) the public compiled entry
46
- core/ # pure, transport-agnostic logic + keys.ts (compiled accessors)
45
+ # + index.ts exports D = compileDomain(schema) (resolved paths/keys)
46
+ core/<context>/ # pure, transport-agnostic logic per bounded context (e.g. core/monitor: keys/health/node)
47
47
  integrations/ # external-API ports + adapters + a lazy registry (see §4) — what deps is built from
48
48
  runtime/index.ts # composition root: the methods map; each execute resolves deps → calls core logic
49
49
  functions/ # standalone remote functions (webhook-shaped endpoints)
@@ -123,7 +123,7 @@ Handler context: `kernel` (callback client bound to the composed credential —
123
123
  caller's delegated authority ∪ this function's own), `self` (instance methods),
124
124
  `params` (zod-validated), `deps` (your typed `Deps` from `deps.ts`; raw `Env` if
125
125
  you omit the mapper), `auth` (principal, verified claims). Resolve ports from
126
- `deps` per request (`deps.summarizer()` — the registry builds + caches them per
126
+ `deps` per request (`deps.prober()` — the registry builds + caches them per
127
127
  isolate) — NEVER construct external clients at module load (workers must be
128
128
  import-side-effect-free).
129
129
 
@@ -167,7 +167,7 @@ example — Scaleway/WorkOS/KV):
167
167
 
168
168
  1. **Port** — a narrow interface in `integrations/<feature>/port.ts` declaring
169
169
  only what the logic needs (`WeatherClient { forecast(city) }`, the scaffold's
170
- `Summarizer { summarize(body) }`). `core/` logic depends on the port, never
170
+ `Prober { probe(url) }`). `core/` logic depends on the port, never
171
171
  on fetch/SDKs/env.
172
172
  2. **Adapter(s) + registry** — `createXClient(config)` in
173
173
  `integrations/<feature>/` (one per backend), plus a `registry.ts` that reads
@@ -177,7 +177,7 @@ example — Scaleway/WorkOS/KV):
177
177
  upstream detail in `cause`. The registry builds the chosen adapter LAZILY +
178
178
  caches it per isolate (a worker never validates an unused backend's env).
179
179
  3. **Wiring** — `deps.ts` mounts the registry (`defineDomain({ deps })`); the
180
- `execute` hook resolves the PORT from `deps` (`deps.summarizer()`) per
180
+ `execute` hook resolves the PORT from `deps` (`deps.prober()`) per
181
181
  request and passes it to the `core/` logic.
182
182
 
183
183
  Secrets & config:
@@ -5,10 +5,8 @@
5
5
  #
6
6
  # EXAMPLE_API_KEY=sk-...
7
7
  #
8
- # ── Summarizer (the example external-API integration) ──────────────────────
9
- # The scaffold summarizes notes with a no-network heuristic by default. To use a
10
- # real OpenAI-compatible model instead, set NOTE_SUMMARIZER=http and provide:
11
- # NOTE_SUMMARIZER=http
12
- # SUMMARIZER_API_KEY=sk-...
13
- # SUMMARIZER_BASE_URL=https://api.openai.com/v1
14
- # SUMMARIZER_MODEL=gpt-4o-mini
8
+ # ── Prober (the example external-API integration) ──────────────────────────
9
+ # The scaffold probes monitor targets with a real, KEYLESS HTTP request by
10
+ # default (no secret needed). These knobs are optional config, not secrets:
11
+ # PROBER=http # `http` (default) | `mock` (offline/deterministic)
12
+ # PROBE_TIMEOUT_MS=10000
@@ -27,7 +27,7 @@ pnpm prod # deploy prod → prints a URL
27
27
 
28
28
  ```
29
29
  schema/ classes + interfaces (the data model) + compiled accessors
30
- core/ pure, transport-agnostic logic + keys.ts (never hand-write keys)
30
+ core/<context>/ pure, transport-agnostic logic per bounded context (e.g. core/monitor)
31
31
  integrations/ external-API ports + adapters + a lazy registry (built into deps)
32
32
  runtime/ the execute() handlers — the composition root (the methods map)
33
33
  views/ iframe-mountable UIs
@@ -89,7 +89,7 @@ adapter-owned keys `name`, `main`, `assets`, `routes` are rejected (use
89
89
 
90
90
  - **Edit a handler** (`core/` logic or `runtime/` wiring) → hot-reloads at the same URL. Nothing to reinstall.
91
91
  - **Edit the schema** (`schema/`) → rebuilds the graph; reinstall with `astrale domain install <url> --direct`.
92
- - **`postInstall`** (the static `Note.seed` method) runs once after install, as
92
+ - **`postInstall`** (the static `Monitor.seed` method) runs once after install, as
93
93
  the system identity, so the domain can seed itself and set its own grants. It
94
94
  must be a class-hosted static addressed by a typed colon-path
95
95
  (`/:origin:class.X:seed`) — the kernel refuses tree paths here.
@@ -1,38 +1,64 @@
1
1
  # astrale-domain-client — SPA for domain views
2
2
 
3
- A small React + Vite SPA that renders the domain's `ui-note` View. It is loaded
4
- inside an iframe mounted by the Astrale shell, runs the shell handshake to learn
5
- its target node id and a delegation token, fetches that Note from the kernel,
6
- and renders its title/body. Built into `../.dist/` and served by the
7
- generated worker (`.astrale/`) under `/ui/*` via its `ASSETS` binding.
8
-
9
- ## Self-contained on purpose
10
-
11
- This client depends only on `react`, `react-dom`, `vite`, and
12
- `@vitejs/plugin-react` all on public npm. It does **not** import
13
- `@astrale-os/shell`, `@astrale-os/kernel-client`, or `@astrale-os/ui-components`,
14
- so the template builds with no registry auth and no workspace `link:`s. Two
15
- small subsets are reimplemented inline:
16
-
17
- - the shell child-handshake (`src/lib/shell.ts`), and
18
- - a minimal JSON kernel client (`src/lib/kernel.ts`).
3
+ A small React + Vite SPA that renders the domain's Views. It is loaded inside an
4
+ iframe mounted by the Astrale shell, runs the shell handshake (via the real
5
+ `@astrale-os/shell`) to learn its target node id and a kernel session, fetches
6
+ that node from the kernel, and renders it. The bundled `ui-monitor` view loads a
7
+ Monitor and renders its status/url/latency, with a "Check now" button that calls
8
+ the Monitor's `check` instance method and reloads the node. Built into `../.dist/`
9
+ and served by the generated worker (`.astrale/`) under `/ui/*` via its `ASSETS`
10
+ binding.
11
+
12
+ ## Built on the real shell SDK
13
+
14
+ This client consumes `@astrale-os/shell` for the child handshake AND the kernel
15
+ client: `src/shell/use-shell.ts` boots `createShell({ mode: 'sandboxed' })`, and
16
+ feature hooks call kernel methods through `shell.kernel` (token refresh, codec
17
+ negotiation, redirect following, and delegation are all handled by the SDK no
18
+ inline wire code). `@` is aliased to `src/` (mirrors `tsconfig` paths), so feature
19
+ code imports `@/shell`, `@/ui`, `@/monitor`.
19
20
 
20
21
  ## Layout
21
22
 
23
+ Feature-first: each feature owns its types/api/mappers/hooks/components. The
24
+ `shell/` and `ui/` folders are the generic, domain-neutral seams every feature
25
+ builds on.
26
+
22
27
  ```
23
28
  src/
24
29
  main.tsx # entry → createRoot(App)
25
- app.tsx # the ui-note view (renders the loaded Note)
26
- lib/
27
- shell.ts # inline shell child handshake (postMessage + MessagePort)
28
- use-shell.ts # React hook over the handshake (status + live session)
29
- kernel.ts # inline kernel JSON client (kernelCall + prop readers)
30
- use-node.ts # React hook: fetch a node via @<id>::get
31
- styles.css # self-contained styles (no Tailwind)
30
+ app.tsx # path router: ROUTES { '/ui/monitor': MonitorView }
31
+ styles.css # self-contained styles (no Tailwind), design tokens
32
+ shell/ # GENERIC kernel/shell adapter on @astrale-os/shell
33
+ client.ts # prop readers (PROP, readProp, readPropBySuffix) + errors
34
+ invoke.ts # callMethod / invokeNode (@<id>::<method>) / nodeAddr
35
+ use-shell.ts # useShell() createShell({ mode: 'sandboxed' })
36
+ use-node.ts # useNode(session, id) @<id>::get (+ reload)
37
+ use-async.ts # generic reloadable async resource
38
+ use-capability.ts # write lifecycle (idle → running → done/failed)
39
+ transformers.ts # qualified-prop / link / node-array shaping
40
+ view-router.tsx # resolveView(routes) + ViewFrame (handshake gate)
41
+ index.ts # barrel → @/shell
42
+ ui/ # PURE presentation — no kernel, no hooks
43
+ surface.tsx # Panel / ErrorBanner / EmptyState / Spinner
44
+ badge.tsx # StatusBadge (up | down | unknown)
45
+ value.tsx # KV / Mono / ExternalLink
46
+ format.ts # relativeTime
47
+ index.ts # barrel → @/ui
48
+ monitor/ # THE Monitor feature (template example)
49
+ monitor.types.ts # MonitorRecord
50
+ monitor.api.ts # check(session, id) → @<id>::check
51
+ monitor.mappers.ts # monitorFromNode (node → record)
52
+ hooks/ # useMonitor.query / useCheck.mutation
53
+ components/ # MonitorCard (container: hooks + @/ui)
54
+ index.ts # barrel → @/monitor
55
+ views/
56
+ monitor.tsx # MonitorView = <ViewFrame>…<MonitorCard/></ViewFrame>
32
57
  __tests__/
33
- harness.ts # fake shell parent + fake kernel (fetch stub)
34
- app.test.tsx # handshake → render → setTarget → tokenRefresh
35
- kernel.test.ts # kernelCall wire shape + prop readers
58
+ harness.ts # fake shell parent (real protocol) + fake kernel (fetch stub)
59
+ app.test.tsx # handshake → render → check now → setTarget → fallback
60
+ seam.test.tsx # pure @/ui + @/monitor hooks in isolation (no handshake)
61
+ kernel.test.ts # pure helpers: prop readers, mapper, relativeTime
36
62
  ```
37
63
 
38
64
  ## Dev loops
@@ -40,7 +66,7 @@ __tests__/
40
66
  ```bash
41
67
  pnpm dev # vite build --watch → ../.dist/ (worker auto-reloads, no HMR)
42
68
  pnpm dev:hmr # vite dev on http://127.0.0.1:5173/ (React fast-refresh)
43
- pnpm test # vitest run (happy-dom; fake parent + fake kernel, JSON-only)
69
+ pnpm test # vitest run (happy-dom; fake parent speaks the real shell protocol)
44
70
  ```
45
71
 
46
72
  For HMR, set `VIEW_DEV_URL=http://127.0.0.1:5173` in the worker's `.dev.vars`;
@@ -48,38 +74,29 @@ the generated worker forwards `/ui/*` to vite.
48
74
 
49
75
  ## How it connects
50
76
 
51
- 1. The domain declares the View in `../views/note.ts` with `mount: '/ui/note'`.
52
- The SDK points the View node's iframe at `<serving url>/ui/note`.
53
- 2. The shell mounts that iframe and completes the handshake: it transfers a
54
- `MessagePort` and sends a `ctrl:handshake` carrying `kernelUrl`, a
55
- `delegationToken` (+ `tokenExpiresAt`), and the `targetNodeId`. `useShell()`
56
- surfaces a live session and tracks `setTarget` hot-swaps.
57
- 3. `useNode(session, nodeId)` POSTs `@<id>::get` to `kernelUrl` with the
58
- delegation token as `authorization` and renders the Note's `title`/`body`
59
- (props are read by key suffix, since the domain origin is unknown at build
60
- time). The iframe authenticates with the handshake token ONLY the parent
61
- minted it for this kernel, so the audience already matches (no cookie, no
62
- whoami, no client-side minting).
63
-
64
- ### Token refresh
65
-
66
- The parent pushes a fresh credential over the port as `ctrl:tokenRefresh`
67
- before the current one expires. `shell.ts` swaps it into a mutable `currentToken`
68
- so the next `kernelCall` (e.g. on the next `setTarget` or remount) uses it —
69
- `getToken()` always returns the live token, never the one captured at handshake.
70
-
71
- ## Deferred kept minimal on purpose
72
-
73
- The inline kernel client is **JSON-only** and read-only for this template:
74
-
75
- - no msgpack codec (the `application/vnd.astrale.kernel+msgpack` body),
76
- - no streaming (`output: 'stream'`) or binary (`output: 'binary'`) responses,
77
- - no redirect following — a `{ redirect }` envelope (remote-domain Functions)
78
- throws rather than re-minting against the target worker,
79
- - no client-side credential minting / `whoami` (the handshake token is used
80
- as-is), and
81
- - no writes — only `@<id>::get`.
82
-
83
- These all live in `@astrale-os/kernel-client` / `@astrale-os/shell`. To grow
84
- past the template, pull those in (and `link:` them in dev) instead of extending
85
- the inline subset.
77
+ 1. The domain declares each View in `../views/` with `mount: '/ui/<path>'`. The
78
+ SDK points the View node's iframe at `<serving url>/ui/<path>`.
79
+ 2. The shell mounts that iframe and completes the handshake (a transferred
80
+ `MessagePort` + a `ctrl:handshake` carrying `kernelUrl`, the delegation token,
81
+ and the `targetNodeId`). `useShell()` boots `@astrale-os/shell`, surfaces the
82
+ kernel session, and tracks `setTarget` hot-swaps.
83
+ 3. A feature loads its node through the session: `useNode(session, nodeId)` calls
84
+ `@<id>::get` over `shell.kernel`, and a mapper (`monitorFromNode`) projects it
85
+ to a typed record. Domain props are read by key SUFFIX (`.property.url`, …)
86
+ because the domain origin is unknown at build time; the name uses the kernel
87
+ `Named.name` key.
88
+ 4. Writes go through `useCapability`: "Check now" runs `@<id>::check`, then calls
89
+ `useMonitor`'s `reload()` so the fresh status/latency re-render. The shell's
90
+ kernel client owns the credential and refreshes it before expiry — features
91
+ never touch the token.
92
+
93
+ ## Adding a view
94
+
95
+ 1. Write a `ViewComponent` (usually wrapping `ViewFrame`) under `src/views/`.
96
+ 2. Add a `ROUTES` entry keyed by its mount path in `src/app.tsx`.
97
+ 3. Register a matching `defineView({ mount: '/ui/<path>' })` in the domain's
98
+ `views/` so the shell mounts an iframe there.
99
+
100
+ For a NEW feature, mirror `src/monitor/`: a `*.types.ts`, `*.api.ts`,
101
+ `*.mappers.ts`, a `hooks/` folder of `.query`/`.mutation` hooks, and a
102
+ `components/` folder of containers that compose `@/ui`.
@@ -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()