@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.
- package/package.json +1 -1
- package/template/.agents/skills/astrale-cli/SKILL.md +1 -1
- package/template/.agents/skills/astrale-domain/SKILL.md +5 -5
- package/template/.env.example +5 -7
- package/template/README.md +2 -2
- package/template/client/README.md +79 -62
- package/template/client/__tests__/app.test.tsx +188 -99
- package/template/client/__tests__/harness.ts +67 -12
- package/template/client/__tests__/kernel.test.ts +65 -50
- package/template/client/__tests__/seam.test.tsx +111 -0
- package/template/client/index.html +1 -1
- package/template/client/package.json +1 -0
- package/template/client/src/app.tsx +40 -83
- package/template/client/src/main.tsx +2 -2
- package/template/client/src/monitor/components/MonitorCard.tsx +50 -0
- package/template/client/src/monitor/components/index.ts +1 -0
- package/template/client/src/monitor/hooks/index.ts +3 -0
- package/template/client/src/monitor/hooks/useCheck.mutation.ts +16 -0
- package/template/client/src/monitor/hooks/useMonitor.query.ts +64 -0
- package/template/client/src/monitor/index.ts +6 -0
- package/template/client/src/monitor/monitor.api.ts +11 -0
- package/template/client/src/monitor/monitor.mappers.ts +38 -0
- package/template/client/src/monitor/monitor.types.ts +23 -0
- package/template/client/src/monitor/ui/MonitorDetails.UI.tsx +38 -0
- package/template/client/src/monitor/ui/StatusBadge.UI.tsx +14 -0
- package/template/client/src/monitor/ui/index.ts +8 -0
- package/template/client/src/shell/client.ts +67 -0
- package/template/client/src/shell/index.ts +20 -0
- package/template/client/src/shell/invoke.ts +35 -0
- package/template/client/src/shell/transformers.ts +72 -0
- package/template/client/src/shell/use-async.ts +56 -0
- package/template/client/src/shell/use-capability.ts +61 -0
- package/template/client/src/shell/use-node.ts +61 -0
- package/template/client/src/shell/use-shell.ts +91 -0
- package/template/client/src/shell/view-router.tsx +98 -0
- package/template/client/src/styles.css +177 -4
- package/template/client/src/ui/format.ts +24 -0
- package/template/client/src/ui/index.ts +9 -0
- package/template/client/src/ui/surface.tsx +56 -0
- package/template/client/src/ui/value.tsx +32 -0
- package/template/client/src/views/monitor.tsx +30 -0
- package/template/client/tsconfig.json +2 -1
- package/template/client/vite.config.ts +12 -13
- package/template/client/vitest.config.ts +12 -5
- package/template/core/monitor/health.ts +19 -0
- package/template/core/monitor/index.ts +9 -0
- package/template/core/monitor/keys.ts +29 -0
- package/template/core/monitor/node.ts +51 -0
- package/template/deps.ts +8 -8
- package/template/domain.ts +1 -1
- package/template/env.ts +2 -9
- package/template/integrations/prober/http.ts +43 -0
- package/template/integrations/prober/mock.ts +22 -0
- package/template/integrations/prober/port.ts +28 -0
- package/template/integrations/prober/registry.ts +66 -0
- package/template/package.json +1 -1
- package/template/pnpm-lock.yaml +2766 -0
- package/template/runtime/index.ts +36 -19
- package/template/runtime/monitor/check.ts +29 -0
- package/template/runtime/monitor/dependsOn.ts +16 -0
- package/template/runtime/monitor/index.ts +12 -0
- package/template/runtime/monitor/seed.ts +74 -0
- package/template/runtime/monitor/shared.ts +17 -0
- package/template/runtime/monitor/watch.ts +37 -0
- package/template/schema/index.ts +11 -4
- package/template/schema/monitor.ts +80 -0
- package/template/views/index.ts +9 -2
- package/template/views/monitor.ts +22 -0
- package/template/client/src/lib/kernel.ts +0 -135
- package/template/client/src/lib/shell.ts +0 -197
- package/template/client/src/lib/use-node.ts +0 -66
- package/template/client/src/lib/use-shell.ts +0 -85
- package/template/core/keys.ts +0 -28
- package/template/core/note.ts +0 -148
- package/template/integrations/summary/heuristic.ts +0 -25
- package/template/integrations/summary/http.ts +0 -69
- package/template/integrations/summary/port.ts +0 -21
- package/template/integrations/summary/registry.ts +0 -52
- package/template/schema/note.ts +0 -67
- package/template/views/note.ts +0 -21
package/package.json
CHANGED
|
@@ -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.
|
|
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
|
-
# +
|
|
46
|
-
core
|
|
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.
|
|
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
|
-
`
|
|
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.
|
|
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:
|
package/template/.env.example
CHANGED
|
@@ -5,10 +5,8 @@
|
|
|
5
5
|
#
|
|
6
6
|
# EXAMPLE_API_KEY=sk-...
|
|
7
7
|
#
|
|
8
|
-
# ──
|
|
9
|
-
# The scaffold
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
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
|
package/template/README.md
CHANGED
|
@@ -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
|
|
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 `
|
|
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
|
|
4
|
-
|
|
5
|
-
its target node id and a
|
|
6
|
-
and renders
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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 #
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
use-
|
|
31
|
-
|
|
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 →
|
|
35
|
-
|
|
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
|
|
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
|
|
52
|
-
|
|
53
|
-
2. The shell mounts that iframe and completes the handshake
|
|
54
|
-
`MessagePort`
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
3. `useNode(session, nodeId)`
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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 '
|
|
5
|
-
import {
|
|
4
|
+
import { App } from '@/app'
|
|
5
|
+
import { installFakeKernel, installFakeParent, monitorNode, ok } from './harness'
|
|
6
6
|
|
|
7
7
|
const KERNEL_URL = 'https://k.example.test/api'
|
|
8
|
-
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The router picks a view from `window.location.pathname`, so every test mounts
|
|
11
|
+
* at a known path. `replaceState` rewrites the happy-dom location without a
|
|
12
|
+
* reload. Default to the Monitor detail path; the fallback test overrides.
|
|
13
|
+
*/
|
|
14
|
+
function mountAt(path: string) {
|
|
15
|
+
window.history.replaceState({}, '', path)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
mountAt('/ui/monitor')
|
|
20
|
+
})
|
|
9
21
|
|
|
10
22
|
afterEach(() => {
|
|
11
23
|
cleanup()
|
|
12
24
|
})
|
|
13
25
|
|
|
14
26
|
/**
|
|
15
|
-
* Let the handshake + any kernel
|
|
27
|
+
* Let the handshake + any kernel fetches settle, flushing the resulting React
|
|
16
28
|
* state updates inside `act`. The handshake completes over a real MessagePort
|
|
17
|
-
* (async hops), so a microtask isn't enough — a short timer pump is.
|
|
18
|
-
* it in `act` keeps the updates warning-free and committed before we assert.
|
|
29
|
+
* (async hops), so a microtask isn't enough — a short timer pump is.
|
|
19
30
|
*/
|
|
20
31
|
async function flush(ms = 20) {
|
|
21
32
|
await act(async () => {
|
|
@@ -23,35 +34,36 @@ async function flush(ms = 20) {
|
|
|
23
34
|
})
|
|
24
35
|
}
|
|
25
36
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
expect(req.headers['accept']).toBe(KERNEL_TYPE)
|
|
36
|
-
// Envelope body shape.
|
|
37
|
-
expect(req.body.method).toBe(`@${nodeId}::get`)
|
|
38
|
-
expect(req.body.params).toEqual({})
|
|
39
|
-
expect(req.body.id).toBeTruthy()
|
|
37
|
+
function handshake(targetNodeId?: string) {
|
|
38
|
+
return installFakeParent({
|
|
39
|
+
windowId: 'win-1',
|
|
40
|
+
kernelUrl: KERNEL_URL,
|
|
41
|
+
functionId: 'ui-monitor',
|
|
42
|
+
delegationToken: 'tok-A',
|
|
43
|
+
tokenExpiresAt: Date.now() + 3_600_000,
|
|
44
|
+
targetNodeId,
|
|
45
|
+
})
|
|
40
46
|
}
|
|
41
47
|
|
|
42
|
-
describe('
|
|
43
|
-
it('
|
|
44
|
-
const parent =
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
describe('monitor view (/ui/monitor)', () => {
|
|
49
|
+
it('loads the target node and renders name, url, status and latency', async () => {
|
|
50
|
+
const parent = handshake('mon-1')
|
|
51
|
+
const kernel = installFakeKernel((body) => {
|
|
52
|
+
if (body.method === '@mon-1::get') {
|
|
53
|
+
return ok(
|
|
54
|
+
monitorNode({
|
|
55
|
+
id: 'mon-1',
|
|
56
|
+
name: 'API health',
|
|
57
|
+
url: 'https://api.example.test/health',
|
|
58
|
+
status: 'up',
|
|
59
|
+
statusCode: '200',
|
|
60
|
+
latencyMs: '42',
|
|
61
|
+
lastCheckedAt: '2026-06-14T10:00:00Z',
|
|
62
|
+
}),
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
return { error: { code: 3002, message: `unexpected: ${body.method}` } }
|
|
51
66
|
})
|
|
52
|
-
const kernel = installFakeKernel(() =>
|
|
53
|
-
ok(noteNode({ id: 'note-1', name: 'My first note', body: 'Hello from the kernel.' })),
|
|
54
|
-
)
|
|
55
67
|
|
|
56
68
|
try {
|
|
57
69
|
render(<App />)
|
|
@@ -60,113 +72,176 @@ describe('ui-note view — handshake + real node render', () => {
|
|
|
60
72
|
// Parent observed the child's handshakeAck.
|
|
61
73
|
await expect(parent.ack).resolves.toBeUndefined()
|
|
62
74
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
expect(screen.getByText('
|
|
75
|
+
expect(screen.getByText('API health')).toBeTruthy()
|
|
76
|
+
// ExternalLink strips the scheme.
|
|
77
|
+
expect(screen.getByText('api.example.test/health')).toBeTruthy()
|
|
78
|
+
const badge = screen.getByText('UP')
|
|
79
|
+
expect(badge.className).toContain('status-up')
|
|
80
|
+
expect(screen.getByText('42ms')).toBeTruthy()
|
|
81
|
+
expect(screen.getByText('200')).toBeTruthy()
|
|
66
82
|
|
|
67
|
-
// Exactly one kernel call,
|
|
83
|
+
// Exactly one kernel call, for the node load.
|
|
68
84
|
expect(kernel.calls).toHaveLength(1)
|
|
69
|
-
|
|
85
|
+
expect(kernel.calls[0]!.body.method).toBe('@mon-1::get')
|
|
70
86
|
} finally {
|
|
71
87
|
kernel.restore()
|
|
72
88
|
parent.restore()
|
|
73
89
|
}
|
|
74
90
|
})
|
|
75
91
|
|
|
76
|
-
it('renders
|
|
77
|
-
const parent =
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
delegationToken: 'tok-A',
|
|
82
|
-
tokenExpiresAt: Date.now() + 3_600_000,
|
|
83
|
-
targetNodeId: 'missing-1',
|
|
84
|
-
})
|
|
85
|
-
const kernel = installFakeKernel(() => ({
|
|
86
|
-
error: { code: 3002, message: 'Path not found' },
|
|
87
|
-
}))
|
|
92
|
+
it('renders a DOWN badge when the monitor status is down', async () => {
|
|
93
|
+
const parent = handshake('mon-d')
|
|
94
|
+
const kernel = installFakeKernel(() =>
|
|
95
|
+
ok(monitorNode({ id: 'mon-d', name: 'Down site', url: 'https://x.test', status: 'down' })),
|
|
96
|
+
)
|
|
88
97
|
|
|
89
98
|
try {
|
|
90
99
|
render(<App />)
|
|
91
100
|
await flush()
|
|
92
101
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
expect(
|
|
96
|
-
expect(kernel.calls).toHaveLength(1)
|
|
102
|
+
const badge = screen.getByText('DOWN')
|
|
103
|
+
expect(badge).toBeTruthy()
|
|
104
|
+
expect(badge.className).toContain('status-down')
|
|
97
105
|
} finally {
|
|
98
106
|
kernel.restore()
|
|
99
107
|
parent.restore()
|
|
100
108
|
}
|
|
101
109
|
})
|
|
102
110
|
|
|
103
|
-
it('
|
|
104
|
-
const parent =
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
+
it('clicks "Check now", issues @<id>::check, and reflects the refreshed status', async () => {
|
|
112
|
+
const parent = handshake('mon-1')
|
|
113
|
+
// First ::get is down; ::check mutates server-side; the post-check ::get is up.
|
|
114
|
+
let checked = false
|
|
115
|
+
const kernel = installFakeKernel((body) => {
|
|
116
|
+
if (body.method === '@mon-1::check') {
|
|
117
|
+
checked = true
|
|
118
|
+
return ok(null)
|
|
119
|
+
}
|
|
120
|
+
// @mon-1::get — reflect the pre/post-check status.
|
|
121
|
+
return ok(
|
|
122
|
+
monitorNode({
|
|
123
|
+
id: 'mon-1',
|
|
124
|
+
name: 'API health',
|
|
125
|
+
url: 'https://api.example.test/health',
|
|
126
|
+
status: checked ? 'up' : 'down',
|
|
127
|
+
statusCode: checked ? '200' : '503',
|
|
128
|
+
}),
|
|
129
|
+
)
|
|
111
130
|
})
|
|
112
|
-
const kernel = installFakeKernel((body) =>
|
|
113
|
-
// Respond per-target so the swap is observable.
|
|
114
|
-
body.method === '@note-2::get'
|
|
115
|
-
? ok(noteNode({ id: 'note-2', name: 'Second note', body: 'Swapped in.' }))
|
|
116
|
-
: ok(noteNode({ id: 'note-1', name: 'First note', body: 'Original.' })),
|
|
117
|
-
)
|
|
118
131
|
|
|
119
132
|
try {
|
|
120
133
|
render(<App />)
|
|
121
134
|
await flush()
|
|
122
|
-
|
|
135
|
+
|
|
136
|
+
// Initial load: DOWN.
|
|
137
|
+
expect(screen.getByText('DOWN')).toBeTruthy()
|
|
123
138
|
expect(kernel.calls).toHaveLength(1)
|
|
139
|
+
expect(kernel.calls[0]!.body.method).toBe('@mon-1::get')
|
|
124
140
|
|
|
125
|
-
//
|
|
126
|
-
|
|
141
|
+
// Click "Check now".
|
|
142
|
+
await act(async () => {
|
|
143
|
+
fireEvent.click(screen.getByText('Check now'))
|
|
144
|
+
})
|
|
127
145
|
await flush()
|
|
128
146
|
|
|
129
|
-
|
|
130
|
-
expect(
|
|
147
|
+
// A ::check call was issued, then the node was re-fetched.
|
|
148
|
+
expect(kernel.calls.map((c) => c.body.method)).toEqual([
|
|
149
|
+
'@mon-1::get',
|
|
150
|
+
'@mon-1::check',
|
|
151
|
+
'@mon-1::get',
|
|
152
|
+
])
|
|
131
153
|
|
|
132
|
-
//
|
|
133
|
-
expect(
|
|
134
|
-
|
|
154
|
+
// The refreshed status renders.
|
|
155
|
+
expect(screen.getByText('UP')).toBeTruthy()
|
|
156
|
+
expect(screen.getByText('200')).toBeTruthy()
|
|
135
157
|
} finally {
|
|
136
158
|
kernel.restore()
|
|
137
159
|
parent.restore()
|
|
138
160
|
}
|
|
139
161
|
})
|
|
140
162
|
|
|
141
|
-
it('
|
|
142
|
-
const parent =
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
targetNodeId: 'note-1',
|
|
163
|
+
it('surfaces a check failure without losing the loaded record', async () => {
|
|
164
|
+
const parent = handshake('mon-1')
|
|
165
|
+
const kernel = installFakeKernel((body) => {
|
|
166
|
+
if (body.method === '@mon-1::check') {
|
|
167
|
+
return { error: { code: 2004, message: 'Permission denied' } }
|
|
168
|
+
}
|
|
169
|
+
return ok(monitorNode({ id: 'mon-1', name: 'API health', url: 'https://x.test', status: 'up' }))
|
|
149
170
|
})
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
render(<App />)
|
|
174
|
+
await flush()
|
|
175
|
+
|
|
176
|
+
await act(async () => {
|
|
177
|
+
fireEvent.click(screen.getByText('Check now'))
|
|
178
|
+
})
|
|
179
|
+
await flush()
|
|
180
|
+
|
|
181
|
+
// The proper client maps the error code to a typed error, so the operator
|
|
182
|
+
// sees the clean server message (no `<code>:` prefix).
|
|
183
|
+
expect(screen.getByText(/Check failed:/)).toBeTruthy()
|
|
184
|
+
expect(screen.getByText(/Permission denied/)).toBeTruthy()
|
|
185
|
+
// The record is still shown.
|
|
186
|
+
expect(screen.getByText('API health')).toBeTruthy()
|
|
187
|
+
} finally {
|
|
188
|
+
kernel.restore()
|
|
189
|
+
parent.restore()
|
|
190
|
+
}
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('renders the kernel error on the node-load error path', async () => {
|
|
194
|
+
const parent = handshake('missing-1')
|
|
195
|
+
const kernel = installFakeKernel(() => ({ error: { code: 3002, message: 'Path not found' } }))
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
render(<App />)
|
|
199
|
+
await flush()
|
|
200
|
+
|
|
201
|
+
expect(screen.getByText(/Failed to load the Monitor/)).toBeTruthy()
|
|
202
|
+
// The proper client maps NOT_FOUND (3002) to a typed error → clean message.
|
|
203
|
+
expect(screen.getByText(/Path not found/)).toBeTruthy()
|
|
204
|
+
} finally {
|
|
205
|
+
kernel.restore()
|
|
206
|
+
parent.restore()
|
|
207
|
+
}
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('re-fetches on a setTarget hot-swap', async () => {
|
|
211
|
+
const parent = handshake('mon-1')
|
|
150
212
|
const kernel = installFakeKernel((body) =>
|
|
151
|
-
|
|
213
|
+
body.method === '@mon-2::get'
|
|
214
|
+
? ok(monitorNode({ id: 'mon-2', name: 'Second monitor', url: 'https://b.test', status: 'down' }))
|
|
215
|
+
: ok(monitorNode({ id: 'mon-1', name: 'First monitor', url: 'https://a.test', status: 'up' })),
|
|
152
216
|
)
|
|
153
217
|
|
|
154
218
|
try {
|
|
155
219
|
render(<App />)
|
|
156
220
|
await flush()
|
|
157
|
-
expect(
|
|
158
|
-
|
|
159
|
-
|
|
221
|
+
expect(screen.getByText('First monitor')).toBeTruthy()
|
|
222
|
+
expect(screen.getByText('UP')).toBeTruthy()
|
|
223
|
+
|
|
224
|
+
// Parent pushes a new target node.
|
|
225
|
+
parent.setTarget('mon-2')
|
|
226
|
+
await flush()
|
|
227
|
+
|
|
228
|
+
expect(screen.getByText('Second monitor')).toBeTruthy()
|
|
229
|
+
expect(screen.getByText('DOWN')).toBeTruthy()
|
|
230
|
+
} finally {
|
|
231
|
+
kernel.restore()
|
|
232
|
+
parent.restore()
|
|
233
|
+
}
|
|
234
|
+
})
|
|
160
235
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
236
|
+
it('explains a missing target instead of a blank screen', async () => {
|
|
237
|
+
const parent = handshake(undefined)
|
|
238
|
+
const kernel = installFakeKernel(() => ok(null))
|
|
239
|
+
try {
|
|
240
|
+
render(<App />)
|
|
164
241
|
await flush()
|
|
165
242
|
|
|
166
|
-
|
|
167
|
-
expect(kernel.calls).toHaveLength(
|
|
168
|
-
expect(kernel.calls[1]!.body.method).toBe('@note-9::get')
|
|
169
|
-
expect(kernel.calls[1]!.headers['authorization']).toBe('tok-B')
|
|
243
|
+
expect(screen.getByText(/No target Monitor/)).toBeTruthy()
|
|
244
|
+
expect(kernel.calls).toHaveLength(0)
|
|
170
245
|
} finally {
|
|
171
246
|
kernel.restore()
|
|
172
247
|
parent.restore()
|
|
@@ -174,15 +249,29 @@ describe('ui-note view — handshake + real node render', () => {
|
|
|
174
249
|
})
|
|
175
250
|
|
|
176
251
|
it('falls back to a standalone preview with no parent', async () => {
|
|
177
|
-
// No fake parent
|
|
178
|
-
// window.parent === window in this realm.
|
|
252
|
+
// No fake parent → the shell sees window.parent === window and stands alone.
|
|
179
253
|
const kernel = installFakeKernel(() => ok(null))
|
|
180
254
|
try {
|
|
181
255
|
render(<App />)
|
|
182
256
|
await flush()
|
|
183
257
|
|
|
184
258
|
expect(screen.getByText(/No parent shell/)).toBeTruthy()
|
|
185
|
-
|
|
259
|
+
expect(kernel.calls).toHaveLength(0)
|
|
260
|
+
} finally {
|
|
261
|
+
kernel.restore()
|
|
262
|
+
}
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
describe('router fallback', () => {
|
|
267
|
+
it('renders "No view registered" for an unregistered path', async () => {
|
|
268
|
+
mountAt('/ui/nope')
|
|
269
|
+
const kernel = installFakeKernel(() => ok(null))
|
|
270
|
+
try {
|
|
271
|
+
render(<App />)
|
|
272
|
+
await flush()
|
|
273
|
+
|
|
274
|
+
expect(screen.getByText(/No view registered for \/ui\/nope/)).toBeTruthy()
|
|
186
275
|
expect(kernel.calls).toHaveLength(0)
|
|
187
276
|
} finally {
|
|
188
277
|
kernel.restore()
|