diff --git a/.gitignore b/.gitignore index 7b25c60..053bbb7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Node node_modules/ packages/*/dist/ +examples/native-editor/dist/ # Go /bin/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c54f24d..b6acc9a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -62,7 +62,7 @@ the rule it protects. |---|---| | `task test` | Deterministic gateway, adapter, client, and shared-fixture coverage. | | `task e2e-wire` | Real Go SSE bytes consumed by the TypeScript client over HTTP. | -| `task e2e-browser` | Native `EventSource`, unbundled ESM and the native viewer example in Chromium. | +| `task e2e-browser` | Native `EventSource`, unbundled ESM and the native viewer and editor pages in Chromium. | | `task cluster-facts` | Record observed Kubernetes behavior for the supported cluster version. | | `task test-real-api` | Compose the save path and native viewing and editing against a real API server: gateway or host proxy, host endpoint and browser store. | | `task test-cluster` | Exercise the Kubernetes backend against a real API server. | diff --git a/Taskfile.yml b/Taskfile.yml index 59b41de..7a6c975 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -191,7 +191,7 @@ tasks: # and it cannot do that with no node_modules there. On a developer's machine that directory is # already populated by `task test`, which is exactly why this gap survived until the first CI run # on a clean checkout found it. - deps: [fixtures, build-client, _example-deps] + deps: [fixtures, build-client, build-native-editor, _example-deps] dir: examples/vanilla-browser cmds: # The only place the library's central promise is actually tested: that the published ESM @@ -325,6 +325,26 @@ tasks: # whose exports map pointed at a file that was not in it. - npm run --silent build + build-native-editor: + desc: "Build the library and compile the native editor page's controller and recipes into examples/native-editor/dist." + deps: [build-client] + sources: + - examples/native-editor/build.mjs + - examples/native-editor/editor.ts + - examples/editor-recipes/keepLocal.ts + - examples/editor-recipes/recoveryCopy.ts + generates: + - examples/native-editor/dist/editor.js + - examples/native-editor/dist/keepLocal.js + - examples/native-editor/dist/recoveryCopy.js + status: + - test -f examples/native-editor/dist/editor.js + - test -f examples/native-editor/dist/keepLocal.js + - test -f examples/native-editor/dist/recoveryCopy.js + cmds: + # Types stripped, library import renamed to the published package; see build.mjs. + - node examples/native-editor/build.mjs + pack-client: desc: "Pack the npm tarball and PROVE it contains every entry point the exports map advertises." deps: [build-client] @@ -365,7 +385,7 @@ tasks: cmds: # .task/ is Task's checksum store: what makes a task say "up to date". Remove it when one is wrong. - rm -rf .task - - rm -rf {{.CLIENT_DIR}}/node_modules {{.CLIENT_DIR}}/dist + - rm -rf {{.CLIENT_DIR}}/node_modules {{.CLIENT_DIR}}/dist examples/native-editor/dist - rm -rf examples/vanilla-browser/node_modules # Go's test cache is content-addressed and sound: a cached `ok` means those inputs really did # pass. Use `go test -count=1` to watch a suite run; `go clean -cache` buys nothing but minutes. diff --git a/docs/field-reports/third-our-identity.md b/docs/field-reports/third-our-identity.md index 822f752..7148238 100644 --- a/docs/field-reports/third-our-identity.md +++ b/docs/field-reports/third-our-identity.md @@ -139,8 +139,16 @@ capture and PATCH (409, guarded read, a second deliberate save, neither write lo refusing an unconditional patch and a machinery patch before they reach the API server, and an object replaced under the same name reported `unavailable` with the replacement untouched. -There is no browser page for native editing yet; the comparative frontend (order 6) is where one -belongs. +**Browser acceptance (2026-10-05).** The [example page](../../examples/native-editor/README.md#run-the-page) +edits one ConfigMap with the same editor, store and connector, built from `editor.ts` without a +second save controller or new library API. `examples/vanilla-browser/tests/native-editor.spec.ts` +(`task e2e-browser`) runs it in Chromium on both entry points, with the watch live throughout, against +a test server playing the host proxy. It covers a save and its echo, typing and focus during a save, +409 recovery and a second deliberate save, explicit conflict resolution, a lost response and a 502 +each settled by a GET before any further PATCH, an accepted write without an echo confirmed +explicitly, 403 and 422 refusals, multiline values, deletion and UID replacement with copy-out, and +disconnect cleanup. A manual run through `kubectl proxy` against the v1.36.4+k3s1 spike cluster +saved and echoed on both entry points and rendered a real 422. The comparative frontend (order 6) remains separate. ## Follow-ups after the viewer diff --git a/docs/glossary.md b/docs/glossary.md index a5b7140..e6d9a70 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -90,7 +90,8 @@ request. The host authorizes, validates and conditionally writes it. A **merge p editable changes; `null` means deletion. Never write a whole projected object back with PUT. **Version rejection** means a precondition failed, not necessarily a field disagreement. Current -recovery is a guarded projected read, review and another deliberate Save. A **reconciliation guard** +recovery is a guarded read from the store's own source (a projected GET for a gateway view, a native +GET through the host proxy for native objects), review and another deliberate Save. A **reconciliation guard** prevents a delayed read/response from overwriting newer watch state or crossing UID/snapshot recovery. **Dirty draft, accepted write and domain progress** are separate facts. Later typing survives Save; diff --git a/docs/migrating.md b/docs/migrating.md index 1ab1d77..c4977d2 100644 --- a/docs/migrating.md +++ b/docs/migrating.md @@ -12,7 +12,7 @@ For the overall direction, see [watching resources](why-a-gateway.md). ## Browser client -`connectResourceStream` is now the only connector. It takes a callback that receives each resource +`connectResourceStream` is now the only gateway connector. It takes a callback that receives each resource state event, instead of a store: | 0.7 | Now | diff --git a/docs/proposals/0006-stream-and-save-implementation-plan.md b/docs/proposals/0006-stream-and-save-implementation-plan.md index 770c099..a0ce8d9 100644 --- a/docs/proposals/0006-stream-and-save-implementation-plan.md +++ b/docs/proposals/0006-stream-and-save-implementation-plan.md @@ -2,8 +2,8 @@ **Status: active delivery plan, updated 2026-10-05.** This is the single inventory of completed work, open work and ordering. The implementation includes connector separation, Go API cleanup, -real-API save-composition tests, the native viewer and native editing. Detailed contracts stay in the -linked proposals and guides rather than becoming another roadmap. +real-API save-composition tests, the native viewer and native editing with a browser page. Detailed +contracts stay in the linked proposals and guides rather than becoming another roadmap. Start with [watching resources](../why-a-gateway.md); add the [editor](../client-state-model.md) only where a page needs drafts. Hosts with a Kubernetes proxy can read native collections with the @@ -30,7 +30,7 @@ remain separate delivery steps. | Editor integration and recovery recipes | Tested deletion recovery, keep-local resolution and Vue subscription ownership | [Recipes](../../examples/editor-recipes/README.md), [Vue](../../examples/vue/README.md) | | Real-API save composition | Cases for suppressed churn, guarded-read overlap, structured errors and same-name UID replacement | [Completed baseline](#completed-baseline) | | Native viewer (slice 1) | `connectNativeWatch` and `nativeCollectionURL`: unpaginated LIST, WATCH from the collection RV, fresh LIST on reconnect, native 410 recovery and terminal refusals on a lifecycle shared with the gateway connector; fake-fetch, Chromium and real-API tests; minimal example | [Request](../field-reports/third-our-identity.md#slice-1-a-native-viewer), [example](../../examples/native-viewer/README.md), [client](../../packages/krm-stream/README.md#native-connections) | -| Native editing (slice 2) | Machinery (`managedFields`, last-applied annotation) read-only in the store under every policy; maps holding a protected path merged key by key; `nativeObjectURL`; `gateway.ValidateNativeMergePatch` for host proxies; a native conditional editor with guarded native recovery reads; fake-fetch and real-API tests | [Request](../field-reports/third-our-identity.md#slice-2-native-editing), [example](../../examples/native-editor/README.md), [saving](../saving.md#native-editing-through-a-host-proxy) | +| Native editing (slice 2) | Machinery (`managedFields`, last-applied annotation) read-only in the store under every policy; maps holding a protected path merged key by key; `nativeObjectURL`; `gateway.ValidateNativeMergePatch` for host proxies; a native conditional editor with guarded native recovery reads; fake-fetch and real-API tests; a minimal one-ConfigMap page accepted in Chromium on both entry points | [Request](../field-reports/third-our-identity.md#slice-2-native-editing), [example](../../examples/native-editor/README.md), [saving](../saving.md#native-editing-through-a-host-proxy) | | Watch-first documentation | README, watch/edit/save guides, native-fetch request and compact decision records; duplicate/superseded guides removed | [README](../../README.md), [current request](../field-reports/third-our-identity.md) | ## Open work and delivery order @@ -41,7 +41,7 @@ before starting a later one. Deliver separately reviewable changes and use the p | Order | Open work | Dependencies and completion | |---|---|---| | 1 | Native viewer — **completed** | See [completed work](#completed-work); acceptance evidence is recorded in the [request](../field-reports/third-our-identity.md#acceptance-for-slice-1). | -| 2 | Native editing — **completed** | See [completed work](#completed-work); acceptance evidence is recorded in the [request](../field-reports/third-our-identity.md#acceptance-for-slice-2). No editor method changed, so proposal 0009's cleanup is unaffected. | +| 2 | Native editing — **completed** | See [completed work](#completed-work); acceptance evidence, browser acceptance included, is recorded in the [request](../field-reports/third-our-identity.md#acceptance-for-slice-2). No editor method changed, so proposal 0009's cleanup is unaffected. | | 3 | Native reconnect efficiency | After the viewer; evaluate resumable watches, streaming-list initialization and pagination with explicit membership/checkpoint tests. Do not claim these efficiencies for slice 1. | | 4 | Gateway upstream continuation | Independent track with a design below; review reopen/credential bounds, record a baseline, implement and measure. No dependency on native transport. | | 5 | Save progress during suppressed churn | Independent evaluation using existing real-API fixtures; bounded submitted-intent recovery first, optional version delivery second. Implementation depends on measured benefit and reviewed scope. | diff --git a/docs/proposals/0009-stream-and-editor-separation.md b/docs/proposals/0009-stream-and-editor-separation.md index d605ad9..0d62b59 100644 --- a/docs/proposals/0009-stream-and-editor-separation.md +++ b/docs/proposals/0009-stream-and-editor-separation.md @@ -25,12 +25,14 @@ The [native-watch request](../field-reports/third-our-identity.md#native-watch-c separation: native is the original-resource source through a host proxy; the gateway provides projected views over SSE. Share lifecycle and state application without converting native JSON to SSE. The first native slice, `connectNativeWatch`, is a read-only viewer using today's event consumer and -standalone `applyStreamEvent`. It did not depend on the editor cleanup below or a new store; native -editing follows separately. +standalone `applyStreamEvent`. It did not depend on the editor cleanup below or a new store. Native +editing followed in slice 2 on the same terms: conditional merge patches through the same proxy, +with the existing store and no editor method changed. ## Implemented connector and correctness fixes -`connectResourceStream(url, consume, options)` is the one current connector. `consume` receives +`connectResourceStream(url, consume, options)` is the one current gateway connector; +`connectNativeWatch` shares its lifecycle, consumer and handle for native sources. `consume` receives `ResourceStateEvent` synchronously and independently of a store. It can call the standalone `applyStreamEvent(store, event)` or another state consumer. The connector handles wire sequence and errors privately; its handle exposes `state`, `subscribe`, `close` and `closed`. diff --git a/examples/README.md b/examples/README.md index b7e6f7e..3fe2a1f 100644 --- a/examples/README.md +++ b/examples/README.md @@ -7,8 +7,9 @@ optional editor. through an existing host proxy, such as `kubectl proxy`, with the shared lifecycle and read-only store. The checked browser example in [`vanilla-browser/`](vanilla-browser/) runs against the gateway source over SSE. [Native editor](native-editor/README.md) adds conditional saves through the same proxy, -with machinery protection and guarded native recovery reads. The larger view/sharing comparison -follows separately. +with machinery protection and guarded native recovery reads, and a minimal page that edits one +ConfigMap with them. Both native pages run in Chromium under `task e2e-browser`. The larger +view/sharing comparison follows separately. For gateway host integration patterns, use these small recipes: - [Same-origin cookie application](../docs/adopting.md#1-mount-the-stream-endpoint): fetch diff --git a/examples/editor-recipes/README.md b/examples/editor-recipes/README.md index d848024..d79ad18 100644 --- a/examples/editor-recipes/README.md +++ b/examples/editor-recipes/README.md @@ -10,7 +10,8 @@ with `task test`, driving the store with stream events as `connectResourceStream A `deleted` event, or a snapshot that no longer contains an object, removes the object and its draft from the store. Once that has happened there is no draft left to read, so a copy must be taken while the object still exists. [`retainRecoveryCopy`](recoveryCopy.ts) keeps a detached copy of one fixed -UID's draft and unsaved edits, refreshed on every store notification from the moment it starts: +UID's draft and unsaved edits, refreshed on every store notification from the moment it starts. +Its reads are current even inside a store listener the host subscribed before it: ```ts const recovery = retainRecoveryCopy(store, uid, { retainMs: 15 * 60_000 }); diff --git a/examples/editor-recipes/recoveryCopy.ts b/examples/editor-recipes/recoveryCopy.ts index b65a1e0..d71645e 100644 --- a/examples/editor-recipes/recoveryCopy.ts +++ b/examples/editor-recipes/recoveryCopy.ts @@ -31,8 +31,10 @@ export function retainRecoveryCopy(store: LiveResourceStore, uid: string, option const now = options.now ?? Date.now; let last: Omit | null = null; let removedAt: number | undefined; + let disposed = false; const capture = () => { + if (disposed) return; if (!store.ids().includes(uid)) { // Removed (or never seen). Keep the last copy; there is no draft left to read. if (last && removedAt === undefined) removedAt = now(); @@ -51,18 +53,23 @@ export function retainRecoveryCopy(store: LiveResourceStore, uid: string, option const stop = store.subscribe(capture); return { + // Both readers capture first: a host listener subscribed before this recipe runs before its + // listener, and would otherwise read a removal the copy has not yet seen. /** The live object's draft is the one to edit; a copy exists only once the object is gone. */ get removed(): boolean { + capture(); return removedAt !== undefined; }, /** The retained copy of a removed object, or null while it still exists, after expiry, after * dispose(), or when it was never in the store. Each call returns a fresh detached copy. */ copy(): RecoveryCopy | null { + capture(); if (!last || removedAt === undefined || now() - removedAt > retainMs) return null; return structuredClone({ ...last, removedAt }); }, /** Stop listening and drop the copy. Call when the editor for this UID is torn down. */ dispose(): void { + disposed = true; stop(); last = null; }, diff --git a/examples/native-editor/README.md b/examples/native-editor/README.md index 481a190..2285459 100644 --- a/examples/native-editor/README.md +++ b/examples/native-editor/README.md @@ -21,6 +21,8 @@ automatic write retry. `gateway.ValidateNativeMergePatch`. Against a real API server it checks a save and its echo, a competing write between capture and PATCH, the proxy refusing unconditional and machinery patches, and an object replaced under the same name. +- [index.html](index.html) is a minimal page that edits one ConfigMap with this editor. Its + [browser tests](../vanilla-browser/tests/native-editor.spec.ts) are described [below](#run-the-page). ```ts const proxy = "/k8s"; @@ -46,6 +48,63 @@ its own namespace and name, so a collection watched across namespaces works too. source, scope and login identity. Never point this editor at a projected store, and never give a projected editor a native response. +## Run the page + +[index.html](index.html) watches one named ConfigMap and edits its `data` and ordinary annotations, +showing identity, connection state, server state, the draft and pending edits. It has no framework. +The page loads the built library and `editor.ts` compiled into `dist/`, together with the +[keep-local and recovery-copy recipes](../editor-recipes/README.md). +[build.mjs](build.mjs) only strips types and renames the library import to +`@configbutler/krm-stream`. An import map in the page resolves that name to one built entry point. +`dist/` is not committed. + +```bash +task build-native-editor # builds the library, then dist/ +kubectl create configmap settings --from-literal=value=hello # or pick an existing one with name= +kubectl proxy --port=8001 --api-prefix=/k8s/ --www=. --www-prefix=/files/ +# http://127.0.0.1:8001/files/examples/native-editor/?namespace=default&name=settings +``` + +The query parameters are `proxy` (default `/k8s`), `namespace` (default `default`), `name` (default +`settings`) and `echoWaitMs` (default 5000, how long an accepted write waits for its watch echo). +Add `entry=bundle` to load the single-file build. + +- Save is enabled only while the watch is `live`, no request is in flight and no conflict is open. + Typing continues during a save, and inputs keep focus and caret through watch events and reads. +- `saved` waits for the watch. The next version it delivers includes the write; fields still dirty + then were typed after Save or not kept by the server. With no echo within `echoWaitMs`, the page + offers **Confirm current state**, which calls `editor.confirm()`. A network failure or a 5xx + offers Confirm at once. Until a read succeeds, Save also only reads, and nothing is resent. +- `confirmed` says the form now builds on the server's state. Fields still dirty were not written. +- Conflicts offer **Use server value** (`store.revert`) and **Keep mine** (the keep-local recipe). + Typing into a field whose save is in flight becomes a conflict when the echo arrives: the server + holds the saved value and the draft holds the later typing. +- A refusal (401, 403, 422 or another 4xx) shows the Kubernetes `Status` and keeps the draft. +- Values are edited in text areas, so line breaks survive. A browser text area normalizes `\r\n` to + `\n`, so editing a value that contains carriage returns writes it back without them. +- A deleted or replaced object shows its unsaved edits from the recovery copy. A replacement opens + only on request, without the old edits. +- **Disconnect**, or leaving the page, closes the watch, unsubscribes and clears the echo timer. A + request in flight is not cancelled, because the write may already have landed, and its answer + changes nothing. + +`kubectl proxy` stands in for the host here. It holds your kubeconfig credential server-side, serves +the page and the API from one origin, accepts only `localhost` host names and refuses pod +`exec`/`attach` by default. It provides none of the write protections listed +[under the host proxy](#the-host-proxy). Every request runs as **your** kubeconfig identity, so RBAC +checks you, not the person editing. It does not check the content type, bound the body or run +`ValidateNativeMergePatch`, and it has no CSRF checks, per-user write policy or audit. The page and +store still send only conditional patches without machinery, but nothing stops another client from +sending something else. Keep it on localhost and stop it when you are done. + +`task e2e-browser` runs the page in Chromium on both entry points against a test server that serves +the page and plays the host proxy and API server, with a watch that stays open while editing: +[native-editor.spec.ts](../vanilla-browser/tests/native-editor.spec.ts). It covers a conditional +save and its echo, typing while a save is in flight, 409 recovery and a later deliberate Save, +explicit conflict resolution, a lost response and a 502 each settled by a GET before any further +PATCH, an accepted write without an echo confirmed explicitly, refusals, multiline values, a deletion +and a UID replacement each with copy-out, and disconnect. + ## What it protects - **Machinery.** `metadata.managedFields` and the `kubectl.kubernetes.io/last-applied-configuration` diff --git a/examples/native-editor/build.mjs b/examples/native-editor/build.mjs new file mode 100644 index 0000000..144fe92 --- /dev/null +++ b/examples/native-editor/build.mjs @@ -0,0 +1,36 @@ +// Compile the native editor and the editor recipes the page reuses into browser-loadable ESM in +// dist/. They stay TypeScript in the repository, where the client suite runs them; this only strips +// types. Their library import, a relative path to the TypeScript source, becomes +// "@configbutler/krm-stream", exactly as a host copying them would write it. The page's import map +// points that name at one built entry point, so the page and the editor share one copy of the +// library: index.js or the single-file bundle. +// +// Run with `task build-native-editor`. esbuild is the client package's own devDependency. + +import { createRequire } from "node:module"; + +const { build } = createRequire(new URL("../../packages/krm-stream/package.json", import.meta.url))("esbuild"); + +const library = { + name: "published-library", + setup(build) { + build.onResolve({ filter: /\/packages\/krm-stream\/src\/index\.ts$/ }, () => ({ + path: "@configbutler/krm-stream", + external: true, + })); + }, +}; + +await build({ + entryPoints: ["editor.ts", "../editor-recipes/keepLocal.ts", "../editor-recipes/recoveryCopy.ts"].map((path) => ({ + in: new URL(path, import.meta.url).pathname, + out: path.replace(/^.*\//, "").replace(/\.ts$/, ""), + })), + outdir: new URL("dist", import.meta.url).pathname, + bundle: true, + format: "esm", + target: "es2022", + legalComments: "none", + plugins: [library], + logLevel: "warning", +}); diff --git a/examples/native-editor/index.html b/examples/native-editor/index.html new file mode 100644 index 0000000..4602fea --- /dev/null +++ b/examples/native-editor/index.html @@ -0,0 +1,598 @@ + + + + + + + krm-stream — native editor + + + + +

Native editor

+

+

+ Connection: connecting + +

+

+ +
+

Identity

+
+
+ +
+
+

Draft — edits stay local until Save

+
+
+ + + + +
+
+ + + +
+

Pending edits

+
    +

    + + + +

    +

    +
    +
    + + + +
    +

    Server state — read-only, follows the watch

    +
    
    +    
    + + + + diff --git a/examples/native-viewer/README.md b/examples/native-viewer/README.md index 066fe74..2d6a666 100644 --- a/examples/native-viewer/README.md +++ b/examples/native-viewer/README.md @@ -36,7 +36,7 @@ browser never holds a Kubernetes credential. access provides no projection, redaction, suppression or shared watches; use the [gateway](../../docs/why-a-gateway.md) for those. - It is a viewer. To edit through the same proxy, add the - [native editor](../native-editor/README.md). Resumable watches and paginated lists are later work; + [native editor](../native-editor/README.md), which has its own page for one ConfigMap. Resumable watches and paginated lists are later work; see the [delivery plan](../../docs/proposals/0006-stream-and-save-implementation-plan.md#open-work-and-delivery-order). `task e2e-browser` loads this page in Chromium on both entry points, with Playwright standing in for diff --git a/examples/vanilla-browser/README.md b/examples/vanilla-browser/README.md index 73131fe..f24445b 100644 --- a/examples/vanilla-browser/README.md +++ b/examples/vanilla-browser/README.md @@ -11,8 +11,8 @@ task demo Use `task e2e-browser` to run the Chromium check. It loads both published entry points, and also reads the gateway with a raw native `EventSource` to check its framing, reconnect snapshots and -terminal shutdown. The same run loads the [native viewer](../native-viewer/README.md) against a -Playwright-played host proxy. +terminal shutdown. The same run loads the [native viewer](../native-viewer/README.md) and the +[native editor](../native-editor/README.md#run-the-page) pages against test-played host proxies. The page demonstrates live status updates, three-way conflict handling, draft edits, and redacted Secret values. Useful fixtures include: @@ -32,5 +32,6 @@ The example uses the gateway's projected SSE connector over fetch. For a read-on state with `LiveResourceStore(readOnlyPolicy)` as shown in the [README](../../README.md#watch-a-gateway-view). Editing is an optional layer; the [editor model](../../docs/client-state-model.md) and [saving guide](../../docs/saving.md) describe its intended use. For native resources through a host -proxy, see the [native viewer](../native-viewer/README.md), which this suite also loads; native -editing and a larger source comparison follow separately. +proxy, see the [native viewer](../native-viewer/README.md) and +[native editor](../native-editor/README.md), which this suite also loads; a larger source comparison +follows separately. diff --git a/examples/vanilla-browser/playwright.config.ts b/examples/vanilla-browser/playwright.config.ts index 65d3475..e9d982a 100644 --- a/examples/vanilla-browser/playwright.config.ts +++ b/examples/vanilla-browser/playwright.config.ts @@ -41,7 +41,7 @@ export default defineConfig<{ entry: Entry }>({ // first-ever CI run. Run `task e2e-browser`, which installs the library's devDependencies first. webServer: { command: - "cd ../../packages/krm-stream && npm run --silent build && " + + "cd ../../packages/krm-stream && npm run --silent build && node ../../examples/native-editor/build.mjs && " + "cd ../../gateway && go run ./cmd/replay " + "--addr 127.0.0.1:8100 --corpus ../conformance " + "--static ../examples/vanilla-browser --dist ../packages/krm-stream/dist", diff --git a/examples/vanilla-browser/tests/native-editor.spec.ts b/examples/vanilla-browser/tests/native-editor.spec.ts new file mode 100644 index 0000000..7057032 --- /dev/null +++ b/examples/vanilla-browser/tests/native-editor.spec.ts @@ -0,0 +1,616 @@ +// The native editor example (examples/native-editor) in a real browser, on both entry points: the +// page, connectNativeWatch, LiveResourceStore and the native editor, against a host proxy that keeps +// its watch open while the person edits. +// +// No cluster. Playwright's request routing cannot stream a response, so each test starts a small +// HTTP server that plays both halves of `kubectl proxy --www`: it serves the page and the built +// library from the repository, and answers /k8s as an API server would for one ConfigMap — LIST, +// a WATCH that stays open, GET, and a conditional JSON merge PATCH refused like the Go test proxy +// refuses it. A test can hold an answer, script it, or hold watch events back, so it decides what +// the API server did and when the watch saw it. + +import { readFile } from "node:fs/promises"; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import { extname } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { Page } from "@playwright/test"; +import { test as base, expect } from "./fixtures.ts"; + +const repository = fileURLToPath(new URL("../../../", import.meta.url)); +const types: Record = { ".html": "text/html", ".js": "text/javascript" }; +const LAST_APPLIED = "kubectl.kubernetes.io/last-applied-configuration"; +const OBJECT = "/k8s/api/v1/namespaces/app/configmaps/settings"; +const COLLECTION = "/k8s/api/v1/namespaces/app/configmaps"; + +interface ConfigMap { + metadata: { + name: string; + namespace: string; + uid: string; + resourceVersion: string; + annotations: Record; + managedFields: unknown[]; + }; + data: Record; +} + +interface Call { + method: string; + path: string; + query: URLSearchParams; + contentType: string | undefined; + body: unknown; +} + +type Answer = (call: Call, res: ServerResponse) => void | Promise; + +/** One ConfigMap behind a host proxy. Writes are answered in order by `patches` and reads by + * `reads`, falling back to the API server's own behavior. */ +class Host { + readonly calls: Call[] = []; + readonly patches: Answer[] = []; + readonly reads: Answer[] = []; + object: ConfigMap | undefined = configMap("u1", 1, "base"); + #rv = 1; + #watches = new Set(); + #held: string[] = []; + #server: Server; + url = ""; + + constructor() { + this.#server = createServer((req, res) => void this.#serve(req, res)); + } + + async start() { + await new Promise((resolve) => this.#server.listen(0, "127.0.0.1", resolve)); + this.url = `http://127.0.0.1:${(this.#server.address() as AddressInfo).port}`; + } + + async stop() { + for (const watch of this.#watches) watch.end(); + this.#server.closeAllConnections(); + await new Promise((resolve) => this.#server.close(resolve)); + } + + get openWatches() { + return this.#watches.size; + } + + /** Requests for the object itself: the editor's PATCHes and guarded GETs. */ + objectCalls() { + return this.calls.filter((c) => c.path === OBJECT).map((c) => c.method); + } + + patchBodies() { + return this.calls.filter((c) => c.method === "PATCH").map((c) => c.body); + } + + /** Another writer changes the object. Its watch event is delivered now, or held until flush(). */ + change(mutate: (object: ConfigMap) => void, { deliver = true } = {}) { + mutate(this.object!); + this.object!.metadata.resourceVersion = String(++this.#rv); + this.#emit("MODIFIED", this.object!, deliver); + } + + /** Delete the object, with nothing in its place. */ + remove() { + const old = this.object!; + old.metadata.resourceVersion = String(++this.#rv); + this.object = undefined; + this.#emit("DELETED", old, true); + } + + /** Delete the object and create another under the same name, with a new UID. */ + replace({ deliver = true } = {}) { + const old = this.object!; + old.metadata.resourceVersion = String(++this.#rv); + this.#emit("DELETED", old, deliver); + this.object = configMap("u2", ++this.#rv, "replacement"); + this.#emit("ADDED", this.object, deliver); + } + + /** Deliver the watch events held back so far. */ + flush() { + for (const frame of this.#held.splice(0)) for (const watch of this.#watches) watch.write(frame); + } + + /** The API server's PATCH: preconditions checked, then the merge patch applied and echoed. */ + patch = (call: Call, res: ServerResponse, { deliver = true } = {}) => { + const body = call.body as { metadata?: Record }; + const object = this.object; + if (!object) return status(res, 404, 'configmaps "settings" not found'); + if ( + body.metadata?.resourceVersion !== object.metadata.resourceVersion || + body.metadata?.uid !== object.metadata.uid + ) { + return status(res, 409, "Operation cannot be fulfilled: the object has been modified"); + } + this.write(body, { deliver }); + json(res, 200, typed(this.object!)); + }; + + /** Apply a merge patch as the API server would, without answering. */ + write(body: unknown, { deliver = true } = {}) { + const { metadata, ...rest } = body as { metadata: Record }; + const { uid: _uid, resourceVersion: _rv, ...fields } = metadata; + this.object = merge(this.object, { ...rest, metadata: fields }) as ConfigMap; + this.object.metadata.resourceVersion = String(++this.#rv); + this.#emit("MODIFIED", this.object, deliver); + } + + #emit(type: string, object: ConfigMap, deliver: boolean) { + const frame = `${JSON.stringify({ type, object: typed(object) })}\n`; + if (!deliver) return void this.#held.push(frame); + for (const watch of this.#watches) watch.write(frame); + } + + async #serve(req: IncomingMessage, res: ServerResponse) { + const url = new URL(req.url!, this.url); + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + if (!url.pathname.startsWith("/k8s/")) return this.#file(url.pathname, res); + const text = Buffer.concat(chunks).toString(); + const call: Call = { + method: req.method!, + path: url.pathname, + query: url.searchParams, + contentType: req.headers["content-type"], + body: text ? JSON.parse(text) : undefined, + }; + this.calls.push(call); + + if (call.path === COLLECTION && call.method === "GET") { + expect(call.query.get("fieldSelector")).toBe("metadata.name=settings"); + if (call.query.get("watch") === "1") { + // Accepted, and open until the page or the test closes it. + res.writeHead(200, { "Content-Type": "application/json" }); + res.flushHeaders(); + this.#watches.add(res); + res.on("close", () => this.#watches.delete(res)); + return; + } + const items = this.object ? [this.object] : []; + return json(res, 200, { + kind: "ConfigMapList", + apiVersion: "v1", + metadata: { resourceVersion: String(this.#rv) }, + items, + }); + } + if (call.path !== OBJECT) return status(res, 404, "not proxied"); + if (call.method === "GET") { + const answer = this.reads.shift(); + if (answer) return answer(call, res); + return this.object ? json(res, 200, typed(this.object)) : status(res, 404, "not found"); + } + if (call.method !== "PATCH") return status(res, 405, "not proxied"); + // The host proxy's own checks, as in gateway/kube/native_e2e_test.go. + if (call.contentType !== "application/merge-patch+json") return status(res, 415, "only merge patches"); + const metadata = ( + call.body as { + metadata?: { uid?: string; resourceVersion?: string; annotations?: object; managedFields?: unknown }; + } + ).metadata; + if (!metadata?.uid || !metadata.resourceVersion) return status(res, 400, "a native patch needs both preconditions"); + if (metadata.managedFields !== undefined || (metadata.annotations && LAST_APPLIED in metadata.annotations)) { + return status(res, 400, "a native patch must not touch machinery"); + } + const answer = this.patches.shift(); + return answer ? answer(call, res) : this.patch(call, res); + } + + async #file(path: string, res: ServerResponse) { + if (!path.startsWith("/examples/native-editor/") && !path.startsWith("/packages/krm-stream/dist/")) { + return status(res, 404, "not served"); + } + res.writeHead(200, { "Content-Type": types[extname(path)] ?? "application/octet-stream", Connection: "close" }); + res.end(await readFile(`${repository}${path.slice(1)}`)); + } +} + +function configMap(uid: string, rv: number, value: string): ConfigMap { + return { + metadata: { + name: "settings", + namespace: "app", + uid, + resourceVersion: String(rv), + annotations: { owner: "team-a", [LAST_APPLIED]: `{"data":{"value":"${value}"}}` }, + managedFields: [{ manager: "kubectl", operation: "Apply" }], + }, + data: { value }, + }; +} + +const typed = (object: ConfigMap) => ({ apiVersion: "v1", kind: "ConfigMap", ...structuredClone(object) }); + +function merge(target: unknown, patch: unknown): unknown { + if (patch === null || typeof patch !== "object" || Array.isArray(patch)) return patch; + const out: Record = + target && typeof target === "object" && !Array.isArray(target) ? { ...(target as object) } : {}; + for (const [k, v] of Object.entries(patch)) { + if (v === null) delete out[k]; + else out[k] = merge(out[k], v); + } + return out; +} + +// Every answer but a watch closes its connection, so Chromium never resends a request over a reused +// socket and the PATCH count is the page's own. +function json(res: ServerResponse, code: number, body: unknown) { + res.writeHead(code, { "Content-Type": "application/json", Connection: "close" }); + res.end(JSON.stringify(body)); +} + +function status(res: ServerResponse, code: number, message: string, extra: object = {}) { + json(res, code, { kind: "Status", apiVersion: "v1", status: "Failure", code, message, ...extra }); +} + +function gate() { + let open!: () => void; + const opened = new Promise((resolve) => { + open = resolve; + }); + return { opened, open }; +} + +const test = base.extend<{ host: Host; errors: string[] }>({ + host: async ({}, use) => { + const host = new Host(); + await host.start(); + await use(host); + await host.stop(); + }, + // Every test: the page reports no uncaught error. + errors: [ + async ({ page }, use) => { + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(e.message)); + await use(errors); + expect(errors).toEqual([]); + }, + { auto: true }, + ], +}); + +async function open(page: Page, host: Host, entry: string, query = "") { + await page.goto(`${host.url}/examples/native-editor/index.html?entry=${entry}&namespace=app&name=settings${query}`); + await expect(page.locator("#state")).toHaveText("live"); + await expect(field(page, "data value")).toHaveValue("base"); +} + +const field = (page: Page, name: string) => page.getByRole("textbox", { name, exact: true }); +const outcome = (page: Page) => page.locator("#outcome"); +const save = (page: Page) => page.getByRole("button", { name: "Save" }); +const confirm = (page: Page) => page.getByRole("button", { name: "Confirm current state" }); + +test("a conditional save settles on its watch echo, with machinery read-only", async ({ page, entry, host }) => { + await open(page, host, entry); + await expect(page.locator("#identity")).toContainText("u1"); + await expect(page.locator("#server")).toContainText("kubectl (Apply)"); + // The last-applied annotation is shown but has no input; managedFields never reach the form. + await expect(field(page, `annotation ${LAST_APPLIED}`)).toHaveCount(0); + await expect(page.locator("#fields tr", { hasText: LAST_APPLIED })).toContainText("read-only"); + + await field(page, "data value").fill("mine"); + await field(page, "annotation owner").fill("team-b"); + await expect(page.locator("#changes")).toContainText('change data value: "base" → "mine"'); + await save(page).click(); + + await expect(outcome(page)).toHaveAttribute("data-kind", "echoed"); + await expect(outcome(page)).toContainText("resourceVersion 2. Every field matches the server."); + expect(host.calls.find((c) => c.method === "PATCH")?.contentType).toBe("application/merge-patch+json"); + expect(host.patchBodies()).toEqual([ + { metadata: { annotations: { owner: "team-b" }, uid: "u1", resourceVersion: "1" }, data: { value: "mine" } }, + ]); + expect(host.objectCalls()).toEqual(["PATCH"]); + expect(host.object!.metadata.annotations[LAST_APPLIED]).toBe('{"data":{"value":"base"}}'); + await expect(page.locator("#changes")).toHaveText("No pending edits."); + await expect(page.locator("#identity")).toContainText("resourceVersion2"); +}); + +test("typing and focus survive a save in flight and its echo", async ({ page, entry, host }) => { + await open(page, host, entry); + await field(page, "data value").fill("first"); + const held = gate(); + host.patches.push(async (call, res) => { + await held.opened; + host.patch(call, res); + }); + await save(page).click(); + await expect(save(page)).toBeDisabled(); // one request at a time + + // Typing while the write is in flight: into the field being saved, then into another one. + await field(page, "data value").click(); + await page.keyboard.press("End"); + await page.keyboard.type("+1"); + await field(page, "annotation owner").click(); + await page.keyboard.press("End"); + await page.keyboard.type(" and more"); + held.open(); + + await expect(outcome(page)).toHaveAttribute("data-kind", "echoed"); + expect(host.patchBodies()).toEqual([{ metadata: { uid: "u1", resourceVersion: "1" }, data: { value: "first" } }]); + await expect(field(page, "annotation owner")).toBeFocused(); + // An unrelated change arrives while the person is still typing: a new row, the caret stays put. + host.change((o) => { + o.data.extra = "from the server"; + }); + await expect(field(page, "data extra")).toHaveValue("from the server"); + await page.keyboard.type("!"); + await expect(field(page, "annotation owner")).toHaveValue("team-a and more!"); + await expect(field(page, "annotation owner")).toBeFocused(); + + // The echo of "first" arrived after "+1" was typed into the same field: the server value moved + // under a local edit, which the page shows as a conflict rather than choosing for the person. + await expect(field(page, "data value")).toHaveValue("first+1"); + await expect(page.locator("#conflicts")).toContainText('data value — yours: "first+1", server: "first"'); + await expect(save(page)).toBeDisabled(); + await page.getByRole("button", { name: "Keep mine" }).click(); + await expect(page.locator("#changes")).toContainText('change data value: "first" → "first+1"'); + await expect(page.locator("#changes")).toContainText('change annotation owner: "team-a" → "team-a and more!"'); + await expect(save(page)).toBeEnabled(); +}); + +test("a 409 refreshes the base, keeps the edits and writes only on the next deliberate Save", async ({ + page, + entry, + host, +}) => { + await open(page, host, entry); + await field(page, "data value").fill("mine"); + host.patches.push((call, res) => { + // Another writer lands between capture and PATCH; the watch has not delivered it yet. + host.change( + (o) => { + o.metadata.annotations.owner = "team-c"; + }, + { deliver: false }, + ); + host.patch(call, res); + }); + await save(page).click(); + + await expect(outcome(page)).toHaveAttribute("data-kind", "version-stale"); + await expect(outcome(page)).toContainText("nothing was written"); + expect(host.objectCalls()).toEqual(["PATCH", "GET"]); + await expect(field(page, "data value")).toHaveValue("mine"); + await expect(field(page, "annotation owner")).toHaveValue("team-c"); + await expect(page.locator("#identity")).toContainText("resourceVersion2"); + await page.waitForTimeout(500); + expect(host.objectCalls(), "no automatic retry").toEqual(["PATCH", "GET"]); + host.flush(); + + await save(page).click(); + await expect(outcome(page)).toHaveAttribute("data-kind", "echoed"); + expect(host.patchBodies()[1]).toEqual({ metadata: { uid: "u1", resourceVersion: "2" }, data: { value: "mine" } }); + expect(host.object!.data.value).toBe("mine"); + expect(host.object!.metadata.annotations.owner).toBe("team-c"); +}); + +test("field conflicts are resolved explicitly before the next save", async ({ page, entry, host }) => { + await open(page, host, entry); + await field(page, "data value").fill("mine"); + await field(page, "annotation owner").fill("team-b"); + host.patches.push((call, res) => { + host.change( + (o) => { + o.data.value = "theirs"; + o.metadata.annotations.owner = "team-c"; + }, + { deliver: false }, + ); + host.patch(call, res); + }); + await save(page).click(); + + await expect(outcome(page)).toHaveAttribute("data-kind", "draft-conflict"); + const conflicts = page.locator("#conflicts li"); + await expect(conflicts).toHaveCount(2); + await expect(save(page)).toBeDisabled(); + await expect(page.locator("#hint")).toHaveText("Resolve the conflicts to save."); + await conflicts.filter({ hasText: "data value" }).getByRole("button", { name: "Keep mine" }).click(); + await conflicts.filter({ hasText: "annotation owner" }).getByRole("button", { name: "Use server value" }).click(); + await expect(page.locator("#conflicts-section")).toBeHidden(); + await expect(field(page, "annotation owner")).toHaveValue("team-c"); + + await save(page).click(); + await expect(outcome(page)).toHaveAttribute("data-kind", "echoed"); + expect(host.patchBodies()[1]).toEqual({ metadata: { uid: "u1", resourceVersion: "2" }, data: { value: "mine" } }); + expect(host.object!.metadata.annotations.owner).toBe("team-c"); +}); + +test("a lost response is settled by a GET, never a second PATCH", async ({ page, entry, host }) => { + await open(page, host, entry); + await field(page, "data value").fill("mine"); + host.patches.push((call, res) => { + // The write lands, but its answer and its watch event never reach the page. + host.write(call.body, { deliver: false }); + res.socket?.destroy(); + }); + await save(page).click(); + + await expect(outcome(page)).toHaveAttribute("data-kind", "unknown"); + await expect(outcome(page)).toContainText("got no answer"); + await expect(confirm(page)).toBeVisible(); + await page.waitForTimeout(500); + expect(host.objectCalls(), "no automatic retry").toEqual(["PATCH"]); + + // A deliberate Save reads instead of writing again, and finds the write landed. + await save(page).click(); + await expect(outcome(page)).toHaveAttribute("data-kind", "confirmed"); + await expect(outcome(page)).toContainText("resourceVersion 2. Every field matches the server."); + expect(host.objectCalls()).toEqual(["PATCH", "GET"]); + await expect(confirm(page)).toBeHidden(); + host.flush(); + await expect(page.locator("#changes")).toHaveText("No pending edits."); +}); + +test("a proxy 502 is confirmed by a GET, and the unsaved edit is written by the next Save", async ({ + page, + entry, + host, +}) => { + await open(page, host, entry); + await field(page, "data value").fill("mine"); + host.patches.push((_call, res) => status(res, 502, "upstream unavailable")); + await save(page).click(); + + await expect(outcome(page)).toHaveAttribute("data-kind", "unknown"); + await expect(outcome(page)).toContainText("HTTP 502"); + await confirm(page).click(); + await expect(outcome(page)).toHaveAttribute("data-kind", "confirmed"); + await expect(outcome(page)).toContainText("1 field still differ"); + expect(host.objectCalls()).toEqual(["PATCH", "GET"]); + await expect(page.locator("#changes")).toContainText('change data value: "base" → "mine"'); + + await save(page).click(); + await expect(outcome(page)).toHaveAttribute("data-kind", "echoed"); + expect(host.objectCalls()).toEqual(["PATCH", "GET", "PATCH"]); +}); + +test("an accepted write without an echo is confirmed explicitly, and its unsaved field stays dirty", async ({ + page, + entry, + host, +}) => { + await open(page, host, entry, "&echoWaitMs=1000"); + await field(page, "data value").fill("mine"); + // Accepted, but an admission webhook restored the value: the object is unchanged, so no event. + host.patches.push((_call, res) => json(res, 200, typed(host.object!))); + await save(page).click(); + + await expect(outcome(page)).toHaveAttribute("data-kind", "saved"); + await expect(outcome(page)).toHaveAttribute("data-kind", "no-echo"); + await expect(confirm(page)).toBeVisible(); + await page.waitForTimeout(500); + expect(host.objectCalls(), "no automatic retry or read").toEqual(["PATCH"]); + + await confirm(page).click(); + await expect(outcome(page)).toHaveAttribute("data-kind", "confirmed"); + await expect(outcome(page)).toContainText("1 field still differ from the server and were not saved by this write"); + await expect(confirm(page)).toBeHidden(); + await expect(page.locator("#changes")).toContainText('change data value: "base" → "mine"'); + expect(host.objectCalls()).toEqual(["PATCH", "GET"]); +}); + +test("refused writes keep the draft and need no read", async ({ page, entry, host }) => { + await open(page, host, entry); + await field(page, "data value").fill("mine"); + host.patches.push((_call, res) => status(res, 403, 'configmaps "settings" is forbidden')); + await save(page).click(); + await expect(outcome(page)).toHaveAttribute("data-kind", "forbidden"); + await expect(outcome(page)).toContainText('HTTP 403: configmaps "settings" is forbidden. Nothing was written'); + + host.patches.push((_call, res) => + status(res, 422, "ConfigMap is invalid", { details: { causes: [{ field: "data.value", message: "too long" }] } }), + ); + await save(page).click(); + await expect(outcome(page)).toHaveAttribute("data-kind", "invalid"); + await expect(outcome(page)).toContainText("(data.value: too long)"); + await expect(field(page, "data value")).toHaveValue("mine"); + expect(host.objectCalls()).toEqual(["PATCH", "PATCH"]); +}); + +test("an object replaced under the same name is never written, and the replacement opens separately", async ({ + page, + entry, + host, +}) => { + await open(page, host, entry); + await field(page, "data value").fill("mine"); + host.patches.push((call, res) => { + host.replace({ deliver: false }); + host.patch(call, res); + }); + await save(page).click(); + + await expect(outcome(page)).toHaveAttribute("data-kind", "unavailable"); + expect(host.objectCalls()).toEqual(["PATCH", "GET"]); + expect(host.object!.data.value).toBe("replacement"); + + // The watch catches up: the edited object is gone and its edits are kept for copying. + host.flush(); + await expect(page.locator("#removed")).toBeVisible(); + await expect(page.locator("#recovery")).toHaveText('change data value: "base" → "mine"'); + await expect(page.locator("#identity")).toContainText("Removed from the server."); + await expect(save(page)).toBeDisabled(); + + await page.getByRole("button", { name: "Discard these edits and open the replacement" }).click(); + await expect(page.locator("#identity")).toContainText("u2"); + await expect(field(page, "data value")).toHaveValue("replacement"); + await expect(page.locator("#changes")).toHaveText("No pending edits."); + expect(host.objectCalls()).toEqual(["PATCH", "GET"]); +}); + +test("disconnect closes the watch, clears the echo timer and ignores a late answer", async ({ page, entry, host }) => { + await open(page, host, entry, "&echoWaitMs=1500"); + await field(page, "data value").fill("mine"); + host.patches.push((_call, res) => json(res, 200, typed(host.object!))); + await save(page).click(); + await expect(outcome(page)).toHaveAttribute("data-kind", "saved"); + + // No echo yet, so the next Save is a guarded read. Hold it, and disconnect while it is in flight. + const held = gate(); + host.reads.push(async (_call, res) => { + await held.opened; + json(res, 200, typed(host.object!)); + }); + await save(page).click(); + await expect.poll(() => host.objectCalls()).toEqual(["PATCH", "GET"]); + await page.getByRole("button", { name: "Disconnect" }).click(); + + await expect(page.locator("#state")).toHaveText("closed"); + await expect(page.locator("#outcome")).toHaveAttribute("data-kind", "closed"); + await expect(field(page, "data value")).toBeDisabled(); + await expect(save(page)).toBeDisabled(); + await expect.poll(() => host.openWatches).toBe(0); + const requests = host.calls.length; + held.open(); + await page.waitForTimeout(2_000); // past the echo wait + await expect(page.locator("#outcome")).toHaveAttribute("data-kind", "closed"); + await expect(confirm(page)).toBeHidden(); + expect(host.calls.length, "nothing is requested after disconnect").toBe(requests); +}); + +test("multiline values keep their line breaks through editing and saving", async ({ page, entry, host }) => { + host.change((o) => { + o.data.script = "first\nsecond\n"; + }); + await open(page, host, entry); + const script = field(page, "data script"); + await expect(script).toHaveValue("first\nsecond\n"); + await script.click(); + await page.keyboard.press("ControlOrMeta+End"); + await page.keyboard.type("third"); + await field(page, "annotation owner").fill("team-a\nteam-b"); + await save(page).click(); + + await expect(outcome(page)).toHaveAttribute("data-kind", "echoed"); + expect(host.patchBodies()).toEqual([ + { + metadata: { annotations: { owner: "team-a\nteam-b" }, uid: "u1", resourceVersion: "2" }, + data: { script: "first\nsecond\nthird" }, + }, + ]); + await expect(script).toHaveValue("first\nsecond\nthird"); +}); + +test("a deleted object shows its unsaved edits at once, with nothing to open in its place", async ({ + page, + entry, + host, +}) => { + await open(page, host, entry); + await field(page, "data value").fill("mine"); + host.remove(); + + // The deletion is the last event: no later render could fill the copy in. + await expect(page.locator("#removed")).toBeVisible(); + await expect(page.locator("#recovery")).toHaveText('change data value: "base" → "mine"'); + await expect(page.locator("#identity")).toContainText("Removed from the server."); + await expect(page.getByRole("button", { name: "Discard these edits and open the replacement" })).toBeHidden(); + await expect(save(page)).toBeDisabled(); + expect(host.objectCalls()).toEqual([]); +}); diff --git a/packages/krm-stream/test/recipes.test.ts b/packages/krm-stream/test/recipes.test.ts index 0ad74e7..bd85375 100644 --- a/packages/krm-stream/test/recipes.test.ts +++ b/packages/krm-stream/test/recipes.test.ts @@ -47,6 +47,17 @@ test("recovery: the copy taken when the recipe starts survives an immediate dele assert.equal(copy?.removedAt, 1000); }); +test("recovery: a host listener subscribed before the recipe already sees the removal", () => { + const store = storeWith(widget("u", "1", { replicas: 1 })); + const seen: unknown[] = []; + // A page renders from its own listener, which it subscribed first, and reads the copy there. + store.subscribe(() => seen.push([recovery.removed, recovery.copy()?.draft.spec ?? null])); + const recovery = retainRecoveryCopy(store, "u"); + store.setValue("u", ["spec", "replicas"], 3); + applyStreamEvent(store, { type: "deleted", identity: identity("u") }); + assert.deepEqual(seen.at(-1), [true, { replicas: 3 }]); +}); + test("recovery: typing just before snapshot pruning is kept", () => { const store = storeWith(widget("u", "1", { replicas: 1, image: "v1" })); const recovery = retainRecoveryCopy(store, "u");