@astrale-os/adapter-cloudflare 0.1.9 → 0.2.0

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 (81) 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 +81 -62
  7. package/template/client/__tests__/app.test.tsx +143 -98
  8. package/template/client/__tests__/harness.ts +62 -12
  9. package/template/client/__tests__/kernel.test.ts +40 -51
  10. package/template/client/__tests__/seam.test.tsx +115 -0
  11. package/template/client/index.html +1 -1
  12. package/template/client/package.json +1 -0
  13. package/template/client/src/app.tsx +34 -83
  14. package/template/client/src/main.tsx +2 -2
  15. package/template/client/src/shell/client.ts +67 -0
  16. package/template/client/src/shell/index.ts +20 -0
  17. package/template/client/src/shell/invoke.ts +35 -0
  18. package/template/client/src/shell/transformers.ts +72 -0
  19. package/template/client/src/shell/use-async.ts +56 -0
  20. package/template/client/src/shell/use-capability.ts +59 -0
  21. package/template/client/src/shell/use-node.ts +61 -0
  22. package/template/client/src/shell/use-shell.ts +91 -0
  23. package/template/client/src/shell/view-router.tsx +97 -0
  24. package/template/client/src/status/components/StatusCard.tsx +50 -0
  25. package/template/client/src/status/components/index.ts +1 -0
  26. package/template/client/src/status/hooks/index.ts +3 -0
  27. package/template/client/src/status/hooks/useCheck.mutation.ts +16 -0
  28. package/template/client/src/status/hooks/useCheckable.query.ts +64 -0
  29. package/template/client/src/status/index.ts +7 -0
  30. package/template/client/src/status/status.api.ts +12 -0
  31. package/template/client/src/status/status.mappers.ts +19 -0
  32. package/template/client/src/status/status.types.ts +11 -0
  33. package/template/client/src/styles.css +182 -4
  34. package/template/client/src/ui/StatusBadge.tsx +31 -0
  35. package/template/client/src/ui/format.ts +24 -0
  36. package/template/client/src/ui/index.ts +13 -0
  37. package/template/client/src/ui/surface.tsx +56 -0
  38. package/template/client/src/ui/value.tsx +32 -0
  39. package/template/client/src/views/status.tsx +28 -0
  40. package/template/client/tsconfig.json +2 -1
  41. package/template/client/vite.config.ts +11 -13
  42. package/template/client/vitest.config.ts +11 -5
  43. package/template/core/monitor/health.ts +34 -0
  44. package/template/core/monitor/index.ts +9 -0
  45. package/template/core/monitor/keys.ts +41 -0
  46. package/template/core/monitor/node.ts +57 -0
  47. package/template/deps.ts +10 -9
  48. package/template/domain.ts +1 -1
  49. package/template/env.ts +2 -9
  50. package/template/integrations/prober/http.ts +32 -0
  51. package/template/integrations/prober/mock.ts +18 -0
  52. package/template/integrations/prober/port.ts +26 -0
  53. package/template/integrations/prober/registry.ts +65 -0
  54. package/template/package.json +1 -1
  55. package/template/pnpm-lock.yaml +2766 -0
  56. package/template/runtime/index.ts +63 -34
  57. package/template/runtime/monitor/check.ts +29 -0
  58. package/template/runtime/monitor/index.ts +9 -0
  59. package/template/runtime/monitor/seed.ts +95 -0
  60. package/template/runtime/monitor/watch.ts +31 -0
  61. package/template/runtime/shared.ts +21 -0
  62. package/template/runtime/status-page/add.ts +21 -0
  63. package/template/runtime/status-page/check.ts +50 -0
  64. package/template/runtime/status-page/create.ts +24 -0
  65. package/template/runtime/status-page/index.ts +8 -0
  66. package/template/schema/index.ts +11 -4
  67. package/template/schema/monitor.ts +94 -0
  68. package/template/views/index.ts +8 -2
  69. package/template/views/status-page.ts +16 -0
  70. package/template/client/src/lib/kernel.ts +0 -135
  71. package/template/client/src/lib/shell.ts +0 -197
  72. package/template/client/src/lib/use-node.ts +0 -66
  73. package/template/client/src/lib/use-shell.ts +0 -85
  74. package/template/core/keys.ts +0 -28
  75. package/template/core/note.ts +0 -148
  76. package/template/integrations/summary/heuristic.ts +0 -25
  77. package/template/integrations/summary/http.ts +0 -69
  78. package/template/integrations/summary/port.ts +0 -21
  79. package/template/integrations/summary/registry.ts +0 -52
  80. package/template/schema/note.ts +0 -67
  81. 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.2.0",
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:class.Monitor: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,66 @@
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 status view loads a
7
+ `Checkable` node (a Monitor or a StatusPage — both carry a `status` prop and a
8
+ `::check` method) and renders its status, with a "Check now" button that calls
9
+ the node's `check` instance method and reloads the node. Built into `../.dist/`
10
+ and served by the generated worker (`.astrale/`) under `/ui/*` via its `ASSETS`
11
+ binding.
12
+
13
+ ## Built on the real shell SDK
14
+
15
+ This client consumes `@astrale-os/shell` for the child handshake AND the kernel
16
+ client: `src/shell/use-shell.ts` boots `createShell({ mode: 'sandboxed' })`, and
17
+ feature hooks call kernel methods through `shell.kernel` (token refresh, codec
18
+ negotiation, redirect following, and delegation are all handled by the SDK — no
19
+ inline wire code). `@` is aliased to `src/` (mirrors `tsconfig` paths), so feature
20
+ code imports `@/shell`, `@/ui`, `@/status`.
19
21
 
20
22
  ## Layout
21
23
 
24
+ Feature-first: each feature owns its types/api/mappers/hooks/components. The
25
+ `shell/` and `ui/` folders are the generic, domain-neutral seams every feature
26
+ builds on. The template ships ONE feature, `status/`, over a `Checkable` node —
27
+ one card loads the node and exposes its status + a "Check now" action.
28
+
22
29
  ```
23
30
  src/
24
31
  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)
32
+ app.tsx # path router: ROUTES { '/ui/status-page': StatusView }
33
+ styles.css # self-contained styles (no Tailwind), design tokens
34
+ shell/ # GENERIC kernel/shell adapter on @astrale-os/shell
35
+ client.ts # prop readers (PROP, readProp, readPropBySuffix) + errors
36
+ invoke.ts # callMethod / invokeNode (@<id>::<method>) / nodeAddr
37
+ use-shell.ts # useShell() createShell({ mode: 'sandboxed' })
38
+ use-node.ts # useNode(session, id) @<id>::get (+ reload)
39
+ use-async.ts # generic reloadable async resource
40
+ use-capability.ts # write lifecycle (idle → running → done/failed)
41
+ transformers.ts # qualified-prop / link / node-array shaping
42
+ view-router.tsx # resolveView(routes) + ViewFrame (handshake gate)
43
+ index.ts # barrel → @/shell
44
+ ui/ # PURE presentation — no kernel, no hooks
45
+ surface.tsx # Panel / ErrorBanner / EmptyState / Spinner
46
+ StatusBadge.tsx # StatusBadge (up | degraded | down | unknown)
47
+ value.tsx # KV / Mono / ExternalLink
48
+ format.ts # relativeTime
49
+ index.ts # barrel → @/ui
50
+ status/ # THE status feature — polymorphic over Checkable nodes
51
+ status.types.ts # CheckableRecord
52
+ status.api.ts # check(session, id) → @<id>::check
53
+ status.mappers.ts # checkableFromNode (node → record)
54
+ hooks/ # useCheckable.query / useCheck.mutation
55
+ components/ # StatusCard (container: hooks + @/ui)
56
+ index.ts # barrel → @/status
57
+ views/
58
+ status.tsx # StatusView = <ViewFrame>…<StatusCard/></ViewFrame>
32
59
  __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
60
+ harness.ts # fake shell parent (real protocol) + fake kernel (fetch stub)
61
+ app.test.tsx # handshake → render → check now → setTarget → fallback
62
+ seam.test.tsx # pure @/ui + @/status hooks in isolation (no handshake)
63
+ kernel.test.ts # pure helpers: prop readers, mapper, relativeTime
36
64
  ```
37
65
 
38
66
  ## Dev loops
@@ -40,7 +68,7 @@ __tests__/
40
68
  ```bash
41
69
  pnpm dev # vite build --watch → ../.dist/ (worker auto-reloads, no HMR)
42
70
  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)
71
+ pnpm test # vitest run (happy-dom; fake parent speaks the real shell protocol)
44
72
  ```
45
73
 
46
74
  For HMR, set `VIEW_DEV_URL=http://127.0.0.1:5173` in the worker's `.dev.vars`;
@@ -48,38 +76,29 @@ the generated worker forwards `/ui/*` to vite.
48
76
 
49
77
  ## How it connects
50
78
 
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.
79
+ 1. The domain declares each View in `../views/` with `mount: '/ui/<path>'`. The
80
+ SDK points the View node's iframe at `<serving url>/ui/<path>`.
81
+ 2. The shell mounts that iframe and completes the handshake (a transferred
82
+ `MessagePort` + a `ctrl:handshake` carrying `kernelUrl`, the delegation token,
83
+ and the `targetNodeId`). `useShell()` boots `@astrale-os/shell`, surfaces the
84
+ kernel session, and tracks `setTarget` hot-swaps.
85
+ 3. A feature loads its node through the session: `useNode(session, nodeId)` calls
86
+ `@<id>::get` over `shell.kernel`, and a mapper (`checkableFromNode`) projects
87
+ it to a typed record. Domain props are read by key SUFFIX (`.property.status`,
88
+ ) because the domain origin is unknown at build time; the name uses the kernel
89
+ `Named.name` key.
90
+ 4. Writes go through `useCapability`: "Check now" runs `@<id>::check`, then calls
91
+ `useCheckable`'s `reload()` so the fresh status re-renders. The shell's kernel
92
+ client owns the credential and refreshes it before expiry — features never
93
+ touch the token.
94
+
95
+ ## Adding a view
96
+
97
+ 1. Write a `ViewComponent` (usually wrapping `ViewFrame`) under `src/views/`.
98
+ 2. Add a `ROUTES` entry keyed by its mount path in `src/app.tsx`.
99
+ 3. Register a matching `defineView({ mount: '/ui/<path>' })` in the domain's
100
+ `views/` so the shell mounts an iframe there.
101
+
102
+ For a NEW feature, mirror `src/status/`: a `*.types.ts`, `*.api.ts`,
103
+ `*.mappers.ts`, a `hooks/` folder of `.query`/`.mutation` hooks, and a
104
+ `components/` folder of containers that compose `@/ui`.
@@ -1,21 +1,33 @@
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
+
6
+ import { checkableNode, installFakeKernel, installFakeParent, ok } from './harness'
6
7
 
7
8
  const KERNEL_URL = 'https://k.example.test/api'
8
- const KERNEL_TYPE = 'application/vnd.astrale.kernel+json'
9
+
10
+ /**
11
+ * The router picks a view from `window.location.pathname`, so every test mounts
12
+ * at the StatusPage view's path. `replaceState` rewrites the happy-dom location
13
+ * without a reload; the fallback test overrides it.
14
+ */
15
+ function mountAt(path: string) {
16
+ window.history.replaceState({}, '', path)
17
+ }
18
+
19
+ beforeEach(() => {
20
+ mountAt('/ui/status-page')
21
+ })
9
22
 
10
23
  afterEach(() => {
11
24
  cleanup()
12
25
  })
13
26
 
14
27
  /**
15
- * Let the handshake + any kernel fetch settle, flushing the resulting React
28
+ * Let the handshake + any kernel fetches settle, flushing the resulting React
16
29
  * 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.
30
+ * (async hops), so a microtask isn't enough — a short timer pump is.
19
31
  */
20
32
  async function flush(ms = 20) {
21
33
  await act(async () => {
@@ -23,35 +35,28 @@ async function flush(ms = 20) {
23
35
  })
24
36
  }
25
37
 
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()
38
+ function handshake(targetNodeId?: string) {
39
+ return installFakeParent({
40
+ windowId: 'win-1',
41
+ kernelUrl: KERNEL_URL,
42
+ functionId: 'ui-status-page',
43
+ delegationToken: 'tok-A',
44
+ tokenExpiresAt: Date.now() + 3_600_000,
45
+ targetNodeId,
46
+ })
40
47
  }
41
48
 
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',
49
+ /** A StatusPage node fixture its rolled-up `status`, no `url`. */
50
+ const page = (id: string, status: string, name = 'Public status') =>
51
+ checkableNode({ id, name, status, className: 'StatusPage' })
52
+
53
+ describe('status view (/ui/status-page)', () => {
54
+ it('loads the StatusPage node and renders its name + rolled-up status', async () => {
55
+ const parent = handshake('page-1')
56
+ const kernel = installFakeKernel((body) => {
57
+ if (body.method === '@page-1::get') return ok(page('page-1', 'degraded'))
58
+ return { error: { code: 3002, message: `unexpected: ${body.method}` } }
51
59
  })
52
- const kernel = installFakeKernel(() =>
53
- ok(noteNode({ id: 'note-1', name: 'My first note', body: 'Hello from the kernel.' })),
54
- )
55
60
 
56
61
  try {
57
62
  render(<App />)
@@ -60,113 +65,139 @@ describe('ui-note view — handshake + real node render', () => {
60
65
  // Parent observed the child's handshakeAck.
61
66
  await expect(parent.ack).resolves.toBeUndefined()
62
67
 
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()
68
+ expect(screen.getByText('Public status')).toBeTruthy()
69
+ const badge = screen.getByText('DEGRADED')
70
+ expect(badge.className).toContain('status-degraded')
71
+ // A StatusPage carries no `url`, so there's no url row.
72
+ expect(screen.queryByText('url')).toBeNull()
66
73
 
67
- // Exactly one kernel call, with the verified wire shape.
74
+ // Exactly one kernel call, for the node load.
68
75
  expect(kernel.calls).toHaveLength(1)
69
- expectGetCall(kernel.calls[0]!, 'note-1', 'tok-A')
76
+ expect(kernel.calls[0]!.body.method).toBe('@page-1::get')
70
77
  } finally {
71
78
  kernel.restore()
72
79
  parent.restore()
73
80
  }
74
81
  })
75
82
 
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',
83
+ it('clicks "Check now", issues @<id>::check, and reflects the refreshed status', async () => {
84
+ const parent = handshake('page-1')
85
+ // First ::get is down; ::check rolls up server-side; the post-check ::get is up.
86
+ let checked = false
87
+ const kernel = installFakeKernel((body) => {
88
+ if (body.method === '@page-1::check') {
89
+ checked = true
90
+ return ok(null)
91
+ }
92
+ return ok(page('page-1', checked ? 'up' : 'down'))
84
93
  })
85
- const kernel = installFakeKernel(() => ({
86
- error: { code: 3002, message: 'Path not found' },
87
- }))
88
94
 
89
95
  try {
90
96
  render(<App />)
91
97
  await flush()
92
98
 
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()
99
+ expect(screen.getByText('DOWN')).toBeTruthy()
96
100
  expect(kernel.calls).toHaveLength(1)
101
+ expect(kernel.calls[0]!.body.method).toBe('@page-1::get')
102
+
103
+ await act(async () => {
104
+ fireEvent.click(screen.getByText('Check now'))
105
+ })
106
+ await flush()
107
+
108
+ // A ::check call was issued, then the node was re-fetched.
109
+ expect(kernel.calls.map((c) => c.body.method)).toEqual([
110
+ '@page-1::get',
111
+ '@page-1::check',
112
+ '@page-1::get',
113
+ ])
114
+ expect(screen.getByText('UP')).toBeTruthy()
97
115
  } finally {
98
116
  kernel.restore()
99
117
  parent.restore()
100
118
  }
101
119
  })
102
120
 
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
- })
121
+ it('surfaces a check failure without losing the loaded record', async () => {
122
+ const parent = handshake('page-1')
112
123
  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.' })),
124
+ body.method === '@page-1::check'
125
+ ? { error: { code: 2004, message: 'Permission denied' } }
126
+ : ok(page('page-1', 'up')),
117
127
  )
118
128
 
119
129
  try {
120
130
  render(<App />)
121
131
  await flush()
122
- expect(screen.getByText('First note')).toBeTruthy()
123
- expect(kernel.calls).toHaveLength(1)
124
132
 
125
- // Parent pushes a new target node.
126
- parent.setTarget('note-2')
133
+ await act(async () => {
134
+ fireEvent.click(screen.getByText('Check now'))
135
+ })
127
136
  await flush()
128
137
 
129
- expect(screen.getByText('Second note')).toBeTruthy()
130
- expect(screen.getByText('Swapped in.')).toBeTruthy()
138
+ // The proper client maps the error code to a typed error, so the operator
139
+ // sees the clean server message (no `<code>:` prefix).
140
+ expect(screen.getByText(/Check failed:/)).toBeTruthy()
141
+ expect(screen.getByText(/Permission denied/)).toBeTruthy()
142
+ // The record is still shown.
143
+ expect(screen.getByText('Public status')).toBeTruthy()
144
+ } finally {
145
+ kernel.restore()
146
+ parent.restore()
147
+ }
148
+ })
149
+
150
+ it('renders the kernel error on the node-load error path', async () => {
151
+ const parent = handshake('missing-1')
152
+ const kernel = installFakeKernel(() => ({ error: { code: 3002, message: 'Path not found' } }))
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
+ try {
155
+ render(<App />)
156
+ await flush()
157
+
158
+ expect(screen.getByText(/Failed to load/)).toBeTruthy()
159
+ expect(screen.getByText(/Path not found/)).toBeTruthy()
135
160
  } finally {
136
161
  kernel.restore()
137
162
  parent.restore()
138
163
  }
139
164
  })
140
165
 
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',
149
- })
166
+ it('re-fetches on a setTarget hot-swap', async () => {
167
+ const parent = handshake('page-1')
150
168
  const kernel = installFakeKernel((body) =>
151
- ok(noteNode({ id: body.method.slice(1, -5), name: 'Note', body: 'b' })),
169
+ body.method === '@page-2::get'
170
+ ? ok(page('page-2', 'down', 'Internal status'))
171
+ : ok(page('page-1', 'up', 'Public status')),
152
172
  )
153
173
 
154
174
  try {
155
175
  render(<App />)
156
176
  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')
177
+ expect(screen.getByText('Public status')).toBeTruthy()
178
+ expect(screen.getByText('UP')).toBeTruthy()
160
179
 
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')
180
+ // Parent pushes a new target node.
181
+ parent.setTarget('page-2')
164
182
  await flush()
165
183
 
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')
184
+ expect(screen.getByText('Internal status')).toBeTruthy()
185
+ expect(screen.getByText('DOWN')).toBeTruthy()
186
+ } finally {
187
+ kernel.restore()
188
+ parent.restore()
189
+ }
190
+ })
191
+
192
+ it('explains a missing target instead of a blank screen', async () => {
193
+ const parent = handshake(undefined)
194
+ const kernel = installFakeKernel(() => ok(null))
195
+ try {
196
+ render(<App />)
197
+ await flush()
198
+
199
+ expect(screen.getByText(/No target node/)).toBeTruthy()
200
+ expect(kernel.calls).toHaveLength(0)
170
201
  } finally {
171
202
  kernel.restore()
172
203
  parent.restore()
@@ -174,15 +205,29 @@ describe('ui-note view — handshake + real node render', () => {
174
205
  })
175
206
 
176
207
  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.
208
+ // No fake parent → the shell sees window.parent === window and stands alone.
179
209
  const kernel = installFakeKernel(() => ok(null))
180
210
  try {
181
211
  render(<App />)
182
212
  await flush()
183
213
 
184
214
  expect(screen.getByText(/No parent shell/)).toBeTruthy()
185
- // Never hit the kernel in standalone mode.
215
+ expect(kernel.calls).toHaveLength(0)
216
+ } finally {
217
+ kernel.restore()
218
+ }
219
+ })
220
+ })
221
+
222
+ describe('router fallback', () => {
223
+ it('renders "No view registered" for an unregistered path', async () => {
224
+ mountAt('/ui/nope')
225
+ const kernel = installFakeKernel(() => ok(null))
226
+ try {
227
+ render(<App />)
228
+ await flush()
229
+
230
+ expect(screen.getByText(/No view registered for \/ui\/nope/)).toBeTruthy()
186
231
  expect(kernel.calls).toHaveLength(0)
187
232
  } finally {
188
233
  kernel.restore()