mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(chord): the reload suite, kit timeouts and base marks, four reload fixes
An end-to-end reload suite (a kit app, a worker, a real Chromium page; one test per row of design's reload table). The kit gains timeouts and base (base bundles' inputs and worker entries mark base). Fixes: a call crossing a failed or a removal gets the cause, not not_ready; a call sent with an old method index is decoded by the names its consumer had until the new bound is acked; a page retries a failed script fetch; applyChanges runs one at a time.
This commit is contained in:
parent
fcebc09956
commit
edc9c69892
30 changed files with 2410 additions and 45 deletions
|
|
@ -33,6 +33,8 @@ const app = await startServer<P>({
|
|||
uploads: 64, importBytes: 4 * 2 ** 30, importEntries: 100_000 },
|
||||
web: { entry: "./src/web/main.tsx", page: (request, assets) => shell(request.nonce, assets) }, // slice 11
|
||||
tui: { entry: "./src/tui/main.ts" },
|
||||
base: ["./src/app/", "./start.ts"], // base code besides worker entries and the base bundles' inputs
|
||||
timeouts: { closeTimeout: 30_000, readyTimeout: 60_000 }, // every host's (server and workers; the defaults)
|
||||
onError: (error) => log(error), // AppError: { kind: "host" | "http" | "workers" | "plugins", error }
|
||||
}, ctx);
|
||||
console.log(`Open ${app.ownerLink()}`);
|
||||
|
|
@ -196,9 +198,11 @@ archive leaves nothing. The next hold starts the worker on the imported store an
|
|||
## Plugins
|
||||
|
||||
Roots: the app's `root` (required: a plugin of it failing stops `startServer`) and `<dataDir>/plugins/` (the user's:
|
||||
installs go there). `app.plugins.state` is the plugins component's marks for the app's "apply changes" button;
|
||||
`applyChanges` builds the dirty plugins (and installed ones) and rolls out to the server, then each worker kind;
|
||||
nothing is applied by itself.
|
||||
installs go there). `app.plugins.state` is the plugins component's marks for the app's "apply changes" button; its
|
||||
`base` turns true when base code changes (the app restarts its hosts): `base`'s files and directories, each worker
|
||||
kind's `entry`, and every file the web and terminal base bundles were built from (esbuild's metafile), never files under
|
||||
`node_modules` (an npm upgrade is a restart anyway). `applyChanges` builds the dirty plugins (and installed ones) and
|
||||
rolls out to the server, then each worker kind; nothing is applied by itself.
|
||||
|
||||
## Internals
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
// the server's plugins with the kit's own facets (the start-data object, sessions, the owner link, pairing, files),
|
||||
// and gives the app the controls it needs: the owner link, `applyChanges`, installs, export and import per address.
|
||||
|
||||
import { statSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import type { TelemetryContext } from "@earendil-works/pi-telemetry";
|
||||
import { type Context, withAbortSignal } from "../../context/index.ts";
|
||||
|
|
@ -134,6 +135,13 @@ export type AppOptions<P extends Principal> = {
|
|||
* send (the principal of their link). Each worker's is its `startWorker`'s.
|
||||
*/
|
||||
readonly principalAttributes?: PrincipalAttributes<P>;
|
||||
/**
|
||||
* The app's base code beside what the kit knows (files or directories: its API modules, its start script): a change
|
||||
* sets `plugins.state.base`. The kit adds each worker kind's `entry` and every file its base bundles were built from.
|
||||
*/
|
||||
readonly base?: readonly string[];
|
||||
/** The `closeTimeout` and `readyTimeout` of every host (the server's and each worker's): 30 s and 60 s by default. */
|
||||
readonly timeouts?: AppTimeouts;
|
||||
readonly onError: (error: AppError) => void;
|
||||
} & (OwnerPrincipal extends P ? unknown : { readonly "the app's principal type must have { kind: 'owner' }": never });
|
||||
|
||||
|
|
@ -152,6 +160,9 @@ export type AppLimits = {
|
|||
readonly importEntries?: number;
|
||||
};
|
||||
|
||||
/** The hosts' timeouts in milliseconds (`HostOptions.closeTimeout`, `HostOptions.readyTimeout`). */
|
||||
export type AppTimeouts = { readonly closeTimeout?: number; readonly readyTimeout?: number };
|
||||
|
||||
/** The kit's web surface options (`webSurface`'s, its kind `web` and plugins the kit's). */
|
||||
export type AppWeb = Pick<WebSurfaceOptions, "entry" | "page" | "paths" | "public">;
|
||||
|
||||
|
|
@ -182,7 +193,10 @@ export interface App<P extends Principal> {
|
|||
ownerLink(): string;
|
||||
/** The owner token file's path (a device session of the owner, mode 0600). */
|
||||
readonly ownerTokenFile: string;
|
||||
/** Builds the dirty plugins and rolls them out: the server, every worker kind, then the clients; never by itself. */
|
||||
/**
|
||||
* Builds the dirty plugins and rolls them out: the server, every worker kind, then the clients; never by itself. Calls
|
||||
* run one after another (a later one builds after the earlier one rolled out).
|
||||
*/
|
||||
applyChanges(context: Context): Promise<RolloutResult>;
|
||||
/**
|
||||
* Writes the data the worker at `address` created (its store and finished files) as an archive at `path`, stopping
|
||||
|
|
@ -210,6 +224,8 @@ export type KitStart = {
|
|||
readonly telemetry: boolean;
|
||||
/** The cap of uploads under way per `Files` object (`limits.uploads`). */
|
||||
readonly uploads?: number;
|
||||
/** The app's host timeouts (`timeouts`), the worker's host takes them too. */
|
||||
readonly timeouts: AppTimeouts;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -230,6 +246,7 @@ export async function startServer<P extends Principal>(options: AppOptions<P>, c
|
|||
store,
|
||||
telemetry,
|
||||
...(principalAttributes === undefined ? {} : { principalAttributes }),
|
||||
...options.timeouts,
|
||||
onError: (error) => onError({ kind: "host", error }),
|
||||
});
|
||||
const http = createHttp(
|
||||
|
|
@ -242,6 +259,11 @@ export async function startServer<P extends Principal>(options: AppOptions<P>, c
|
|||
},
|
||||
context,
|
||||
);
|
||||
// Base code by kind: directories are watched as trees, files one by one (`watchBase`).
|
||||
const base = [...(options.base ?? []), ...(options.workers ?? []).map(({ entry }) => entry)].map((path) =>
|
||||
resolve(path),
|
||||
);
|
||||
const baseDirs = base.filter((path) => statSync(path, { throwIfNoEntry: false })?.isDirectory() === true);
|
||||
const plugins = createPlugins(
|
||||
{
|
||||
roots: [{ path: resolve(options.root ?? "plugins"), required: true }, layout.plugins],
|
||||
|
|
@ -253,6 +275,7 @@ export async function startServer<P extends Principal>(options: AppOptions<P>, c
|
|||
]),
|
||||
),
|
||||
install: options.install,
|
||||
base: baseDirs,
|
||||
publicPath: `${WEB_PREFIX}assets/`,
|
||||
onError: (error) => onError({ kind: "plugins", error }),
|
||||
},
|
||||
|
|
@ -300,6 +323,8 @@ export async function startServer<P extends Principal>(options: AppOptions<P>, c
|
|||
surfaces.push(surface);
|
||||
core.push({ name: `chord.${surface.name}`, facet: surface.facet });
|
||||
}
|
||||
const files = base.filter((path) => !baseDirs.includes(path));
|
||||
plugins.watchBase([...files, ...surfaces.flatMap(({ inputs }) => inputs)]);
|
||||
await plugins.load(host, { kind: "server", core, modules: modulesOf(api, options.kinds.server) }, context);
|
||||
await ownerTokenFile(layout.ownerToken, host, context);
|
||||
app.started(link.renew(), await http.listening(context));
|
||||
|
|
@ -316,6 +341,8 @@ type Surface<P extends Principal> = {
|
|||
readonly facet: Facet<P>;
|
||||
step(name: string): RolloutStep;
|
||||
readonly base: string;
|
||||
/** The files its base bundle was built from. */
|
||||
readonly inputs: readonly string[];
|
||||
};
|
||||
|
||||
/** The app's client surfaces, built (their base bundles) in order: the web's, the terminal's. */
|
||||
|
|
@ -411,6 +438,7 @@ function workersOf<P extends Principal>(
|
|||
files: { ...options.files },
|
||||
telemetry: options.telemetry !== undefined,
|
||||
...(options.limits?.uploads === undefined ? {} : { uploads: options.limits.uploads }),
|
||||
timeouts: { ...options.timeouts },
|
||||
};
|
||||
return { kit, app: await kind.start(address, ctx) };
|
||||
},
|
||||
|
|
@ -453,6 +481,8 @@ class RunningApp<P extends Principal> implements App<P> {
|
|||
readonly #options: AppOptions<P>;
|
||||
#link = "";
|
||||
#closing: Promise<void> | undefined;
|
||||
/** The latest `applyChanges`, settled to nothing: the next one starts after it. */
|
||||
#applying: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(
|
||||
host: Host<P>,
|
||||
|
|
@ -498,7 +528,19 @@ class RunningApp<P extends Principal> implements App<P> {
|
|||
return this.#surfaces.find(({ name }) => name === "tui");
|
||||
}
|
||||
|
||||
async applyChanges(context: Context): Promise<RolloutResult> {
|
||||
applyChanges(context: Context): Promise<RolloutResult> {
|
||||
// One build and rollout at a time: a later build rolled out before an earlier one would leave the older code
|
||||
// deployed (each host applies the last set it is handed).
|
||||
const applied = this.#applying.then(() => this.#applyChanges(context));
|
||||
this.#applying = applied.then(
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
return applied;
|
||||
}
|
||||
|
||||
/** One build of the dirty plugins and its rollout: the server, every worker kind, then the clients. */
|
||||
async #applyChanges(context: Context): Promise<RolloutResult> {
|
||||
const plugins = this.#component;
|
||||
const built = await plugins.build(plugins.state.value.dirty, context);
|
||||
const steps = [
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export {
|
|||
type AppKind,
|
||||
type AppOptions,
|
||||
type AppPlugins,
|
||||
type AppTimeouts,
|
||||
type AppWorkerKind,
|
||||
Server,
|
||||
startServer,
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ export function startWorker<P extends Principal>(options: WorkerOptions<P>): voi
|
|||
store,
|
||||
telemetry: recorder,
|
||||
...(principalAttributes === undefined ? {} : { principalAttributes }),
|
||||
...kit.timeouts,
|
||||
onError: options.onError,
|
||||
});
|
||||
onClose((ctx) => host.close(ctx));
|
||||
|
|
|
|||
|
|
@ -133,6 +133,11 @@ export interface Plugins {
|
|||
rollout(steps: readonly RolloutStep[], built: Built, context: Context): Promise<RolloutResult>;
|
||||
/** Installs plugin `name`'s dependencies (`installDependencies`): its install mark clears, and it is dirty. */
|
||||
installDependencies(name: string, context: Context): Promise<void>;
|
||||
/**
|
||||
* Adds `files` to the app's base code (beside `base`): a change of one sets `base`. For files the app learns after
|
||||
* creating the component, a base bundle's inputs; files under `node_modules` are never watched.
|
||||
*/
|
||||
watchBase(files: readonly string[]): void;
|
||||
/**
|
||||
* Installs new plugins from git or npm into `root`, one of the roots (`installPlugin`), and marks `set`: the next
|
||||
* build discovers and builds them. Returns their names.
|
||||
|
|
@ -288,6 +293,10 @@ class PluginsComponent implements Plugins {
|
|||
return { steps: done, stopped: undefined };
|
||||
}
|
||||
|
||||
watchBase(files: readonly string[]): void {
|
||||
this.#watcher.baseFiles(files);
|
||||
}
|
||||
|
||||
async installDependencies(name: string, context: Context): Promise<void> {
|
||||
if (!this.#discovered) await this.#discover(context);
|
||||
const plugin = this.#discovery.plugins.find((each) => each.name === name);
|
||||
|
|
|
|||
|
|
@ -49,6 +49,10 @@ export class PluginWatcher extends Source {
|
|||
readonly #options: WatcherOptions;
|
||||
readonly #roots: readonly string[];
|
||||
readonly #baseDirs: readonly string[];
|
||||
/** Base code given file by file (`baseFiles`), by real path. */
|
||||
readonly #baseFiles = new Set<string>();
|
||||
/** The watches of the directories holding those files, each of the directory alone. */
|
||||
readonly #baseWatchers = new Map<string, FSWatcher>();
|
||||
/** The trees it watches: the roots, the base code, and plugin directories linked from outside the roots. */
|
||||
readonly #trees = new Set<string>();
|
||||
/** Its watches, one per directory of its trees. */
|
||||
|
|
@ -174,10 +178,23 @@ export class PluginWatcher extends Source {
|
|||
this.#changed();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds `files` to the base code: a change of one sets `base`. Each one's directory is watched alone (not its tree);
|
||||
* files under ignored directories are skipped.
|
||||
*/
|
||||
baseFiles(files: readonly string[]): void {
|
||||
for (const file of files) {
|
||||
const path = real(file);
|
||||
if (path.split(sep).some((part) => IGNORED.has(part))) continue;
|
||||
this.#baseFiles.add(path);
|
||||
this.#watchBaseDir(dirname(path));
|
||||
}
|
||||
}
|
||||
|
||||
/** Marks what a change of the file or directory at `path` (absolute) asks for. */
|
||||
mark(path: string): void {
|
||||
if (path.split(sep).some((part) => IGNORED.has(part))) return;
|
||||
if (this.#baseDirs.some((dir) => inside(dir, path))) {
|
||||
if (this.#baseFiles.has(path) || this.#baseDirs.some((dir) => inside(dir, path))) {
|
||||
this.#baseChanged = true;
|
||||
} else {
|
||||
this.#markPlugin(path, this.#generation++);
|
||||
|
|
@ -187,8 +204,9 @@ export class PluginWatcher extends Source {
|
|||
|
||||
/** Stops watching. */
|
||||
close(): void {
|
||||
for (const watcher of this.#watchers.values()) watcher.close();
|
||||
for (const watcher of [...this.#watchers.values(), ...this.#baseWatchers.values()]) watcher.close();
|
||||
this.#watchers.clear();
|
||||
this.#baseWatchers.clear();
|
||||
this.#trees.clear();
|
||||
}
|
||||
|
||||
|
|
@ -223,6 +241,22 @@ export class PluginWatcher extends Source {
|
|||
return this.#options.kinds.some((kind) => FACET_EXTENSIONS.some((extension) => file === `${kind}${extension}`));
|
||||
}
|
||||
|
||||
/** Watches directory `dir` alone (once) for base files: a change of one of them marks `base`. */
|
||||
#watchBaseDir(dir: string): void {
|
||||
if (this.#baseWatchers.has(dir) || this.#reported) return;
|
||||
let watcher: FSWatcher;
|
||||
try {
|
||||
watcher = watch(dir, (_event, file) => {
|
||||
if (file !== null && this.#baseFiles.has(join(dir, file))) this.mark(join(dir, file));
|
||||
});
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") this.#failed(error);
|
||||
return;
|
||||
}
|
||||
watcher.on("error", (error) => this.#failed(error));
|
||||
this.#baseWatchers.set(dir, watcher);
|
||||
}
|
||||
|
||||
/** Watches the tree at `dir` (once): it and every directory under it but ignored ones; a missing one is skipped. */
|
||||
#watchTree(dir: string): void {
|
||||
if (this.#trees.has(dir)) return;
|
||||
|
|
|
|||
|
|
@ -4,19 +4,20 @@
|
|||
// a terminal's one CommonJS script the sandbox evaluates with its own `require` (its allowed builtins only).
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { basename, extname } from "node:path";
|
||||
import { basename, extname, resolve } from "node:path";
|
||||
import { build } from "esbuild";
|
||||
import { CSS, JAVASCRIPT, mimeOf, type ServedFile } from "./served.ts";
|
||||
|
||||
/**
|
||||
* A built base bundle: its hash, its script's and stylesheet's paths (under the surface's prefix), and every file by
|
||||
* path.
|
||||
* A built base bundle: its hash, its script's and stylesheet's paths (under the surface's prefix), every file by
|
||||
* path, and the files esbuild read for it (absolute: the app's base code a change of which outdates the bundle).
|
||||
*/
|
||||
export type BaseBundle = {
|
||||
readonly hash: string;
|
||||
readonly script: string;
|
||||
readonly style: string | undefined;
|
||||
readonly files: ReadonlyMap<string, ServedFile>;
|
||||
readonly inputs: readonly string[];
|
||||
};
|
||||
|
||||
/** Where a base bundle runs: a page (a script, its CSS and assets), or a terminal's sandbox (one CommonJS script). */
|
||||
|
|
@ -47,6 +48,7 @@ export async function buildBase(entry: string, platform: BasePlatform, prefix: s
|
|||
jsx: "automatic",
|
||||
define: { "process.env.NODE_ENV": '"production"' },
|
||||
logLevel: "silent",
|
||||
metafile: true,
|
||||
});
|
||||
const hash = createHash("sha256");
|
||||
for (const output of [...result.outputFiles].sort((a, b) => (a.path < b.path ? -1 : 1))) {
|
||||
|
|
@ -67,5 +69,9 @@ export async function buildBase(entry: string, platform: BasePlatform, prefix: s
|
|||
files.set(`${prefix}assets/${basename(output.path)}`, { mime: mimeOf(output.path), bytes: output.contents });
|
||||
}
|
||||
}
|
||||
return { hash: digest, script, style, files };
|
||||
// Metafile inputs are relative to the working directory; a namespaced one (`ns:path`) is no file.
|
||||
const inputs = Object.keys(result.metafile.inputs)
|
||||
.filter((input) => !/^[a-z-]+:/.test(input))
|
||||
.map((input) => resolve(input));
|
||||
return { hash: digest, script, style, files, inputs };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ export type TuiSurface<P extends Principal> = {
|
|||
readonly facet: Facet<P>;
|
||||
step(name: string): RolloutStep;
|
||||
readonly base: string;
|
||||
/** The files its base bundle was built from: the app's base code for this surface. */
|
||||
readonly inputs: readonly string[];
|
||||
};
|
||||
|
||||
/** Where the terminal surface's files live. */
|
||||
|
|
@ -63,5 +65,5 @@ export async function tuiSurface<P extends Principal>(
|
|||
},
|
||||
});
|
||||
});
|
||||
return { facet, step: (name) => published.step(name), base: base.hash };
|
||||
return { facet, step: (name) => published.step(name), base: base.hash, inputs: base.inputs };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@ export type WebSurface<P extends Principal> = {
|
|||
/** The rollout step publishing the web kind's current set (after the server's step). */
|
||||
step(name: string): RolloutStep;
|
||||
readonly base: string;
|
||||
/** The files its base bundle was built from: the app's base code for this surface. */
|
||||
readonly inputs: readonly string[];
|
||||
};
|
||||
|
||||
/** Where the web surface's files live. */
|
||||
|
|
@ -75,7 +77,7 @@ export async function webSurface<P extends Principal>(
|
|||
scripts: [WEB_PREFIX],
|
||||
});
|
||||
});
|
||||
return { facet, step: (name) => published.step(name), base: base.hash };
|
||||
return { facet, step: (name) => published.step(name), base: base.hash, inputs: base.inputs };
|
||||
}
|
||||
|
||||
/** The surface's routes: its files (served forever), its current base hash (public), and the page. */
|
||||
|
|
|
|||
|
|
@ -51,7 +51,10 @@ within each link's limits, compression at the origin, activation chains and revo
|
|||
it does not know is ignored (Q1), one reusing a live id is a protocol error, and so are more running calls than this
|
||||
side announced in `hello` (N = 256 per origin; a call on an unknown hold is ignored before it is counted). Its table
|
||||
has two kinds of entries: `Hold` (served here) and `RelayedHold` (relayed, never hydrated). A call on a pending hold
|
||||
is answered `not_ready`; an aborted, released or gone call gets no reply. While the host closes, its holds are only
|
||||
is answered `not_ready`, one crossing a `failed` or a removal with the cause its consumer was told; a call sent with
|
||||
the indexes of a binding its consumer has not acknowledged a newer `bound` of yet is decoded by them (another
|
||||
variant's indexes name other methods; one the new variant lacks fails `invalid/member`); an aborted, released or gone
|
||||
call gets no reply. While the host closes, its holds are only
|
||||
dropped (the host ends the objects itself). It sends with its origin. A hold's `chain` is kept only from a trusted
|
||||
link and its stamped `limit` only from a parent link; its decoded limit is the smaller of the link's and the stamped
|
||||
one, which a relayed hold carries upstream as `limit`. A call on a relayed hold whose arguments are past the link's
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
import type { TelemetryContext } from "@earendil-works/pi-telemetry";
|
||||
import type { Context } from "../context/index.ts";
|
||||
import { type VariantData, type VariantNames, variantNames, variantOrder, variantsAt } from "../contracts/internal.ts";
|
||||
import { type ChordError, type FailureCause, isChordError, type Principal } from "../data/index.ts";
|
||||
import { ChordError, type FailureCause, isChordError, type Principal } from "../data/index.ts";
|
||||
import { asChordError } from "../data/internal.ts";
|
||||
import { type Encoder, encoder, type Op, PendingOps } from "../delta/index.ts";
|
||||
import {
|
||||
|
|
@ -67,8 +67,8 @@ type Followed = {
|
|||
|
||||
/**
|
||||
* Where a hold stands: `unplaced` in the unbound index until the host's publication places it; `pending` until its
|
||||
* object is live (the activation, parking and retries; `toldFailed` once the consumer was told of a failure, so the
|
||||
* next attempt is announced with `loading`); `binding` while its object is live and its `bound` waits for a slot;
|
||||
* object is live (the activation, parking and retries; `told`: the failure its consumer was told, so the next attempt
|
||||
* is announced with `loading` and a call that crossed the failure on the wire gets it); `binding` while its object is live and its `bound` waits for a slot;
|
||||
* `bound` with the object's variant and the members it follows. A `pending` hold unbound with `loading` and the
|
||||
* `binding` after it, or a `binding` after a swap in place, keep the names it was bound with, `previous`: its consumer's
|
||||
* calls sent by those names (before it learnt of the `loading`, or still bound) wait at its object's gate and are
|
||||
|
|
@ -76,7 +76,7 @@ type Followed = {
|
|||
*/
|
||||
type HoldState =
|
||||
| { readonly kind: "unplaced"; readonly request: HoldRequest }
|
||||
| { readonly kind: "pending"; readonly toldFailed: boolean; readonly previous?: BoundNames }
|
||||
| { readonly kind: "pending"; readonly told?: ChordError; readonly previous?: BoundNames }
|
||||
| { readonly kind: "binding"; readonly previous?: BoundNames }
|
||||
| {
|
||||
readonly kind: "bound";
|
||||
|
|
@ -88,6 +88,11 @@ type HoldState =
|
|||
readonly followed: Followed[];
|
||||
/** The instance it was bound to: a swap brings another, which it binds to afresh. */
|
||||
readonly instance: unknown;
|
||||
/**
|
||||
* A rebinding's names before it (`until`: its `bound`, this host's own message number): calls its consumer sent
|
||||
* before it applied that `bound` carry their indexes, so they are decoded by them until its `ack`.
|
||||
*/
|
||||
readonly previous?: BoundNames & { readonly until: number };
|
||||
}
|
||||
/**
|
||||
* Refused where it arrived, with `cause`: `waiting` (not served here, another version, placed elsewhere by the
|
||||
|
|
@ -103,7 +108,7 @@ type Bound = Extract<HoldState, { readonly kind: "bound" }>;
|
|||
/** What calls need of a binding: its variant's data at the hold's version and the names the wire's indexes point into. */
|
||||
type BoundNames = { readonly variant: VariantData; readonly names: VariantNames };
|
||||
|
||||
const PENDING: HoldState = { kind: "pending", toldFailed: false };
|
||||
const PENDING: HoldState = { kind: "pending" };
|
||||
|
||||
const BINDING: HoldState = { kind: "binding" };
|
||||
|
||||
|
|
@ -163,10 +168,27 @@ export class Hold implements Holder, UnboundRequest {
|
|||
return this.#names() !== undefined;
|
||||
}
|
||||
|
||||
/** The names its calls are decoded by: its binding's, or the ones it was bound with while it rebinds. */
|
||||
/**
|
||||
* What a call it does not take is refused with: the failure its consumer was told (refused, or failed while pending:
|
||||
* the call crossed that `failed` on the wire, and its consumer shows that cause), else `not_ready` (not bound yet).
|
||||
*/
|
||||
get refusal(): ChordError {
|
||||
const state = this.#state;
|
||||
if (state.kind === "refused") return state.cause.error;
|
||||
if (state.kind === "pending" && state.told !== undefined) return state.told;
|
||||
return new ChordError({ code: "not_ready", message: `hold ${this.id}: not bound yet` });
|
||||
}
|
||||
|
||||
/**
|
||||
* The names its calls are decoded by: its binding's (its previous ones until its consumer acknowledged the `bound`
|
||||
* that brought them: another variant's indexes name other methods), or the ones it was bound with while it rebinds.
|
||||
*/
|
||||
#names(): BoundNames | undefined {
|
||||
const state = this.#state;
|
||||
if (state.kind === "bound") return state;
|
||||
if (state.kind === "bound") {
|
||||
const { previous } = state;
|
||||
return previous !== undefined && !this.#provider.acknowledged(previous.until) ? previous : state;
|
||||
}
|
||||
return state.kind === "pending" || state.kind === "binding" ? state.previous : undefined;
|
||||
}
|
||||
|
||||
|
|
@ -311,20 +333,25 @@ export class Hold implements Holder, UnboundRequest {
|
|||
return;
|
||||
}
|
||||
this.#provider.unbind(this);
|
||||
this.#state = previous === undefined ? PENDING : { kind: "pending", toldFailed: false, previous };
|
||||
this.#state =
|
||||
previous === undefined
|
||||
? PENDING
|
||||
: { kind: "pending", previous: { variant: previous.variant, names: previous.names } };
|
||||
this.#provider.send({ kind: "loading", hold: this.id });
|
||||
}
|
||||
|
||||
/**
|
||||
* A swap of its object went live in place (slice 7b §5, audit A9): no longer subscribed to the old instance, it gets
|
||||
* a fresh `bound` of the new one's members (as a restart, a variant or a version change does), answering calls by
|
||||
* its old names until that is sent.
|
||||
* the names its consumer has until it applied that.
|
||||
*/
|
||||
#rebind(state: Bound): void {
|
||||
// The names its consumer has: the old binding's, or older ones while it has not applied that `bound` yet.
|
||||
const { variant, names } = this.#names()!;
|
||||
unfollow(state);
|
||||
// Its ops still waiting for a slot are the old instance's: the `bound` replaces them.
|
||||
this.#provider.unbind(this);
|
||||
this.#state = { kind: "binding", previous: { variant: state.variant, names: state.names } };
|
||||
this.#state = { kind: "binding", previous: { variant, names } };
|
||||
this.#provider.bind(this, this.#provider.link.context);
|
||||
}
|
||||
|
||||
|
|
@ -359,8 +386,8 @@ export class Hold implements Holder, UnboundRequest {
|
|||
const failed = { kind: cause.kind, error: wireError(cause.error) };
|
||||
this.#provider.send({ kind: "failed", hold: this.id, cause: failed, ...retryIn });
|
||||
// Told a failure, its consumer rejects what it still waits on: calls by the old names are not taken any more.
|
||||
this.#state = { kind: "pending", toldFailed: true };
|
||||
} else if (state.kind === "loading" && current.toldFailed) {
|
||||
this.#state = { kind: "pending", told: cause.error };
|
||||
} else if (state.kind === "loading" && current.told !== undefined) {
|
||||
this.#provider.send({ kind: "loading", hold: this.id });
|
||||
this.#state = PENDING;
|
||||
}
|
||||
|
|
@ -505,15 +532,22 @@ export class Hold implements Holder, UnboundRequest {
|
|||
this.#provider.send({ kind: "gone", hold: this.id, error: wireError(error) });
|
||||
}
|
||||
|
||||
/** The binding: its variant and names at the hold's version, with no member followed yet. */
|
||||
/**
|
||||
* The binding: its variant and names at the hold's version, with no member followed yet, and the names its consumer
|
||||
* still has (a rebinding's) until it acknowledged this `bound`, the next of this host's own messages.
|
||||
*/
|
||||
#bindTo(object: ChordObject): Bound {
|
||||
const variant = variantsAt(object.served.contract, this.contract.version).get(object.variant!)!;
|
||||
const previous = this.#names();
|
||||
const state: Bound = {
|
||||
kind: "bound",
|
||||
variant,
|
||||
names: variantNames(variant),
|
||||
followed: [],
|
||||
instance: object.instance,
|
||||
...(previous === undefined
|
||||
? {}
|
||||
: { previous: { variant: previous.variant, names: previous.names, until: this.#provider.nextOwn } }),
|
||||
};
|
||||
this.#state = state;
|
||||
return state;
|
||||
|
|
|
|||
|
|
@ -62,6 +62,9 @@ export class ProviderSide {
|
|||
readonly #sent: Sender[] = [];
|
||||
/** This host's own messages in flight. */
|
||||
#inFlight = 0;
|
||||
/** How many of this host's own `bound` and `state` messages it sent, and how many its consumer acknowledged. */
|
||||
#ownSent = 0;
|
||||
#ownAcked = 0;
|
||||
/** The holds the current batch touched, in order; its flush sends or queues them. */
|
||||
#touched: Hold[] = [];
|
||||
/** Holds whose ops wait in their pending queues for a slot. */
|
||||
|
|
@ -406,7 +409,7 @@ export class ProviderSide {
|
|||
return;
|
||||
}
|
||||
if (!hold.callable) {
|
||||
this.#refuse(message.call, new ChordError({ code: "not_ready", message: `hold ${hold.id}: not bound yet` }));
|
||||
this.#refuse(message.call, hold.refusal);
|
||||
return;
|
||||
}
|
||||
const method = hold.method(message.method);
|
||||
|
|
@ -494,9 +497,23 @@ export class ProviderSide {
|
|||
return;
|
||||
}
|
||||
this.#inFlight--;
|
||||
this.#ownAcked++;
|
||||
this.#slotFreed();
|
||||
}
|
||||
|
||||
/** The number the next of this host's own `bound` and `state` messages gets (counted from 1). */
|
||||
get nextOwn(): number {
|
||||
return this.#ownSent + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether its consumer acknowledged this host's own message number `own`, so applied it: a call it sent after that
|
||||
* arrives after the `ack` (one direction keeps its order).
|
||||
*/
|
||||
acknowledged(own: number): boolean {
|
||||
return this.#ownAcked >= own;
|
||||
}
|
||||
|
||||
/**
|
||||
* The end of a batch: while a slot is free and nothing waits, one `state` message with an entry per touched hold;
|
||||
* otherwise each touched hold's ops join its pending queues until a slot frees.
|
||||
|
|
@ -578,6 +595,7 @@ export class ProviderSide {
|
|||
/** Sends one of this host's own `bound` or `state` messages, taking a slot. */
|
||||
#sendOwn(message: Extract<OriginMessage, { readonly kind: "bound" | "state" }>): void {
|
||||
this.#inFlight++;
|
||||
this.#ownSent++;
|
||||
this.#sent.push("local");
|
||||
if (message.kind === "state") this.link.host.metrics.counter("chord.link.state_messages").add(1);
|
||||
this.send(message);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
// A client host with its plugins (slice 11 §2, §4, decisions "Slice 11, P4" to "P6"): what a page and a terminal's
|
||||
// sandbox both run. A client host (no address) loads the plugin scripts it started with beside the app's core facets,
|
||||
// keeps its link to the server with the connection loop, and holds the kind's scripts object: a new set fetches the
|
||||
// changed scripts and reloads the host in place (a failed plugin keeps its old facet); a new base bundle means this
|
||||
// client's code is outdated, which the app answers by starting over (a page reloads). The surface specifics (how a
|
||||
// socket opens, how a script is fetched, what is outdated) are given.
|
||||
// changed scripts and reloads the host in place (a failed plugin keeps its old facet; a failed fetch is tried again);
|
||||
// a new base bundle means this client's code is outdated, which the app answers by starting over (a page reloads). The
|
||||
// surface specifics (how a socket opens, how a script is fetched, what is outdated) are given.
|
||||
|
||||
import type { TelemetryContext } from "@earendil-works/pi-telemetry";
|
||||
import { BACKGROUND_CONTEXT, type Context, withAbortSignal } from "../context/index.ts";
|
||||
|
|
@ -158,9 +158,17 @@ function entryOf(script: RegisteredScript, modules: ClientModules): SetEntry {
|
|||
};
|
||||
}
|
||||
|
||||
/** The first pause before a failed update is tried again: 1 s, doubling from there. */
|
||||
const UPDATE_RETRY_INITIAL_MS = 1000;
|
||||
|
||||
/** The longest pause between tries of a failed update: 30 s. */
|
||||
const UPDATE_RETRY_MAX_MS = 30_000;
|
||||
|
||||
/**
|
||||
* The client's plugin updates: it holds the scripts object and applies each new set in order (fetching the changed
|
||||
* scripts, reloading the host with the complete set); a new base calls `outdated` instead.
|
||||
* scripts, reloading the host with the complete set); a new base calls `outdated` instead. An update that failed (a
|
||||
* script that did not load) is tried again after a growing pause (1 s doubling to 30 s) until it applies or a newer set
|
||||
* replaces it: the scripts object shows nothing new until the next rollout or reconnect.
|
||||
*/
|
||||
class Updates<P extends Principal> {
|
||||
readonly #host: Host<P>;
|
||||
|
|
@ -170,6 +178,13 @@ class Updates<P extends Principal> {
|
|||
readonly #scripts = new Map<string, RegisteredScript>();
|
||||
readonly #abort = new AbortController();
|
||||
#release: (() => void) | undefined;
|
||||
/** The newest set shown: a retry applies only it. */
|
||||
#latest: ScriptSet | undefined;
|
||||
/** The updates in order: one at a time, a retry's among them. */
|
||||
#applying: Promise<void> = Promise.resolve();
|
||||
/** The pending retry's cancel and the pause it waited (0: none waited yet). */
|
||||
#retry: { cancel: () => void } | undefined;
|
||||
#pause = 0;
|
||||
|
||||
constructor(host: Host<P>, loaded: LoadedFacets, options: ClientOptions<P>) {
|
||||
this.#host = host;
|
||||
|
|
@ -184,22 +199,54 @@ class Updates<P extends Principal> {
|
|||
this.#release = () => held.release();
|
||||
const watch = await held.set.watch(withAbortSignal(this.#abort.signal, context));
|
||||
watch.start(async (call, ctx) => {
|
||||
if (call.kind === "value") await this.#apply(call.value as ScriptSet, ctx);
|
||||
if (call.kind !== "value") return;
|
||||
const set = call.value as ScriptSet;
|
||||
this.#latest = set;
|
||||
this.#retry?.cancel();
|
||||
this.#retry = undefined;
|
||||
this.#pause = 0;
|
||||
await this.#queue(set, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
/** Stops following: the watch ends, the hold is released. */
|
||||
/** Stops following: the watch ends, the hold is released, a pending retry is dropped. */
|
||||
async stop(): Promise<void> {
|
||||
this.#abort.abort();
|
||||
this.#retry?.cancel();
|
||||
this.#retry = undefined;
|
||||
this.#release?.();
|
||||
}
|
||||
|
||||
/** Applies `set`: a new base calls `outdated`; else the changed scripts fetched and the host reloaded with all. */
|
||||
async #apply(set: ScriptSet, context: Context): Promise<void> {
|
||||
/** Applies `set` after the updates before it, unless a newer set replaced it meanwhile. */
|
||||
#queue(set: ScriptSet, context: Context): Promise<void> {
|
||||
const applied = this.#applying.then(async () => {
|
||||
if (this.#latest !== set || this.#abort.signal.aborted) return;
|
||||
if (!(await this.#apply(set, context))) this.#retryLater(set, context);
|
||||
});
|
||||
this.#applying = applied;
|
||||
return applied;
|
||||
}
|
||||
|
||||
/** Tries `set` again after the next pause (1 s doubling to 30 s). */
|
||||
#retryLater(set: ScriptSet, context: Context): void {
|
||||
this.#pause = this.#pause === 0 ? UPDATE_RETRY_INITIAL_MS : Math.min(this.#pause * 2, UPDATE_RETRY_MAX_MS);
|
||||
this.#retry = {
|
||||
cancel: this.#options.environment.after(this.#pause, () => {
|
||||
this.#retry = undefined;
|
||||
void this.#queue(set, context);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies `set`: a new base calls `outdated`; else the changed scripts fetched and the host reloaded with all.
|
||||
* `false` when it failed (reported `update_failed`): it is tried again.
|
||||
*/
|
||||
async #apply(set: ScriptSet, context: Context): Promise<boolean> {
|
||||
const options = this.#options;
|
||||
if (set.base !== options.base) {
|
||||
options.outdated();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
// The first frame (and a reconnect's snapshot) usually shows what this client runs already: nothing to reload.
|
||||
const known = this.#scripts;
|
||||
|
|
@ -208,7 +255,7 @@ class Updates<P extends Principal> {
|
|||
set.plugins.every((entry) => known.get(entry.name)?.digest === entry.digest)
|
||||
) {
|
||||
options.applied?.(set);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const scripts: RegisteredScript[] = [];
|
||||
|
|
@ -222,8 +269,10 @@ class Updates<P extends Principal> {
|
|||
for (const script of scripts) this.#scripts.set(script.plugin, script);
|
||||
if (report.failed.length > 0) options.onError({ kind: "plugins", failed: report.failed });
|
||||
options.applied?.(set);
|
||||
return true;
|
||||
} catch (error) {
|
||||
options.onError({ kind: "update_failed", error });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,12 +5,12 @@
|
|||
import { chmod, mkdir, readdir, stat, symlink, writeFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
|
||||
import { hostKind, local, remote, str, unknown } from "../../../src/contracts/index.ts";
|
||||
import { addressParam, HostData } from "../../../src/node/app/data.ts";
|
||||
import { AppInfo } from "../../../src/node/app/index.ts";
|
||||
import { rejection } from "../../support.ts";
|
||||
import { changesOf, rejection } from "../../support.ts";
|
||||
import { send } from "../http/support.ts";
|
||||
import { gitRepository, writePlugin } from "../plugins/support.ts";
|
||||
import { ECHO_SERVER, NOTES_PLUGIN, Notes, Project, tempDir, testApp, WORKER_ENTRY } from "./support.ts";
|
||||
|
|
@ -180,6 +180,78 @@ describe("workers", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("applying changes one after another", () => {
|
||||
it("starts a later applyChanges after the earlier one rolled out, so the newest build stays deployed", async () => {
|
||||
const { app, root } = await testApp({ plugins: { echo: { "server.ts": ECHO_SERVER } } });
|
||||
const echo = await app.host.get(Echo, {}, context);
|
||||
const component = app.plugins.component;
|
||||
const build = component.build.bind(component);
|
||||
const rollout = component.rollout.bind(component);
|
||||
const events: string[] = [];
|
||||
const built = Promise.withResolvers<void>();
|
||||
const gate = Promise.withResolvers<void>();
|
||||
// The first build read its files, then waits: unserialized, the second would build and roll out meanwhile.
|
||||
component.build = async (names, ctx) => {
|
||||
const n = events.filter((event) => event.startsWith("build")).length + 1;
|
||||
events.push(`build ${n}`);
|
||||
const result = await build(names, ctx);
|
||||
if (n === 1) {
|
||||
built.resolve();
|
||||
await gate.promise;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
component.rollout = async (steps, result, ctx) => {
|
||||
events.push("rollout");
|
||||
return rollout(steps, result, ctx);
|
||||
};
|
||||
const marks = changesOf(await app.plugins.state.watch(context));
|
||||
const edit = async (text: string): Promise<void> => {
|
||||
const seen = marks.length;
|
||||
await writeFile(join(root, "echo", "server.ts"), ECHO_SERVER.replace('"echo"', JSON.stringify(text)));
|
||||
await vi.waitFor(() => expect(marks.length).toBeGreaterThan(seen), { timeout: 5_000 });
|
||||
};
|
||||
try {
|
||||
await edit("v2");
|
||||
const first = app.applyChanges(context);
|
||||
await built.promise;
|
||||
await edit("v3");
|
||||
const second = app.applyChanges(context);
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(events).toEqual(["build 1"]);
|
||||
gate.resolve();
|
||||
await first;
|
||||
await second;
|
||||
expect(events).toEqual(["build 1", "rollout", "build 2", "rollout"]);
|
||||
expect(echo.text.value).toBe("v3");
|
||||
} finally {
|
||||
echo.release();
|
||||
await app.close(context);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("base code", () => {
|
||||
it("marks base for a change of `base`'s files and directories and of a worker kind's entry", async () => {
|
||||
const dir = await tempDir();
|
||||
await mkdir(join(dir, "api"));
|
||||
await writeFile(join(dir, "api", "v1.ts"), "export {};");
|
||||
await writeFile(join(dir, "start.ts"), "export {};");
|
||||
await writeFile(join(dir, "worker.ts"), "export {};");
|
||||
const worker = { kind: Project, entry: join(dir, "worker.ts"), start: () => null, authorize: () => true };
|
||||
for (const changed of [join(dir, "api", "v1.ts"), join(dir, "start.ts"), join(dir, "worker.ts")]) {
|
||||
const { app } = await testApp({ base: [join(dir, "api"), join(dir, "start.ts")], workers: [worker] });
|
||||
try {
|
||||
expect(app.plugins.state.value.base).toBe(false);
|
||||
await writeFile(changed, "export const changed = 1;");
|
||||
await vi.waitFor(() => expect(app.plugins.state.value.base).toBe(true), { timeout: 5_000 });
|
||||
} finally {
|
||||
await app.close(context);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("installs", () => {
|
||||
it("installs a plugin from git into the user's root; applying changes loads it", async () => {
|
||||
const tools = await tempDir();
|
||||
|
|
|
|||
306
packages/chord/test/node/fixtures/reload/app.ts
Normal file
306
packages/chord/test/node/fixtures/reload/app.ts
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
// The reload suite's app (design §21 "What a user sees during a reload"): a kit app with the web surface (the suite's
|
||||
// base bundle, `entry.ts` starting `main.ts`) and the tests' project workers, short host timeouts, its root holding the
|
||||
// suite's plugins; a real page signed in as the owner; and the test's hands: editing a plugin on disk and applying the changes, the latches of calls in
|
||||
// flight, and what the page shows and answers (`ReloadPage`).
|
||||
|
||||
import { rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { BACKGROUND_CONTEXT as context } from "../../../../src/context/index.ts";
|
||||
import { arr, bool, hostKind, method, obj, remote, str } from "../../../../src/contracts/index.ts";
|
||||
import { Sessions } from "../../../../src/node/app/index.ts";
|
||||
import type { RolloutResult } from "../../../../src/node/plugins/index.ts";
|
||||
import { tempDir, testApp } from "../../app/support.ts";
|
||||
import { writePlugin } from "../../plugins/support.ts";
|
||||
import { type Browser, poll } from "../../web/browser.ts";
|
||||
import { freePort, page } from "../../web/support.ts";
|
||||
import type { CallResult } from "./web-api.ts";
|
||||
|
||||
/** The suite's base bundle entry, and its page (what another entry starts). */
|
||||
export const ENTRY = fileURLToPath(new URL("./entry.ts", import.meta.url));
|
||||
|
||||
const MAIN = fileURLToPath(new URL("./main.ts", import.meta.url));
|
||||
|
||||
/** A started reload app: the kit's app and what it reported, its root, port and data directory. */
|
||||
export type ReloadApp = Awaited<ReturnType<typeof testApp>>;
|
||||
|
||||
/**
|
||||
* The hosts' timeouts of the suite's apps (the server's and the workers'): a call outliving a restart's drain is cut
|
||||
* off after 2 s, a call waits at most 5 s for its object to go live.
|
||||
*/
|
||||
export const TIMEOUTS = { closeTimeout: 2000, readyTimeout: 5000 } as const;
|
||||
|
||||
/**
|
||||
* Starts the suite's app with `plugins` (directory name to files; each plugin is `@test/<dir>`) and project workers;
|
||||
* `entry` is the base bundle's entry (the suite's by default), `port` and `dataDir` fixed for a restart.
|
||||
*/
|
||||
export async function startReloadApp(
|
||||
plugins: Readonly<Record<string, Readonly<Record<string, string>>>>,
|
||||
more: { readonly entry?: string; readonly port?: number; readonly dataDir?: string; readonly root?: string } = {},
|
||||
): Promise<ReloadApp> {
|
||||
return testApp({
|
||||
kinds: {
|
||||
server: { platform: "node" },
|
||||
worker: { platform: "node" },
|
||||
web: { platform: "browser", packages: { react: {}, "react/jsx-runtime": {} } },
|
||||
},
|
||||
listen: { host: "127.0.0.1", port: more.port ?? (await freePort()) },
|
||||
web: { entry: more.entry ?? ENTRY, page },
|
||||
timeouts: TIMEOUTS,
|
||||
plugins,
|
||||
withWorkers: true,
|
||||
...(more.dataDir === undefined ? {} : { dataDir: more.dataDir }),
|
||||
...(more.root === undefined ? {} : { root: more.root }),
|
||||
});
|
||||
}
|
||||
|
||||
/** A base bundle entry in a new directory: the suite's, with `version` on `globalThis` (another base bundle). */
|
||||
export async function versionedMain(version: string, dir?: string): Promise<string> {
|
||||
const at = dir ?? (await tempDir());
|
||||
const text = `import { start } from ${JSON.stringify(MAIN)};\n(globalThis as Record<string, unknown>).version = ${JSON.stringify(version)};\nstart();\n`;
|
||||
await writeFile(join(at, "main.ts"), text);
|
||||
return join(at, "main.ts");
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes `files` into plugin `dir` of the app's root (an edit on disk), waits until the watcher marked it dirty, and
|
||||
* applies the changes (the rollout: server, workers, clients).
|
||||
*/
|
||||
export async function editAndApply(
|
||||
app: ReloadApp,
|
||||
dir: string,
|
||||
files: Readonly<Record<string, string>>,
|
||||
): Promise<RolloutResult> {
|
||||
await edit(app, dir, files);
|
||||
return app.app.applyChanges(context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes `files` into plugin `dir` of the app's root and waits until the watcher marked it dirty (or, for a plugin not
|
||||
* discovered yet, the plugin set changed).
|
||||
*/
|
||||
export async function edit(app: ReloadApp, dir: string, files: Readonly<Record<string, string>>): Promise<void> {
|
||||
await writePlugin(app.root, dir, `@test/${dir}`, files);
|
||||
const name = `@test/${dir}`;
|
||||
const marked = (): boolean => app.app.plugins.state.value.dirty.includes(name) || app.app.plugins.state.value.set;
|
||||
await poll(marked, 30_000, `${name} marked`);
|
||||
}
|
||||
|
||||
/** Removes plugin `dir` from the app's root, waits until the watcher saw the plugin set change, and applies it. */
|
||||
export async function removeAndApply(app: ReloadApp, dir: string): Promise<RolloutResult> {
|
||||
await rm(join(app.root, dir), { recursive: true, force: true });
|
||||
await poll(() => app.app.plugins.state.value.set, 30_000, "the plugin set marked changed");
|
||||
return app.app.applyChanges(context);
|
||||
}
|
||||
|
||||
/** The plugins a rollout's hosts kept on their old code, as `<step>/<host>: <plugin>`, and hosts that failed. */
|
||||
export function rolloutFailures(result: RolloutResult): string[] {
|
||||
const failures: string[] = [];
|
||||
for (const step of result.steps) {
|
||||
for (const entry of step.hosts) {
|
||||
if ("report" in entry)
|
||||
for (const { plugin } of entry.report.failed) failures.push(`${step.name}/${entry.host}: ${plugin}`);
|
||||
else if ("error" in entry) failures.push(`${step.name}/${entry.host}: error ${String(entry.error)}`);
|
||||
}
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
|
||||
/** The fates a rollout's hosts reported, as `<step>/<host>: <object> <kind>`. */
|
||||
export function rolloutFates(result: RolloutResult): string[] {
|
||||
const fates: string[] = [];
|
||||
for (const step of result.steps) {
|
||||
for (const entry of step.hosts) {
|
||||
if (!("report" in entry)) continue;
|
||||
for (const each of entry.report.objects) fates.push(`${step.name}/${entry.host}: ${each.object} ${each.kind}`);
|
||||
}
|
||||
}
|
||||
return fates;
|
||||
}
|
||||
|
||||
/** The latch plugin's contract, declared again for the test (contracts are data). */
|
||||
const Control = remote("reload.control", {
|
||||
version: 1,
|
||||
params: { id: str() },
|
||||
hostAddress: ({ id }) => hostKind("project", { id: str() })({ id }),
|
||||
variants: {
|
||||
main: {
|
||||
state: {},
|
||||
methods: {
|
||||
entered: method(str(), bool()),
|
||||
open: method(str()),
|
||||
natives: method(undefined, obj({ opens: arr(str()), closes: arr(str()) })),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/** The test's hand on the latches and the natives' record where the counter runs (a worker's process or this one). */
|
||||
export type Latches = {
|
||||
/** Resolves once a call waiting on latch `name` entered it. */
|
||||
entered(name: string): Promise<void>;
|
||||
/** Opens latch `name`: the call waiting on it goes on. */
|
||||
open(name: string): Promise<void>;
|
||||
/** What the counter's natives did: opens by code, closes as `<code>:<reason>`. */
|
||||
natives(): Promise<{ readonly opens: readonly string[]; readonly closes: readonly string[] }>;
|
||||
};
|
||||
|
||||
/** The latches of project `p1`'s worker (through the latch plugin), or of the server (this process's). */
|
||||
export async function latches(app: ReloadApp, placement: "server" | "worker"): Promise<Latches> {
|
||||
if (placement === "server") return serverLatches();
|
||||
const control = await app.app.host.get(Control, { id: "p1" }, context);
|
||||
return {
|
||||
entered: async (name) => {
|
||||
await control.entered(name, context);
|
||||
},
|
||||
open: async (name) => {
|
||||
await control.open(name, context);
|
||||
},
|
||||
natives: () => control.natives(context),
|
||||
};
|
||||
}
|
||||
|
||||
type Latch = { readonly entered: PromiseWithResolvers<void>; readonly open: PromiseWithResolvers<void> };
|
||||
|
||||
/** This process's latches: the server's plugins run here and keep them on `globalThis`, as the plugins write them. */
|
||||
function serverLatches(): Latches {
|
||||
const global = globalThis as Record<string, unknown>;
|
||||
const latch = (name: string): Latch => {
|
||||
// The plugins' latch map, made by whichever side comes first.
|
||||
global.reloadLatches ??= new Map();
|
||||
const map = global.reloadLatches as Map<string, Latch>;
|
||||
let found = map.get(name);
|
||||
if (found === undefined) {
|
||||
found = { entered: Promise.withResolvers(), open: Promise.withResolvers() };
|
||||
map.set(name, found);
|
||||
}
|
||||
return found;
|
||||
};
|
||||
return {
|
||||
entered: (name) => latch(name).entered.promise,
|
||||
open: async (name) => latch(name).open.resolve(),
|
||||
natives: async () => {
|
||||
const natives = (global.reloadNatives ?? { opens: [], closes: [] }) as { opens: string[]; closes: string[] };
|
||||
return { opens: [...natives.opens], closes: [...natives.closes] };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The page as the tests see and drive it: its DOM, the probe's records, calls from its references. */
|
||||
export class ReloadPage {
|
||||
readonly browser: Browser;
|
||||
|
||||
constructor(browser: Browser) {
|
||||
this.browser = browser;
|
||||
}
|
||||
|
||||
/** Opens the owner link (signed in) and waits until the page shows its views and is connected. */
|
||||
async signIn(app: ReloadApp, views: readonly string[]): Promise<void> {
|
||||
await this.browser.open(app.app.ownerLink());
|
||||
await this.browser.wait('globalThis.page?.connection === "connected"');
|
||||
for (const view of views) await this.browser.wait(`document.querySelector('[data-view="${view}"]')`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs in with a new owner browser session (the owner link is single use: a second page needs its own session)
|
||||
* and waits as `signIn` does.
|
||||
*/
|
||||
async signInAgain(app: ReloadApp, views: readonly string[]): Promise<void> {
|
||||
const sessions = await app.app.host.get(Sessions, {}, context);
|
||||
const { cookie } = await sessions.issue(
|
||||
{ principal: { kind: "owner" }, label: "second page", kind: "browser" },
|
||||
context,
|
||||
);
|
||||
sessions.release();
|
||||
const [pair] = cookie.split(";");
|
||||
const at = pair!.indexOf("=");
|
||||
const url = `http://127.0.0.1:${app.port}/`;
|
||||
await this.browser.call("Network.setCookie", { name: pair!.slice(0, at), value: pair!.slice(at + 1), url });
|
||||
await this.browser.open(url);
|
||||
await this.browser.wait('globalThis.page?.connection === "connected"');
|
||||
for (const view of views) await this.browser.wait(`document.querySelector('[data-view="${view}"]')`);
|
||||
}
|
||||
|
||||
/** The text of `selector` within view `view`, `null` when absent. */
|
||||
text(view: string, selector: string): Promise<string | null> {
|
||||
return this.browser.evaluate<string | null>(
|
||||
`document.querySelector('[data-view="${view}"] ${selector}')?.textContent ?? null`,
|
||||
);
|
||||
}
|
||||
|
||||
/** The value of input `selector` within view `view`. */
|
||||
value(view: string, selector: string): Promise<string> {
|
||||
return this.browser.evaluate<string>(`document.querySelector('[data-view="${view}"] ${selector}').value`);
|
||||
}
|
||||
|
||||
/** Waits until `selector` within view `view` shows `text`. */
|
||||
waitText(view: string, selector: string, text: string, timeoutMs = 30_000): Promise<void> {
|
||||
const expression = `document.querySelector('[data-view="${view}"] ${selector}')?.textContent === ${JSON.stringify(text)}`;
|
||||
return this.browser.wait(expression, timeoutMs);
|
||||
}
|
||||
|
||||
/** Types `text` into input `selector` of view `view`. */
|
||||
async type(view: string, selector: string, text: string): Promise<void> {
|
||||
await this.browser.evaluate(`document.querySelector('[data-view="${view}"] ${selector}').focus()`);
|
||||
await this.browser.type(text);
|
||||
}
|
||||
|
||||
/** Every state the reference tracked as `name` went through. */
|
||||
states(name: string): Promise<string[]> {
|
||||
return this.browser.evaluate<string[]>(`globalThis.reload.states[${JSON.stringify(name)}] ?? []`);
|
||||
}
|
||||
|
||||
/** Every value its watched member had. */
|
||||
frames(name: string): Promise<unknown[]> {
|
||||
return this.browser.evaluate<unknown[]>(`globalThis.reload.frames[${JSON.stringify(name)}] ?? []`);
|
||||
}
|
||||
|
||||
/** Waits until the reference tracked as `name` is in state `state` (as the probe writes it). */
|
||||
async waitState(name: string, state: string, timeoutMs = 30_000): Promise<void> {
|
||||
try {
|
||||
await this.browser.wait(
|
||||
`globalThis.reload.states[${JSON.stringify(name)}]?.at(-1) === ${JSON.stringify(state)}`,
|
||||
timeoutMs,
|
||||
);
|
||||
} catch {
|
||||
throw new Error(`${name} never reached ${state}: ${JSON.stringify(await this.states(name))}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds the object of project `id` as the web plugin `contract` registered its contract, as `name`, its calls
|
||||
* parking `readyTimeout`, its member `member` watched.
|
||||
*/
|
||||
async hold(name: string, contract: string, readyTimeout: number, id = "p1", member = "label"): Promise<void> {
|
||||
const params = JSON.stringify({ id });
|
||||
const args = [JSON.stringify(name), JSON.stringify(contract), params, readyTimeout, JSON.stringify(member)];
|
||||
await this.browser.evaluate(`globalThis.reload.hold(${args.join(", ")})`);
|
||||
}
|
||||
|
||||
/** Calls `method` with `args` on the reference tracked as `name`: the number of its answer. */
|
||||
call(name: string, method: string, ...args: unknown[]): Promise<number> {
|
||||
const list = [JSON.stringify(name), JSON.stringify(method), ...args.map((arg) => JSON.stringify(arg))].join(", ");
|
||||
return this.browser.evaluate<number>(`globalThis.reload.call(${list})`);
|
||||
}
|
||||
|
||||
/** Call `index`'s answer now (pending or not). */
|
||||
peek(index: number): Promise<CallResult> {
|
||||
return this.browser.evaluate<CallResult>(`globalThis.reload.results[${index}]`);
|
||||
}
|
||||
|
||||
/** Call `index`'s answer, once it has one. */
|
||||
async result(index: number, timeoutMs = 30_000): Promise<CallResult> {
|
||||
await this.browser.wait(`globalThis.reload.results[${index}].status !== "pending"`, timeoutMs);
|
||||
return this.peek(index);
|
||||
}
|
||||
|
||||
/** How often the page loaded (its base bundle ran) since the browser opened it. */
|
||||
loads(): Promise<number> {
|
||||
return this.browser.evaluate<number>("globalThis.page.loads");
|
||||
}
|
||||
}
|
||||
|
||||
/** The answer of a call that succeeded with `value`. */
|
||||
export function ok(value: unknown): CallResult {
|
||||
return { status: "ok", value };
|
||||
}
|
||||
5
packages/chord/test/node/fixtures/reload/entry.ts
Normal file
5
packages/chord/test/node/fixtures/reload/entry.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// The reload suite's web entry: starts the suite's page (`main.ts`).
|
||||
|
||||
import { start } from "./main.ts";
|
||||
|
||||
start();
|
||||
107
packages/chord/test/node/fixtures/reload/main.ts
Normal file
107
packages/chord/test/node/fixtures/reload/main.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
// The reload suite's page (the base bundle's code; `entry.ts` starts it): `startPage` with the suite's API modules and React, and a page that
|
||||
// renders every contributed view (`Views`) in its own section, a draft input of its own (base code: no plugin reload
|
||||
// touches it), and a tall spacer so the tests can scroll. The tests reach the page through `globalThis.reload` (the
|
||||
// probe of the web API) and `globalThis.page` (loads, the connection, dropping the link, holding the reconnect).
|
||||
|
||||
import * as React from "react";
|
||||
import { createElement as h, useState } from "react";
|
||||
import * as jsx from "react/jsx-runtime";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import * as chord from "../../../../src/index.ts";
|
||||
import { HostProvider } from "../../../../src/react/index.ts";
|
||||
import { startPage } from "../../../../src/web/index.ts";
|
||||
import * as webApi from "./web-api.ts";
|
||||
|
||||
/** What the tests read and drive besides the probe. */
|
||||
type PageControl = {
|
||||
loads: number;
|
||||
connection?: string;
|
||||
/** What the page reported (`startPage`'s `onError`): the kind, and the plugins of a `plugins` report. */
|
||||
errors: { readonly kind: string; readonly plugins: readonly string[] }[];
|
||||
socket?: WebSocket;
|
||||
/** While set, the page does not reconnect: the tests resolve it. */
|
||||
gate?: Promise<boolean>;
|
||||
release?: () => void;
|
||||
/** Closes the page's socket (the connection loop reconnects, after `gate` if set). */
|
||||
dropLink(): void;
|
||||
/** Holds the page's reconnects until `release`. */
|
||||
holdReconnect(): void;
|
||||
};
|
||||
|
||||
/** The tests' hand on the page, made when it starts. */
|
||||
function pageControl(): PageControl {
|
||||
const page: PageControl = {
|
||||
loads: 0,
|
||||
errors: [],
|
||||
dropLink() {
|
||||
page.socket?.close();
|
||||
},
|
||||
holdReconnect() {
|
||||
const gate = Promise.withResolvers<boolean>();
|
||||
page.gate = gate.promise;
|
||||
page.release = () => {
|
||||
page.gate = undefined;
|
||||
gate.resolve(true);
|
||||
};
|
||||
},
|
||||
};
|
||||
return page;
|
||||
}
|
||||
|
||||
/** The base bundle's facet: it serves `Views`, composed in name order. */
|
||||
const core = chord.defineFacet((host) =>
|
||||
host.serve(webApi.Views, {
|
||||
compose: (table) => table.map(({ value }) => value).sort((a, b) => a.name.localeCompare(b.name)),
|
||||
}),
|
||||
);
|
||||
|
||||
function App() {
|
||||
const views = webApi.useRegistry(webApi.Views) ?? [];
|
||||
const [draft, setDraft] = useState("");
|
||||
return h(
|
||||
"div",
|
||||
null,
|
||||
h("input", {
|
||||
id: "base-draft",
|
||||
value: draft,
|
||||
onChange: (event: { target: { value: string } }) => setDraft(event.target.value),
|
||||
}),
|
||||
h(
|
||||
"div",
|
||||
{ id: "views" },
|
||||
...views.map((view) => h("section", { key: view.name, "data-view": view.name }, h(view.component))),
|
||||
),
|
||||
h("div", { id: "spacer", style: { height: "5000px" } }),
|
||||
);
|
||||
}
|
||||
|
||||
/** Starts the page (Chord's package is free of side effects for bundlers: an entry calls this). */
|
||||
export function start(): void {
|
||||
const page = pageControl();
|
||||
page.loads++;
|
||||
(globalThis as unknown as { page: PageControl }).page = page;
|
||||
(globalThis as unknown as { reload: typeof webApi.probe }).reload = webApi.probe;
|
||||
void startPage({
|
||||
connect: () => {
|
||||
const socket = new WebSocket(`${location.origin.replace(/^http/, "ws")}/chord`);
|
||||
page.socket = socket;
|
||||
return socket;
|
||||
},
|
||||
modules: { "@test/app/v1": chord, "@test/app/v1/web": webApi, react: React, "react/jsx-runtime": jsx },
|
||||
core: [{ name: "@test/core", facet: core }],
|
||||
canRetry: () => page.gate ?? Promise.resolve(true),
|
||||
onError: (error) => {
|
||||
page.errors.push({
|
||||
kind: error.kind,
|
||||
plugins: error.kind === "plugins" ? error.failed.map(({ plugin }) => plugin) : [],
|
||||
});
|
||||
},
|
||||
onConnection: (state) => {
|
||||
page.connection = state;
|
||||
},
|
||||
render: (host) => {
|
||||
webApi.probe.host = host;
|
||||
createRoot(document.getElementById("root")!).render(h(HostProvider, { host }, h(App)));
|
||||
},
|
||||
});
|
||||
}
|
||||
349
packages/chord/test/node/fixtures/reload/plugins.ts
Normal file
349
packages/chord/test/node/fixtures/reload/plugins.ts
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
// The reload suite's plugins, as the files a test writes into the app's root and edits on disk: `counter` (a counter
|
||||
// with a persisted document and a table, on a worker or the server, pure or holding a native, at any contract
|
||||
// version), `latch` (on every worker: the test's hand on calls that wait), and web plugins showing the counter with
|
||||
// a draft input (component state) and a kept input (page state). Calls that must be in flight during a reload wait on
|
||||
// a latch by name: the method marks it entered, then waits until the test opens it. Latches live on `globalThis`, so
|
||||
// old and new code of a plugin and the latch plugin share them within a process.
|
||||
|
||||
/** Where the counter runs: a project's worker (`p1`), or the server. */
|
||||
export type Placement = "server" | "worker";
|
||||
|
||||
/**
|
||||
* The counter's contract as a version of its code has it: `v1`; `v2` evolved (its label an object, `add` taking and
|
||||
* returning objects) for a provider that accepts v1 (`accepted`) or does not (`refused`), the same file either way.
|
||||
*/
|
||||
export type ContractVersion = "v1" | "v2";
|
||||
|
||||
/** The latches, by name: a method marks one entered and waits until the test opens it. */
|
||||
const LATCH = `
|
||||
type Latch = { readonly entered: PromiseWithResolvers<void>; readonly open: PromiseWithResolvers<void> };
|
||||
const latches: Map<string, Latch> = ((globalThis as Record<string, unknown>).reloadLatches ??= new Map()) as Map<string, Latch>;
|
||||
/** The latch named \`name\`, made on first use. */
|
||||
function latch(name: string): Latch {
|
||||
let found = latches.get(name);
|
||||
if (found === undefined) {
|
||||
found = { entered: Promise.withResolvers(), open: Promise.withResolvers() };
|
||||
latches.set(name, found);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
/** What the natives of every version did in this process: opens by version, closes as \`<version>:<reason>\`. */
|
||||
const natives = ((globalThis as Record<string, unknown>).reloadNatives ??= { opens: [], closes: [] }) as { opens: string[]; closes: string[] };
|
||||
`;
|
||||
|
||||
/** The counter's contract file at `version`, placed by `placement`. */
|
||||
export function counterContract(placement: Placement, version: ContractVersion): string {
|
||||
const address =
|
||||
placement === "worker"
|
||||
? 'const Project = hostKind("project", { id: str() });\nconst at = ({ id }: { id: string }) => Project({ id });'
|
||||
: 'const Server = hostKind("server");\nconst at = () => Server();';
|
||||
const v1 = `export const CounterV1 = remote("reload.counter", {
|
||||
version: 1,
|
||||
params: { id: str() },
|
||||
hostAddress: at,
|
||||
variants: {
|
||||
main: {
|
||||
state: { label: str(), count: int() },
|
||||
methods: {
|
||||
add: method(int(), str()),
|
||||
slow: method(str(), str()),
|
||||
slowAdd: method(str(), str()),
|
||||
code: method(undefined, str()),
|
||||
visits: method(undefined, int()),
|
||||
},
|
||||
},
|
||||
},
|
||||
});`;
|
||||
const v2 = `export const CounterV2 = evolve(CounterV1, (e) =>
|
||||
e.state("main", "label", obj({ text: str(), code: str() })).method("add", obj({ n: int() }), obj({ text: str() })),
|
||||
);`;
|
||||
return [
|
||||
"// The counter's contract.",
|
||||
'import { evolve, hostKind, int, method, obj, remote, str } from "@test/app/v1";',
|
||||
address,
|
||||
v1,
|
||||
version === "v2" ? v2 : "",
|
||||
`export const Counter = ${version === "v2" ? "CounterV2" : "CounterV1"};`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/** How a version of the counter's code is written. */
|
||||
export type CounterCode = {
|
||||
/** What its label and `code()` say: which code answered. */
|
||||
readonly code: string;
|
||||
readonly placement: Placement;
|
||||
/** Holds a native (`self.open`): every reload of it restarts the object. */
|
||||
readonly native?: boolean;
|
||||
/** `v2` (the contract file goes with it): accepting v1 consumers through a mapping, or not. */
|
||||
readonly contract?: ContractVersion;
|
||||
readonly accepts?: boolean;
|
||||
/** A broken version: its build fails, its evaluation throws, its setup throws, or its native's open throws. */
|
||||
readonly broken?: "build" | "evaluation" | "setup" | "open";
|
||||
};
|
||||
|
||||
/** The counter plugin's files (its contract and its server or worker facet) for `code`. */
|
||||
export function counterPlugin(code: CounterCode): Record<string, string> {
|
||||
const contract = code.contract ?? "v1";
|
||||
return {
|
||||
"contract.ts": counterContract(code.placement, contract),
|
||||
[`${code.placement}.ts`]: counterFacet(code, contract),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The counter's facet: a store (a local object with empty params: persisted data needs a stable identity) keeping the
|
||||
* count document and the visits table, and the counter the page holds, its label naming the code.
|
||||
*/
|
||||
function counterFacet(code: CounterCode, contract: ContractVersion): string {
|
||||
const v = JSON.stringify(code.code);
|
||||
const label = contract === "v2" ? `({ text: \`${code.code}:\${n}\`, code: ${v} })` : `\`${code.code}:\${n}\``;
|
||||
const add =
|
||||
contract === "v2"
|
||||
? `add: async ({ n }, ctx) => ({ text: await store.add(n, ctx) }),`
|
||||
: "add: (n, ctx) => store.add(n, ctx),";
|
||||
const accepts =
|
||||
contract === "v2" && code.accepts === true
|
||||
? `, { accepts: { 1: { state: { main: { label: (current) => current.text } }, methods: { add: { args: (old) => ({ n: old }), result: (current) => current.text } } } } }`
|
||||
: "";
|
||||
const native =
|
||||
code.native === true
|
||||
? `\t\tself.open(async () => { ${code.broken === "open" ? 'throw new Error("the native does not open");' : ""} natives.opens.push(${v}); return ${v}; }, (value, reason) => { natives.closes.push(\`\${value}:\${reason.kind}\`); });`
|
||||
: "";
|
||||
return [
|
||||
`// The counter's ${code.placement} facet, code ${code.code}.`,
|
||||
'import { defineFacet, defineState, defineTable, int, local, method, obj, query, str } from "@test/app/v1";',
|
||||
'import { Counter } from "./contract.ts";',
|
||||
code.broken === "build" ? "const broken = ;" : "",
|
||||
code.broken === "evaluation" ? 'throw new Error("the counter\'s new code throws when evaluated");' : "",
|
||||
LATCH,
|
||||
'const Count = defineState("reload.count", { schema: obj({ n: int() }), version: 1, initial: () => ({ n: 0 }), persist: true, checkpointWhen: () => false });',
|
||||
'const Visits = defineTable("reload.visits", { version: 1, columns: { at: int() }, key: ["at"] });',
|
||||
'const Store = local("reload.store", { version: 1, params: {}, members: { count: int(), add: method(int(), str()), visits: method(undefined, int()) } });',
|
||||
"export default defineFacet((host) => {",
|
||||
code.broken === "setup" ? '\tthrow new Error("the counter\'s new setup throws");' : "",
|
||||
"\thost.serve(Store, (_params, self) => {",
|
||||
"\t\tconst doc = self.state(Count);",
|
||||
"\t\tconst visits = self.table(Visits);",
|
||||
"\t\treturn {",
|
||||
"\t\t\tcount: self.derive([doc], ([value]) => value.n),",
|
||||
"\t\t\tadd: async (n, ctx) => {",
|
||||
"\t\t\t\tawait doc.change((draft) => { draft.n += n; }, ctx);",
|
||||
"\t\t\t\tconst at = doc.value.n;",
|
||||
"\t\t\t\tawait self.tx(async (tx) => { tx.put(visits, { at }); }, ctx);",
|
||||
`\t\t\t\treturn \`${code.code}:\${at}\`;`,
|
||||
"\t\t\t},",
|
||||
"\t\t\tvisits: async (ctx) => visits.count(query(Visits), ctx),",
|
||||
"\t\t};",
|
||||
"\t});",
|
||||
"\thost.serve(Counter, (_params, _principal, self) => {",
|
||||
native,
|
||||
"\t\tconst store = self.get(Store, {});",
|
||||
'\t\treturn { variant: "main", members: {',
|
||||
`\t\t\tlabel: self.derive([store.count], ([n]) => ${label}),`,
|
||||
"\t\t\tcount: self.derive([store.count], ([n]) => n),",
|
||||
`\t\t\t${add}`,
|
||||
`\t\t\tslow: async (name) => { const held = latch(name); held.entered.resolve(); await held.open.promise; return \`${code.code}:\${name}\`; },`,
|
||||
"\t\t\tslowAdd: async (name, ctx) => { const held = latch(name); held.entered.resolve(); await held.open.promise; return store.add(10, ctx); },",
|
||||
`\t\t\tcode: async () => ${v},`,
|
||||
"\t\t\tvisits: (ctx) => store.visits(ctx),",
|
||||
"\t\t} };",
|
||||
`\t}${accepts});`,
|
||||
"});",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/** The latch plugin's worker facet: `Control` on each project's worker opens latches and reads them for the test. */
|
||||
export const LATCH_PLUGIN: Readonly<Record<string, string>> = {
|
||||
"worker.ts": [
|
||||
"// The latch plugin: the test's hand on the latches and the natives' record in a worker's process.",
|
||||
'import { arr, bool, defineFacet, hostKind, method, obj, remote, str } from "@test/app/v1";',
|
||||
LATCH,
|
||||
'const Project = hostKind("project", { id: str() });',
|
||||
'const Control = remote("reload.control", { version: 1, params: { id: str() }, hostAddress: ({ id }) => Project({ id }), variants: { main: { state: {}, methods: {',
|
||||
"\tentered: method(str(), bool()), open: method(str()), natives: method(undefined, obj({ opens: arr(str()), closes: arr(str()) })),",
|
||||
"} } } });",
|
||||
'export default defineFacet((host) => host.serve(Control, () => ({ variant: "main", members: {',
|
||||
"\tentered: async (name) => { await latch(name).entered.promise; return true; },",
|
||||
"\topen: async (name) => { latch(name).open.resolve(); return null; },",
|
||||
"\tnatives: async () => ({ opens: [...natives.opens], closes: [...natives.closes] }),",
|
||||
"} })));",
|
||||
].join("\n"),
|
||||
};
|
||||
|
||||
/** How a version of a web plugin is written. */
|
||||
export type WebCode = {
|
||||
/** Its name: its section's `data-view` and its probe name. */
|
||||
readonly name: string;
|
||||
/** What its view says it is (`<name>:<code>`): which code renders. */
|
||||
readonly code: string;
|
||||
/** The counter it shows, through its own copy of the contract (an old page keeps its copy); none: no reference. */
|
||||
readonly counter?: { readonly placement: Placement; readonly contract: ContractVersion };
|
||||
/** Shows the server's summary of the counter instead (`summaryPlugin`), through its own contract copy. */
|
||||
readonly summary?: boolean;
|
||||
/** A broken version: its build fails or its evaluation throws. */
|
||||
readonly broken?: "build" | "evaluation";
|
||||
/** Contributes no view (its contributions changed shape: none now). */
|
||||
readonly hidden?: boolean;
|
||||
};
|
||||
|
||||
/** A web plugin's files: its view (the counter's label, count and state, a draft and a kept input) and contract copy. */
|
||||
export function webPlugin(code: WebCode): Record<string, string> {
|
||||
const name = JSON.stringify(code.name);
|
||||
const counter = code.counter;
|
||||
const lines = [
|
||||
`// The ${code.name} web plugin, code ${code.code}.`,
|
||||
'import { createElement as h, useEffect, useState } from "react";',
|
||||
'import { defineFacet } from "@test/app/v1";',
|
||||
'import { probe, useHold, usePageState, useRefState, useWatch, Views } from "@test/app/v1/web";',
|
||||
counter === undefined
|
||||
? ""
|
||||
: `import { ${code.summary === true ? "Summary as Counter" : "Counter"} } from "./contract.ts";`,
|
||||
code.broken === "build" ? "const broken = ;" : "",
|
||||
code.broken === "evaluation" ? 'throw new Error("the web plugin\'s new code throws when evaluated");' : "",
|
||||
counter === undefined ? "" : `probe.contracts[${name}] = Counter;`,
|
||||
"function Shown() {",
|
||||
counter === undefined
|
||||
? "\treturn null;"
|
||||
: [
|
||||
'\tconst ref = useHold(Counter, { id: "p1" });',
|
||||
"\tconst label = useWatch(ref?.label);",
|
||||
"\tconst count = useWatch(ref?.count);",
|
||||
"\tconst state = useRefState(ref);",
|
||||
`\tuseEffect(() => { if (ref !== undefined) probe.track(${name}, ref, "label"); }, [ref]);`,
|
||||
'\treturn h("span", null,',
|
||||
'\t\th("span", { className: "label" }, label === undefined ? "none" : typeof label === "string" ? label : JSON.stringify(label)),',
|
||||
'\t\th("span", { className: "count" }, String(count ?? "none")),',
|
||||
'\t\th("span", { className: "state" }, state?.kind ?? "none"),',
|
||||
"\t);",
|
||||
].join("\n"),
|
||||
"}",
|
||||
"function View() {",
|
||||
'\tconst [draft, setDraft] = useState("");',
|
||||
`\tconst [kept, setKept] = usePageState(${JSON.stringify(`${code.name}.kept`)}, "");`,
|
||||
'\treturn h("div", null,',
|
||||
`\t\th("span", { className: "code" }, ${JSON.stringify(`${code.name}:${code.code}`)}),`,
|
||||
"\t\th(Shown),",
|
||||
'\t\th("input", { className: "draft", value: draft, onChange: (event: { target: { value: string } }) => setDraft(event.target.value) }),',
|
||||
'\t\th("input", { className: "kept", value: kept, onChange: (event: { target: { value: string } }) => setKept(event.target.value) }),',
|
||||
"\t);",
|
||||
"}",
|
||||
code.hidden === true
|
||||
? "export default defineFacet(() => {});"
|
||||
: `export default defineFacet((_host, self) => self.contribute(Views, { name: ${name}, component: View }));`,
|
||||
];
|
||||
return {
|
||||
"web.ts": lines.join("\n"),
|
||||
...(counter === undefined
|
||||
? {}
|
||||
: {
|
||||
"contract.ts":
|
||||
code.summary === true ? SUMMARY_CONTRACT : counterContract(counter.placement, counter.contract),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** The summary's contract: on the server, its label the counter's seen through the summary's code. */
|
||||
const SUMMARY_CONTRACT = `// The summary's contract.
|
||||
import { hostKind, int, method, remote, str } from "@test/app/v1";
|
||||
const Server = hostKind("server");
|
||||
export const Summary = remote("reload.summary", {
|
||||
version: 1,
|
||||
params: { id: str() },
|
||||
hostAddress: () => Server(),
|
||||
variants: { main: { state: { label: str(), count: int() }, methods: { code: method(undefined, str()) } } },
|
||||
});`;
|
||||
|
||||
/**
|
||||
* The summary plugin's files: a pure object on the server whose factory holds the counter of project `id` on its worker
|
||||
* (a dependency on another host), its label `<code>(<counter's count>)`. The counter's contract is the counter
|
||||
* plugin's file, imported by relative path (copied into the summary's script).
|
||||
*/
|
||||
export function summaryPlugin(code: string): Record<string, string> {
|
||||
return {
|
||||
"contract.ts": SUMMARY_CONTRACT,
|
||||
"server.ts": [
|
||||
`// The summary's server facet, code ${code}.`,
|
||||
'import { defineFacet } from "@test/app/v1";',
|
||||
'import { Counter } from "../counter/contract.ts";',
|
||||
'import { Summary } from "./contract.ts";',
|
||||
"export default defineFacet((host) => host.serve(Summary, ({ id }, _principal, self) => {",
|
||||
"\tconst counter = self.get(Counter, { id });",
|
||||
'\treturn { variant: "main", members: {',
|
||||
`\t\tlabel: self.derive([counter.count], ([count]) => \`${code}(\${count})\`),`,
|
||||
"\t\tcount: self.derive([counter.count], ([count]) => count),",
|
||||
`\t\tcode: async () => ${JSON.stringify(code)},`,
|
||||
"\t} };",
|
||||
"}));",
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
/** The tasks' contract: on project `id`'s worker, the finished runs' entries and how many run. */
|
||||
const TASKS_CONTRACT = `// The tasks' contract.
|
||||
import { arr, hostKind, int, method, remote, str } from "@test/app/v1";
|
||||
const Project = hostKind("project", { id: str() });
|
||||
export const Tasks = remote("reload.tasks", {
|
||||
version: 1,
|
||||
params: { id: str() },
|
||||
hostAddress: ({ id }) => Project({ id }),
|
||||
variants: { main: { state: { entries: arr(str()), running: int() }, methods: { start: method(str(), str()) } } },
|
||||
});`;
|
||||
|
||||
/**
|
||||
* The tasks plugin's files: a runner (a local store) with an in-memory job whose run waits on the latch named by its
|
||||
* argument, and whose `finish` records `<code>-finish(<outcome>)` in a persisted list; `native`: the runner holds a
|
||||
* native, so a reload restarts it. The page reaches it through `Tasks`.
|
||||
*/
|
||||
export function tasksPlugin(code: string, native = false): Record<string, string> {
|
||||
const v = JSON.stringify(code);
|
||||
return {
|
||||
"contract.ts": TASKS_CONTRACT,
|
||||
"worker.ts": [
|
||||
`// The tasks' worker facet, code ${code}.`,
|
||||
'import { arr, defineFacet, defineJob, defineState, int, local, method, obj, str } from "@test/app/v1";',
|
||||
'import { Tasks } from "./contract.ts";',
|
||||
LATCH,
|
||||
'const Entries = defineState("reload.entries", { schema: obj({ list: arr(str()) }), version: 1, initial: () => ({ list: [] }), persist: true, checkpointWhen: () => false });',
|
||||
'const Task = defineJob("reload.task", { version: 1, args: obj({ name: str() }), result: str() });',
|
||||
'const Runner = local("reload.runner", { version: 1, params: {}, members: { entries: arr(str()), running: int(), start: method(str(), str()) } });',
|
||||
"export default defineFacet((host) => {",
|
||||
"\thost.serve(Runner, (_params, self) => {",
|
||||
native ? `\t\tself.open(async () => ${v}, () => {});` : "",
|
||||
"\t\tconst entries = self.state(Entries);",
|
||||
"\t\tconst task = self.job(Task, {",
|
||||
"\t\t\trun: async ({ name }, ctx) => {",
|
||||
"\t\t\t\tconst held = latch(name);",
|
||||
"\t\t\t\theld.entered.resolve();",
|
||||
"\t\t\t\tawait new Promise<void>((resolve, reject) => {",
|
||||
"\t\t\t\t\tvoid held.open.promise.then(resolve);",
|
||||
'\t\t\t\t\tctx.abortSignal?.addEventListener("abort", () => reject(new Error("aborted")));',
|
||||
"\t\t\t\t});",
|
||||
`\t\t\t\treturn \`run-${code}:\${name}\`;`,
|
||||
"\t\t\t},",
|
||||
"\t\t\tfinish: async (outcome, tx) => {",
|
||||
'\t\t\t\tconst what = outcome.kind === "done" ? "done:" + outcome.result : outcome.error.kind === "aborted" ? "aborted:" + outcome.error.reason : outcome.error.kind;',
|
||||
`\t\t\t\t(await tx.draft(entries)).list.push(\`${code}-finish(\${what})\`);`,
|
||||
"\t\t\t},",
|
||||
"\t\t});",
|
||||
"\t\treturn {",
|
||||
"\t\t\tentries: self.derive([entries], ([value]) => value.list),",
|
||||
"\t\t\trunning: self.derive([task.running], ([running]) => running.length),",
|
||||
"\t\t\tstart: async (name, ctx) => (await task.start({ name }, ctx)).id,",
|
||||
"\t\t};",
|
||||
"\t});",
|
||||
"\thost.serve(Tasks, (_params, _principal, self) => {",
|
||||
"\t\tconst runner = self.get(Runner, {});",
|
||||
'\t\treturn { variant: "main", members: { entries: runner.entries, running: runner.running, start: (name, ctx) => runner.start(name, ctx) } };',
|
||||
"\t});",
|
||||
"});",
|
||||
].join("\n"),
|
||||
"web.ts": [
|
||||
"// The tasks' web facet: registers the contract for the page's probe.",
|
||||
'import { defineFacet } from "@test/app/v1";',
|
||||
'import { probe } from "@test/app/v1/web";',
|
||||
'import { Tasks } from "./contract.ts";',
|
||||
"probe.contracts.tasks = Tasks;",
|
||||
"export default defineFacet(() => {});",
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
129
packages/chord/test/node/fixtures/reload/web-api.ts
Normal file
129
packages/chord/test/node/fixtures/reload/web-api.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
// The reload suite's web API (`@test/app/v1/web`): the `Views` registry the web plugins contribute their components to,
|
||||
// Chord's React hooks, and the probe through which the tests see the page (every state a reference went through, every
|
||||
// value of a tracked member) and call from it (each call's answer kept by number).
|
||||
|
||||
import type { ComponentType } from "react";
|
||||
import { BACKGROUND_CONTEXT } from "../../../../src/context/index.ts";
|
||||
import { type Contract, isChordError, type RefState } from "../../../../src/index.ts";
|
||||
import { defineRegistry } from "../../../../src/registries/index.ts";
|
||||
import type { WatchCall } from "../../../../src/watch.ts";
|
||||
|
||||
export { useHold, useHost, usePageState, useRefState, useRegistry, useWatch } from "../../../../src/react/index.ts";
|
||||
|
||||
/** A web plugin's view: its name (the section's `data-view`) and its component. */
|
||||
export type View = { readonly name: string; readonly component: ComponentType };
|
||||
|
||||
/** The views, in name order. */
|
||||
export const Views = defineRegistry<View, readonly View[]>("reload.views", { version: 1 });
|
||||
|
||||
/** A call's answer as the tests read it. */
|
||||
export type CallResult =
|
||||
| { readonly status: "pending" }
|
||||
| { readonly status: "ok"; readonly value: unknown }
|
||||
| { readonly status: "error"; readonly code: string; readonly reason: string | undefined; readonly message: string };
|
||||
|
||||
/** What the probe needs of a reference: its state, its members by name (state members and methods). */
|
||||
type ProbedRef = {
|
||||
readonly state: {
|
||||
readonly value: RefState;
|
||||
watch(
|
||||
context: typeof BACKGROUND_CONTEXT,
|
||||
): Promise<{ start(listener: (call: { kind: string; value?: RefState }) => Promise<void>): void }>;
|
||||
};
|
||||
readonly release: () => void;
|
||||
} & Record<string, unknown>;
|
||||
|
||||
/** A state member as the probe watches it. */
|
||||
type ProbedMember = {
|
||||
watch(
|
||||
context: typeof BACKGROUND_CONTEXT,
|
||||
): Promise<{ start(listener: (call: WatchCall<unknown>) => Promise<void>): void }>;
|
||||
};
|
||||
|
||||
/** A reference's state as one string: `live`, `loading`, `failed:<kind>:<reason or code>`, `ended:<code>`. */
|
||||
function stateText(state: RefState): string {
|
||||
if (state.kind === "failed")
|
||||
return `failed:${state.cause.kind}:${state.cause.error.reason ?? state.cause.error.code}`;
|
||||
if (state.kind === "ended") return `ended:${state.error.code}`;
|
||||
return state.kind;
|
||||
}
|
||||
|
||||
/** The page as the tests see it: tracked references, their states and member values, calls and their answers. */
|
||||
class PageProbe {
|
||||
readonly refs: Record<string, ProbedRef> = {};
|
||||
/** Every state each tracked reference went through, in order; `remount` where a new reference took the name. */
|
||||
readonly states: Record<string, string[]> = {};
|
||||
/** Every value (or `failed:<reason>`) of each tracked reference's watched member. */
|
||||
readonly frames: Record<string, unknown[]> = {};
|
||||
readonly results: CallResult[] = [];
|
||||
/** The contracts web plugins registered, by name, for references the tests make themselves (`hold`). */
|
||||
readonly contracts: Record<string, Contract> = {};
|
||||
/** The page's own reference to the host, set by the base bundle. */
|
||||
host:
|
||||
| {
|
||||
hold(
|
||||
Type: Contract,
|
||||
params: unknown,
|
||||
context: typeof BACKGROUND_CONTEXT,
|
||||
options: { readyTimeout: number },
|
||||
): unknown;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
/** Tracks `ref` under `name`: its states from now on and the values of its member `member`. */
|
||||
track(name: string, ref: unknown, member: string): void {
|
||||
// The references the web plugins hand in are Chord references; the probe reads them by member name.
|
||||
const probed = ref as ProbedRef;
|
||||
if (this.refs[name] === probed) return;
|
||||
const states = this.states[name] ?? [];
|
||||
const frames = this.frames[name] ?? [];
|
||||
this.states[name] = states;
|
||||
this.frames[name] = frames;
|
||||
if (this.refs[name] !== undefined) states.push("remount");
|
||||
this.refs[name] = probed;
|
||||
states.push(stateText(probed.state.value));
|
||||
void probed.state.watch(BACKGROUND_CONTEXT).then((watch) =>
|
||||
watch.start(async (call) => {
|
||||
if (this.refs[name] === probed && call.kind === "value") states.push(stateText(call.value!));
|
||||
}),
|
||||
);
|
||||
void (probed[member] as ProbedMember).watch(BACKGROUND_CONTEXT).then((watch) =>
|
||||
watch.start(async (call) => {
|
||||
if (this.refs[name] !== probed) return;
|
||||
frames.push(call.kind === "value" ? call.value : `failed:${failureText(call.error)}`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Holds the contract `contract` registered with `params` as the tracked reference `name`, parking calls `readyTimeout`. */
|
||||
hold(name: string, contract: string, params: unknown, readyTimeout: number, member: string): void {
|
||||
const ref = this.host!.hold(this.contracts[contract]!, params, BACKGROUND_CONTEXT, { readyTimeout });
|
||||
this.track(name, ref, member);
|
||||
}
|
||||
|
||||
/** Calls `method` of the reference tracked as `name` with `args`; the number of its answer in `results`. */
|
||||
call(name: string, method: string, ...args: unknown[]): number {
|
||||
const index = this.results.length;
|
||||
this.results.push({ status: "pending" });
|
||||
const fn = this.refs[name]![method] as (...rest: unknown[]) => Promise<unknown>;
|
||||
fn(...args, BACKGROUND_CONTEXT).then(
|
||||
(value) => {
|
||||
this.results[index] = { status: "ok", value };
|
||||
},
|
||||
(error: unknown) => {
|
||||
this.results[index] = isChordError(error)
|
||||
? { status: "error", code: error.code, reason: error.reason, message: error.message }
|
||||
: { status: "error", code: "thrown", reason: undefined, message: String(error) };
|
||||
},
|
||||
);
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
/** A watch failure's error as text: a Chord error's reason or code, else its message. */
|
||||
function failureText(error: unknown): string {
|
||||
return isChordError(error) ? (error.reason ?? error.code) : String(error);
|
||||
}
|
||||
|
||||
/** The page's probe (one per page: this module is the page's instance of the web API). */
|
||||
export const probe = new PageProbe();
|
||||
|
|
@ -82,6 +82,21 @@ describe("the watcher's marks", () => {
|
|||
watcher.close();
|
||||
});
|
||||
|
||||
it("marks base for a base file given one by one, not for its siblings; never under node_modules", async () => {
|
||||
const { base, watcher } = await watched();
|
||||
const elsewhere = await realpath(await tempRoot());
|
||||
await writeFiles(elsewhere, { "entry.ts": "", "sibling.ts": "", "node_modules/react/index.js": "" });
|
||||
watcher.baseFiles([join(elsewhere, "entry.ts"), join(elsewhere, "node_modules", "react", "index.js")]);
|
||||
watcher.mark(join(elsewhere, "sibling.ts"));
|
||||
watcher.mark(join(elsewhere, "node_modules", "react", "index.js"));
|
||||
expect(watcher.value).toMatchObject({ base: false });
|
||||
// A real event on the file: its directory is watched.
|
||||
await writeFile(join(elsewhere, "entry.ts"), "export {};");
|
||||
await vi.waitFor(() => expect(watcher.value).toMatchObject({ base: true }), { timeout: 5_000 });
|
||||
expect(base).not.toBe(elsewhere);
|
||||
watcher.close();
|
||||
});
|
||||
|
||||
it("ignores node_modules and .git", async () => {
|
||||
const { root, watcher } = await watched();
|
||||
watcher.mark(join(root, "a", "node_modules", "x", "index.js"));
|
||||
|
|
|
|||
83
packages/chord/test/node/reload/base.test.ts
Normal file
83
packages/chord/test/node/reload/base.test.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
// Reload suite, row "base code (the app's client entry, Chord, React)" (design §21 "What a user sees during a
|
||||
// reload"): the page reloads, the only case losing what is on screen; calls in flight end with the link; page state is
|
||||
// lost, server state unchanged. The base bundle's entry changes on disk, the watcher flags base code, and the app
|
||||
// restarts its hosts (the kit's answer to base code) on the same data directory and port.
|
||||
|
||||
import { join } from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
|
||||
import { latches, ok, ReloadPage, startReloadApp, versionedMain } from "../fixtures/reload/app.ts";
|
||||
import { counterPlugin, LATCH_PLUGIN, webPlugin } from "../fixtures/reload/plugins.ts";
|
||||
import { Browser, CHROMIUM, poll } from "../web/browser.ts";
|
||||
|
||||
/** The counter as the web plugins show it. */
|
||||
const COUNTER = { placement: "worker", contract: "v1" } as const;
|
||||
|
||||
describe.skipIf(CHROMIUM === undefined)("a change of base code", () => {
|
||||
let browser: Browser;
|
||||
let page: ReloadPage;
|
||||
|
||||
beforeAll(async () => {
|
||||
browser = await Browser.start();
|
||||
page = new ReloadPage(browser);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
});
|
||||
|
||||
it("reloads the page, ends calls in flight with the link, loses page state and keeps server state", async () => {
|
||||
const entry = await versionedMain("1");
|
||||
const first = await startReloadApp(
|
||||
{
|
||||
latch: LATCH_PLUGIN,
|
||||
counter: counterPlugin({ code: "v1", placement: "worker" }),
|
||||
alpha: webPlugin({ name: "alpha", code: "1", counter: COUNTER }),
|
||||
},
|
||||
{ entry },
|
||||
);
|
||||
let second: Awaited<ReturnType<typeof startReloadApp>> | undefined;
|
||||
try {
|
||||
await page.signIn(first, ["alpha"]);
|
||||
await page.waitText("alpha", ".state", "live");
|
||||
expect(await page.result(await page.call("alpha", "add", 2))).toEqual(ok("v1:2"));
|
||||
await page.waitText("alpha", ".label", "v1:2");
|
||||
await page.type("alpha", ".kept", "kept");
|
||||
await browser.evaluate("globalThis.marked = true");
|
||||
const hand = await latches(first, "worker");
|
||||
const running = await page.call("alpha", "slowAdd", "running");
|
||||
await hand.entered("running");
|
||||
|
||||
// Base code changes; the app restarts its hosts.
|
||||
await versionedMain("2", join(entry, ".."));
|
||||
await first.app.close(context);
|
||||
// The call in flight ends with the link.
|
||||
expect(await page.result(running)).toMatchObject({ status: "error" });
|
||||
second = await startReloadApp({}, { entry, port: first.port, dataDir: first.dataDir, root: first.root });
|
||||
expect(second.app.web!.base).not.toBe(first.app.web!.base);
|
||||
|
||||
// The page reloads for the new base bundle: what was on screen and in page state is gone.
|
||||
await browser.wait('globalThis.version === "2" && globalThis.page?.connection === "connected"', 60_000);
|
||||
expect(await browser.evaluate("globalThis.marked ?? false")).toBe(false);
|
||||
await page.waitText("alpha", ".state", "live");
|
||||
expect(await page.value("alpha", ".kept")).toBe("");
|
||||
// Server state unchanged: the count as committed before (the cut call never committed).
|
||||
expect(await page.text("alpha", ".label")).toBe("v1:2");
|
||||
expect(await page.result(await page.call("alpha", "visits"))).toEqual(ok(1));
|
||||
} finally {
|
||||
await first.app.close(context);
|
||||
await second?.app.close(context);
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
it("flags a change of the web entry as base code (`plugins.state.base`)", async () => {
|
||||
const entry = await versionedMain("1");
|
||||
const app = await startReloadApp({ alpha: webPlugin({ name: "alpha", code: "1" }) }, { entry });
|
||||
try {
|
||||
await versionedMain("2", join(entry, ".."));
|
||||
await poll(() => app.app.plugins.state.value.base, 5000, "base code flagged");
|
||||
} finally {
|
||||
await app.app.close(context);
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
157
packages/chord/test/node/reload/contracts.test.ts
Normal file
157
packages/chord/test/node/reload/contracts.test.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
// Reload suite, rows "a contract change the new code still `accepts`" and "a contract change not accepted, or a type
|
||||
// removed" (design §21 "What a user sees during a reload"). Accepted: the old page keeps working through the mapping,
|
||||
// its calls answered through it. Not accepted or removed: the reference waits with its last values (`failed
|
||||
// { waiting }`: `version`, `not_served`) until the page's code updates or the type returns; a call on it fails at once
|
||||
// with the cause (`contract_mismatch`, `unavailable` not served; as corrected upstream); state unchanged. The page's
|
||||
// web plugin keeps its own copy of the contract (an old page) while the counter on the worker moves.
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
|
||||
import {
|
||||
editAndApply,
|
||||
ok,
|
||||
ReloadPage,
|
||||
removeAndApply,
|
||||
rolloutFailures,
|
||||
startReloadApp,
|
||||
} from "../fixtures/reload/app.ts";
|
||||
import { counterPlugin, LATCH_PLUGIN, webPlugin } from "../fixtures/reload/plugins.ts";
|
||||
import { Browser, CHROMIUM } from "../web/browser.ts";
|
||||
|
||||
/** The page's short reference's `readyTimeout`: a call failing at once answers well before it. */
|
||||
const PARK_MS = 1500;
|
||||
|
||||
describe.skipIf(CHROMIUM === undefined)("a reload changing a contract", () => {
|
||||
let browser: Browser;
|
||||
let page: ReloadPage;
|
||||
|
||||
beforeAll(async () => {
|
||||
browser = await Browser.start();
|
||||
page = new ReloadPage(browser);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
});
|
||||
|
||||
/** The app with the counter at v1 on worker `p1`, the page showing it (`alpha`) with a count of 2. */
|
||||
async function oldPage() {
|
||||
const placement = "worker";
|
||||
const app = await startReloadApp({
|
||||
latch: LATCH_PLUGIN,
|
||||
counter: counterPlugin({ code: "v1", placement }),
|
||||
alpha: webPlugin({ name: "alpha", code: "1", counter: { placement, contract: "v1" } }),
|
||||
});
|
||||
await page.signIn(app, ["alpha"]);
|
||||
expect(await page.result(await page.call("alpha", "add", 2))).toEqual(ok("v1:2"));
|
||||
await page.waitText("alpha", ".label", "v1:2");
|
||||
await page.type("alpha", ".draft", "half-typed");
|
||||
return app;
|
||||
}
|
||||
|
||||
it("keeps an old page working through the mapping of a contract the new code accepts", async () => {
|
||||
const app = await oldPage();
|
||||
try {
|
||||
const settled = (await page.states("alpha")).length;
|
||||
const result = await editAndApply(
|
||||
app,
|
||||
"counter",
|
||||
counterPlugin({ code: "v2", placement: "worker", contract: "v2", accepts: true }),
|
||||
);
|
||||
expect(rolloutFailures(result)).toEqual([]);
|
||||
// The v1 page reads v2's label mapped back, and its v1 call is mapped forwards and its result back.
|
||||
await page.waitText("alpha", ".label", "v2:2");
|
||||
expect(await page.result(await page.call("alpha", "add", 1))).toEqual(ok("v2:3"));
|
||||
await page.waitText("alpha", ".label", "v2:3");
|
||||
expect(await page.result(await page.call("alpha", "code"))).toEqual(ok("v2"));
|
||||
expect((await page.states("alpha")).slice(settled)).toEqual([]);
|
||||
expect(await page.text("alpha", ".count")).toBe("3");
|
||||
expect(await page.result(await page.call("alpha", "visits"))).toEqual(ok(2));
|
||||
expect(await page.value("alpha", ".draft")).toBe("half-typed");
|
||||
} finally {
|
||||
await app.app.close(context);
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
it("keeps the last values while the version is refused and binds once the page's code updates", async () => {
|
||||
const app = await oldPage();
|
||||
try {
|
||||
const result = await editAndApply(
|
||||
app,
|
||||
"counter",
|
||||
counterPlugin({ code: "v2", placement: "worker", contract: "v2" }),
|
||||
);
|
||||
expect(rolloutFailures(result)).toEqual([]);
|
||||
|
||||
// Unbound `version` (§3): `failed { waiting }` with `contract_mismatch`.
|
||||
await page.waitState("alpha", "failed:waiting:contract_mismatch", 5000);
|
||||
// The last values stay on screen.
|
||||
expect(await page.text("alpha", ".label")).toBe("v1:2");
|
||||
expect(await page.text("alpha", ".count")).toBe("2");
|
||||
expect(await page.text("alpha", ".state")).toBe("failed");
|
||||
|
||||
// The page's code updates (its contract copy at v2): the remounted view binds the new version.
|
||||
await editAndApply(
|
||||
app,
|
||||
"alpha",
|
||||
webPlugin({ name: "alpha", code: "2", counter: { placement: "worker", contract: "v2" } }),
|
||||
);
|
||||
await page.waitText("alpha", ".code", "alpha:2");
|
||||
await page.waitText("alpha", ".label", JSON.stringify({ text: "v2:2", code: "v2" }));
|
||||
await page.waitText("alpha", ".state", "live");
|
||||
expect(await page.result(await page.call("alpha", "add", { n: 1 }))).toEqual(ok({ text: "v2:3" }));
|
||||
expect(await page.result(await page.call("alpha", "visits"))).toEqual(ok(2));
|
||||
} finally {
|
||||
await app.app.close(context);
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
it("keeps the last values while the type is removed and binds again when the type returns", async () => {
|
||||
const app = await oldPage();
|
||||
try {
|
||||
const result = await removeAndApply(app, "counter");
|
||||
expect(rolloutFailures(result)).toEqual([]);
|
||||
|
||||
// Unbound `not_served` (§3): `failed { waiting }` with `unavailable` ("X is not served here").
|
||||
await page.waitState("alpha", "failed:waiting:unavailable", 5000);
|
||||
expect(await page.text("alpha", ".label")).toBe("v1:2");
|
||||
expect(await page.text("alpha", ".count")).toBe("2");
|
||||
|
||||
// The type returns: the same reference binds again, the persisted count as it was.
|
||||
await editAndApply(app, "counter", counterPlugin({ code: "v1", placement: "worker" }));
|
||||
await page.waitState("alpha", "live");
|
||||
await page.waitText("alpha", ".state", "live");
|
||||
expect(await page.text("alpha", ".label")).toBe("v1:2");
|
||||
expect(await page.result(await page.call("alpha", "add", 1))).toEqual(ok("v1:3"));
|
||||
expect(await page.result(await page.call("alpha", "visits"))).toEqual(ok(2));
|
||||
expect(await page.value("alpha", ".draft")).toBe("half-typed");
|
||||
} finally {
|
||||
await app.app.close(context);
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
it.each([
|
||||
["the version is refused", "failed:waiting:contract_mismatch", "contract_mismatch"],
|
||||
["the type is removed", "failed:waiting:unavailable", "unavailable"],
|
||||
] as const)(
|
||||
"fails a call at once with the cause while %s",
|
||||
async (change, waiting, code) => {
|
||||
const app = await oldPage();
|
||||
try {
|
||||
await page.hold("short", "alpha", PARK_MS);
|
||||
await page.waitState("short", "live");
|
||||
if (change === "the type is removed") await removeAndApply(app, "counter");
|
||||
else await editAndApply(app, "counter", counterPlugin({ code: "v2", placement: "worker", contract: "v2" }));
|
||||
await page.waitState("short", waiting, 5000);
|
||||
const started = Date.now();
|
||||
const failed = await page.result(await page.call("short", "add", 1));
|
||||
expect(failed).toMatchObject({ status: "error", code });
|
||||
// Not parked: well before the reference's `readyTimeout`.
|
||||
expect(Date.now() - started).toBeLessThan(PARK_MS);
|
||||
} finally {
|
||||
await app.app.close(context);
|
||||
}
|
||||
},
|
||||
120_000,
|
||||
);
|
||||
});
|
||||
170
packages/chord/test/node/reload/failed.test.ts
Normal file
170
packages/chord/test/node/reload/failed.test.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
// Reload suite, row "a plugin whose new build or evaluation fails" (design §21 "What a user sees during a reload"):
|
||||
// everything stays on screen; the plugin keeps its old code and the error is reported to the app; calls and state are
|
||||
// unaffected. Through a real page: a worker plugin whose new code fails to build, throws when evaluated, or whose setup
|
||||
// throws (staging), and a web plugin whose new code fails to build or throws when evaluated.
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
|
||||
import { editAndApply, ok, ReloadPage, rolloutFailures, rolloutFates, startReloadApp } from "../fixtures/reload/app.ts";
|
||||
import { counterPlugin, LATCH_PLUGIN, webPlugin } from "../fixtures/reload/plugins.ts";
|
||||
import { Browser, CHROMIUM } from "../web/browser.ts";
|
||||
|
||||
/** The counter as the web plugins show it. */
|
||||
const COUNTER = { placement: "worker", contract: "v1" } as const;
|
||||
|
||||
describe.skipIf(CHROMIUM === undefined)("a reload whose new code fails", () => {
|
||||
let browser: Browser;
|
||||
let page: ReloadPage;
|
||||
|
||||
beforeAll(async () => {
|
||||
browser = await Browser.start();
|
||||
page = new ReloadPage(browser);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
});
|
||||
|
||||
it.each(["build", "evaluation", "setup"] as const)(
|
||||
"keeps a worker plugin whose new %s fails on its old code, the page untouched",
|
||||
async (broken) => {
|
||||
const placement = "worker";
|
||||
const app = await startReloadApp({
|
||||
latch: LATCH_PLUGIN,
|
||||
counter: counterPlugin({ code: "v1", placement }),
|
||||
alpha: webPlugin({ name: "alpha", code: "1", counter: { placement, contract: "v1" } }),
|
||||
});
|
||||
try {
|
||||
await page.signIn(app, ["alpha"]);
|
||||
expect(await page.result(await page.call("alpha", "add", 2))).toEqual(ok("v1:2"));
|
||||
await page.waitText("alpha", ".label", "v1:2");
|
||||
await page.type("alpha", ".draft", "half-typed");
|
||||
const settled = (await page.states("alpha")).length;
|
||||
const frames = (await page.frames("alpha")).length;
|
||||
|
||||
const result = await editAndApply(app, "counter", counterPlugin({ code: "v2", placement, broken }));
|
||||
// Reported to the app: the worker kept the plugin's old code, and it stays dirty for the next apply.
|
||||
expect(rolloutFailures(result)).toEqual(["project/project|p1: @test/counter"]);
|
||||
expect(app.app.plugins.state.value.dirty).toContain("@test/counter");
|
||||
|
||||
// The page: nothing moved, calls answered by the old code, the document as it was.
|
||||
expect(await page.result(await page.call("alpha", "code"))).toEqual(ok("v1"));
|
||||
expect(await page.result(await page.call("alpha", "add", 1))).toEqual(ok("v1:3"));
|
||||
await page.waitText("alpha", ".label", "v1:3");
|
||||
expect((await page.states("alpha")).slice(settled)).toEqual([]);
|
||||
expect((await page.frames("alpha")).slice(frames)).toEqual(["v1:3"]);
|
||||
expect(await page.result(await page.call("alpha", "visits"))).toEqual(ok(2));
|
||||
expect(await page.value("alpha", ".draft")).toBe("half-typed");
|
||||
expect(await page.loads()).toBe(1);
|
||||
} finally {
|
||||
await app.app.close(context);
|
||||
}
|
||||
},
|
||||
120_000,
|
||||
);
|
||||
|
||||
/** The app with the counter, a web plugin `alpha` showing it and a web plugin `beta`, alpha's inputs typed in. */
|
||||
async function webApp() {
|
||||
const app = await startReloadApp({
|
||||
latch: LATCH_PLUGIN,
|
||||
counter: counterPlugin({ code: "v1", placement: "worker" }),
|
||||
alpha: webPlugin({ name: "alpha", code: "1", counter: COUNTER }),
|
||||
beta: webPlugin({ name: "beta", code: "1" }),
|
||||
});
|
||||
await page.signIn(app, ["alpha", "beta"]);
|
||||
await page.waitText("alpha", ".label", "v1:0");
|
||||
await page.type("alpha", ".draft", "half-typed");
|
||||
await page.type("alpha", ".kept", "kept");
|
||||
return app;
|
||||
}
|
||||
|
||||
/** The old component still mounted with its state, its reference untouched since `settled` states, calls answered. */
|
||||
async function oldComponentKept(settled: number): Promise<void> {
|
||||
expect(await page.text("alpha", ".code")).toBe("alpha:1");
|
||||
expect(await page.value("alpha", ".draft")).toBe("half-typed");
|
||||
expect(await page.value("alpha", ".kept")).toBe("kept");
|
||||
expect((await page.states("alpha")).slice(settled)).toEqual([]);
|
||||
expect(await page.result(await page.call("alpha", "add", 1))).toEqual(ok("v1:1"));
|
||||
expect(await page.loads()).toBe(1);
|
||||
}
|
||||
|
||||
it("keeps a web plugin whose new build fails on its old code, its component and state on screen", async () => {
|
||||
const app = await webApp();
|
||||
try {
|
||||
const settled = (await page.states("alpha")).length;
|
||||
const result = await editAndApply(
|
||||
app,
|
||||
"alpha",
|
||||
webPlugin({ name: "alpha", code: "2", counter: COUNTER, broken: "build" }),
|
||||
);
|
||||
// Reported to the app: the web step kept the plugin's old script, and it stays dirty for the next apply.
|
||||
expect(rolloutFailures(result)).toEqual(["web/web: @test/alpha"]);
|
||||
expect(app.app.plugins.state.value.dirty).toContain("@test/alpha");
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
await oldComponentKept(settled);
|
||||
expect(await browser.evaluate("globalThis.page.errors")).toEqual([]);
|
||||
} finally {
|
||||
await app.app.close(context);
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
// Open design point (2026-11-04, with the owner): pages don't report kept-old plugins back. The server's web step
|
||||
// publishes the set without waiting for clients, so it marks the plugin applied (no longer dirty) although the page
|
||||
// kept its old code; decision "Per plugin at startup and reload" (5) says a plugin kept old on any host stays dirty.
|
||||
it("keeps a web plugin whose new script throws in the page on its old code, reported in the page; the server marks it applied (open: pages don't report kept-old plugins back)", async () => {
|
||||
const app = await webApp();
|
||||
try {
|
||||
const settled = (await page.states("alpha")).length;
|
||||
await editAndApply(
|
||||
app,
|
||||
"alpha",
|
||||
webPlugin({ name: "alpha", code: "2", counter: COUNTER, broken: "evaluation" }),
|
||||
);
|
||||
// The page evaluates the script: it keeps the old one and reports it (`startPage`'s `onError`).
|
||||
await browser.wait("globalThis.page.errors.length > 0");
|
||||
expect(await browser.evaluate("globalThis.page.errors")).toEqual([
|
||||
{ kind: "plugins", plugins: ["@test/alpha"] },
|
||||
]);
|
||||
await oldComponentKept(settled);
|
||||
// Today's behaviour, the open point above: the server considers it applied.
|
||||
expect(app.app.plugins.state.value.dirty).toEqual([]);
|
||||
} finally {
|
||||
await app.app.close(context);
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
// A case the table leaves out (the audit's "new code that fails at activation"): the new code passes staging, so the
|
||||
// commit applies it; its native then fails to open. A runtime state (§21: failed opens stay per object after the
|
||||
// commit): the old code is gone, the page shows the failure with the last values, and the next fix applies.
|
||||
it("shows a failure with the last values when new code passes staging and fails at activation, until a fix", async () => {
|
||||
const placement = "worker";
|
||||
const app = await startReloadApp({
|
||||
latch: LATCH_PLUGIN,
|
||||
counter: counterPlugin({ code: "v1", placement, native: true }),
|
||||
alpha: webPlugin({ name: "alpha", code: "1", counter: COUNTER }),
|
||||
});
|
||||
try {
|
||||
await page.signIn(app, ["alpha"]);
|
||||
expect(await page.result(await page.call("alpha", "add", 2))).toEqual(ok("v1:2"));
|
||||
await page.waitText("alpha", ".label", "v1:2");
|
||||
const result = await editAndApply(
|
||||
app,
|
||||
"counter",
|
||||
counterPlugin({ code: "v2", placement, native: true, broken: "open" }),
|
||||
);
|
||||
expect(rolloutFailures(result)).toEqual([]);
|
||||
expect(rolloutFates(result)).toContain("project/project|p1: reload.counter(p1) restarted");
|
||||
expect(app.app.plugins.state.value.dirty).toEqual([]);
|
||||
await page.waitText("alpha", ".state", "failed");
|
||||
expect(await page.text("alpha", ".label")).toBe("v1:2");
|
||||
expect(await page.result(await page.call("alpha", "code"))).toMatchObject({ status: "error" });
|
||||
|
||||
await editAndApply(app, "counter", counterPlugin({ code: "v3", placement, native: true }));
|
||||
await page.waitText("alpha", ".state", "live");
|
||||
await page.waitText("alpha", ".label", "v3:2");
|
||||
expect(await page.result(await page.call("alpha", "code"))).toEqual(ok("v3"));
|
||||
} finally {
|
||||
await app.app.close(context);
|
||||
}
|
||||
}, 120_000);
|
||||
});
|
||||
71
packages/chord/test/node/reload/jobs.test.ts
Normal file
71
packages/chord/test/node/reload/jobs.test.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
// Reload suite, a case the table leaves out (the audit's "jobs running across a reload"; design §15 "Versions"): a job
|
||||
// run started from the page, running on a worker while its object reloads. A pure swap: the run finishes on its old
|
||||
// code and `finish` runs on the new code. A native restart: the in-memory run ends `aborted { reload }` and `finish`
|
||||
// runs on the new code. The page watches the finished runs' entries.
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
|
||||
import {
|
||||
editAndApply,
|
||||
latches,
|
||||
ReloadPage,
|
||||
rolloutFailures,
|
||||
rolloutFates,
|
||||
startReloadApp,
|
||||
} from "../fixtures/reload/app.ts";
|
||||
import { LATCH_PLUGIN, tasksPlugin } from "../fixtures/reload/plugins.ts";
|
||||
import { Browser, CHROMIUM } from "../web/browser.ts";
|
||||
|
||||
describe.skipIf(CHROMIUM === undefined)("a job running across a reload", () => {
|
||||
let browser: Browser;
|
||||
let page: ReloadPage;
|
||||
|
||||
beforeAll(async () => {
|
||||
browser = await Browser.start();
|
||||
page = new ReloadPage(browser);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a pure swap", false, "swapped", "v2-finish(done:run-v1:a)"],
|
||||
["a native restart", true, "restarted", "v2-finish(aborted:reload)"],
|
||||
] as const)(
|
||||
"across %s",
|
||||
async (_change, native, fate, entry) => {
|
||||
const app = await startReloadApp({ latch: LATCH_PLUGIN, tasks: tasksPlugin("v1", native) });
|
||||
const hand = await latches(app, "worker");
|
||||
try {
|
||||
await page.signIn(app, []);
|
||||
await page.hold("tasks", "tasks", 10_000, "p1", "entries");
|
||||
await page.waitState("tasks", "live");
|
||||
const started = await page.result(await page.call("tasks", "start", "a"));
|
||||
expect(started).toMatchObject({ status: "ok" });
|
||||
await hand.entered("a");
|
||||
|
||||
const result = await editAndApply(app, "tasks", tasksPlugin("v2", native));
|
||||
expect(rolloutFailures(result)).toEqual([]);
|
||||
expect(rolloutFates(result)).toContain(`project/project|p1: reload.runner ${fate}`);
|
||||
await hand.open("a");
|
||||
await browser.wait(
|
||||
`globalThis.reload.frames.tasks?.some((frame) => Array.isArray(frame) && frame.length > 0)`,
|
||||
);
|
||||
const frames = await page.frames("tasks");
|
||||
expect(frames.at(-1)).toEqual([entry]);
|
||||
// A run started now runs on the new code.
|
||||
await page.call("tasks", "start", "b");
|
||||
await hand.entered("b");
|
||||
await hand.open("b");
|
||||
await browser.wait("globalThis.reload.frames.tasks?.at(-1)?.length === 2");
|
||||
expect((await page.frames("tasks")).at(-1)).toEqual([entry, "v2-finish(done:run-v2:b)"]);
|
||||
} finally {
|
||||
await hand.open("a");
|
||||
await hand.open("b");
|
||||
await app.app.close(context);
|
||||
}
|
||||
},
|
||||
120_000,
|
||||
);
|
||||
});
|
||||
133
packages/chord/test/node/reload/pure.test.ts
Normal file
133
packages/chord/test/node/reload/pure.test.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
// Reload suite, row "pure code (no natives) on the server or a worker" (design §21 "What a user sees during a reload"):
|
||||
// the view stays bound, one fresh snapshot once the new code is live, values change only where the new code computes
|
||||
// differently; a running call finishes on the old code, a call arriving meanwhile waits and runs on the new code, never
|
||||
// `not_ready`; documents and tables unchanged. Through a real page, for a counter on a project's worker and on the
|
||||
// server.
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
|
||||
import {
|
||||
editAndApply,
|
||||
latches,
|
||||
ok,
|
||||
ReloadPage,
|
||||
rolloutFailures,
|
||||
rolloutFates,
|
||||
startReloadApp,
|
||||
} from "../fixtures/reload/app.ts";
|
||||
import { counterPlugin, LATCH_PLUGIN, type Placement, summaryPlugin, webPlugin } from "../fixtures/reload/plugins.ts";
|
||||
import { Browser, CHROMIUM } from "../web/browser.ts";
|
||||
|
||||
describe.skipIf(CHROMIUM === undefined)("a reload of pure code", () => {
|
||||
let browser: Browser;
|
||||
let page: ReloadPage;
|
||||
|
||||
beforeAll(async () => {
|
||||
browser = await Browser.start();
|
||||
page = new ReloadPage(browser);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
});
|
||||
|
||||
describe.each<Placement>(["worker", "server"])("on the %s", (placement) => {
|
||||
it("keeps the view bound, finishes a running call on the old code and runs new calls on the new code", async () => {
|
||||
const app = await startReloadApp({
|
||||
latch: LATCH_PLUGIN,
|
||||
counter: counterPlugin({ code: "v1", placement }),
|
||||
alpha: webPlugin({ name: "alpha", code: "1", counter: { placement, contract: "v1" } }),
|
||||
});
|
||||
try {
|
||||
await page.signIn(app, ["alpha"]);
|
||||
await page.waitText("alpha", ".label", "v1:0");
|
||||
expect(await page.result(await page.call("alpha", "add", 2))).toEqual(ok("v1:2"));
|
||||
await page.waitText("alpha", ".label", "v1:2");
|
||||
await page.type("alpha", ".draft", "half-typed");
|
||||
const settled = (await page.states("alpha")).length;
|
||||
const hand = await latches(app, placement);
|
||||
const running = await page.call("alpha", "slow", "running");
|
||||
await hand.entered("running");
|
||||
|
||||
// The new code computes the label differently (its code name) and the count the same.
|
||||
const applying = editAndApply(app, "counter", counterPlugin({ code: "v2", placement }));
|
||||
// Calls sent while the rollout runs: each answered by the old or the new code, never `not_ready`.
|
||||
const during: number[] = [];
|
||||
let applied = false;
|
||||
void applying.finally(() => {
|
||||
applied = true;
|
||||
});
|
||||
while (!applied) {
|
||||
during.push(await page.call("alpha", "code"));
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
const result = await applying;
|
||||
expect(rolloutFailures(result)).toEqual([]);
|
||||
const step = placement === "worker" ? "project/project|p1" : "server/server";
|
||||
expect(rolloutFates(result)).toEqual([
|
||||
`${step}: reload.store swapped`,
|
||||
`${step}: reload.counter(p1) swapped`,
|
||||
]);
|
||||
|
||||
// Arriving after the commit, while the old call still runs: the new code answers.
|
||||
expect(await page.result(await page.call("alpha", "add", 1))).toEqual(ok("v2:3"));
|
||||
expect(await page.peek(running)).toEqual({ status: "pending" });
|
||||
await hand.open("running");
|
||||
expect(await page.result(running)).toEqual(ok("v1:running"));
|
||||
for (const index of during) {
|
||||
const answer = await page.result(index);
|
||||
expect([ok("v1"), ok("v2")]).toContainEqual(answer);
|
||||
}
|
||||
|
||||
await page.waitText("alpha", ".label", "v2:3");
|
||||
// Bound throughout: the reference never left `live`, its label went from the old value to the new.
|
||||
expect((await page.states("alpha")).slice(settled - 1)).toEqual(["live"]);
|
||||
const frames = await page.frames("alpha");
|
||||
expect(frames.filter((frame) => typeof frame === "string" && frame.startsWith("failed"))).toEqual([]);
|
||||
expect(frames.slice(frames.indexOf("v1:2"))).toEqual(["v1:2", "v2:2", "v2:3"]);
|
||||
// Documents and tables unchanged: the count went on from 2, the table kept the old code's rows.
|
||||
expect(await page.text("alpha", ".count")).toBe("3");
|
||||
expect(await page.result(await page.call("alpha", "visits"))).toEqual(ok(2));
|
||||
// The page itself was not touched.
|
||||
expect(await page.value("alpha", ".draft")).toBe("half-typed");
|
||||
expect(await page.loads()).toBe(1);
|
||||
} finally {
|
||||
await app.app.close(context);
|
||||
}
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
// The audit's F1: every `self.get` of a type on another host opens a new hold, which starts `loading`, so the new
|
||||
// attempt of a pure object over a worker object parks instead of swapping in place.
|
||||
// Expected to fail until the open design point is settled: "a parked swap keeps the old instance serving" (under review).
|
||||
it.fails(
|
||||
"keeps the view of a pure server object over a worker object bound when only the server object reloads",
|
||||
async () => {
|
||||
const app = await startReloadApp({
|
||||
latch: LATCH_PLUGIN,
|
||||
counter: counterPlugin({ code: "v1", placement: "worker" }),
|
||||
summary: summaryPlugin("s1"),
|
||||
alpha: webPlugin({
|
||||
name: "alpha",
|
||||
code: "1",
|
||||
counter: { placement: "server", contract: "v1" },
|
||||
summary: true,
|
||||
}),
|
||||
});
|
||||
try {
|
||||
await page.signIn(app, ["alpha"]);
|
||||
await page.waitText("alpha", ".label", "s1(0)");
|
||||
const settled = (await page.states("alpha")).length;
|
||||
const result = await editAndApply(app, "summary", summaryPlugin("s2"));
|
||||
expect(rolloutFailures(result)).toEqual([]);
|
||||
await page.waitText("alpha", ".label", "s2(0)");
|
||||
expect(await page.result(await page.call("alpha", "code"))).toEqual(ok("s2"));
|
||||
expect((await page.states("alpha")).slice(settled)).toEqual([]);
|
||||
expect(rolloutFates(result)).toEqual(["server/server: reload.summary(p1) swapped"]);
|
||||
} finally {
|
||||
await app.app.close(context);
|
||||
}
|
||||
},
|
||||
120_000,
|
||||
);
|
||||
});
|
||||
126
packages/chord/test/node/reload/restart.test.ts
Normal file
126
packages/chord/test/node/reload/restart.test.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
// Reload suite, row "code holding natives (restart)" (design §21 "What a user sees during a reload", as corrected
|
||||
// 2026-11-04): while the old code drains its running calls (up to `closeTimeout`) the page stays `live` with its last
|
||||
// values and whatever the draining old code commits; calls sent meanwhile wait at the gate; then `loading` briefly,
|
||||
// then a fresh snapshot from the new code, which answers the waiting calls; documents and tables unchanged, natives
|
||||
// reopened. Through a real page, for a counter holding a native on a project's worker and on the server.
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
|
||||
import {
|
||||
editAndApply,
|
||||
latches,
|
||||
ok,
|
||||
ReloadPage,
|
||||
rolloutFailures,
|
||||
rolloutFates,
|
||||
startReloadApp,
|
||||
TIMEOUTS,
|
||||
} from "../fixtures/reload/app.ts";
|
||||
import { counterPlugin, LATCH_PLUGIN, type Placement, webPlugin } from "../fixtures/reload/plugins.ts";
|
||||
import { Browser, CHROMIUM } from "../web/browser.ts";
|
||||
|
||||
describe.skipIf(CHROMIUM === undefined)("a reload restarting an object with natives", () => {
|
||||
let browser: Browser;
|
||||
let page: ReloadPage;
|
||||
|
||||
beforeAll(async () => {
|
||||
browser = await Browser.start();
|
||||
page = new ReloadPage(browser);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
});
|
||||
|
||||
describe.each<Placement>(["worker", "server"])("on the %s", (placement) => {
|
||||
it("stays live while the old code drains, then shows loading and a fresh snapshot from the new code", async () => {
|
||||
const app = await startReloadApp({
|
||||
latch: LATCH_PLUGIN,
|
||||
counter: counterPlugin({ code: "v1", placement, native: true }),
|
||||
alpha: webPlugin({ name: "alpha", code: "1", counter: { placement, contract: "v1" } }),
|
||||
});
|
||||
const hand = await latches(app, placement);
|
||||
try {
|
||||
await page.signIn(app, ["alpha"]);
|
||||
await page.waitText("alpha", ".state", "live");
|
||||
expect(await page.result(await page.call("alpha", "add", 2))).toEqual(ok("v1:2"));
|
||||
await page.waitText("alpha", ".label", "v1:2");
|
||||
const settled = (await page.states("alpha")).length;
|
||||
const before = await hand.natives();
|
||||
// A running call that commits once the test lets it go on.
|
||||
const running = await page.call("alpha", "slowAdd", "running");
|
||||
await hand.entered("running");
|
||||
|
||||
const result = await editAndApply(app, "counter", counterPlugin({ code: "v2", placement, native: true }));
|
||||
expect(rolloutFailures(result)).toEqual([]);
|
||||
const step = placement === "worker" ? "project/project|p1" : "server/server";
|
||||
expect(rolloutFates(result)).toEqual([
|
||||
`${step}: reload.store swapped`,
|
||||
`${step}: reload.counter(p1) restarted`,
|
||||
]);
|
||||
|
||||
// The drain: still `live` with the last values; a call sent now waits at the gate.
|
||||
const arriving = await page.call("alpha", "add", 1);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
expect(await page.peek(arriving)).toEqual({ status: "pending" });
|
||||
expect(await page.peek(running)).toEqual({ status: "pending" });
|
||||
expect((await page.states("alpha")).slice(settled)).toEqual([]);
|
||||
expect(await page.text("alpha", ".state")).toBe("live");
|
||||
expect(await page.text("alpha", ".label")).toBe("v1:2");
|
||||
|
||||
// The running call commits through the store (swapped: its new code answers) and finishes on the old
|
||||
// counter; the page sees that commit through the old code before `loading`.
|
||||
await hand.open("running");
|
||||
expect(await page.result(running)).toEqual(ok("v2:12"));
|
||||
expect(await page.result(arriving)).toEqual(ok("v2:13"));
|
||||
await page.waitText("alpha", ".label", "v2:13");
|
||||
await page.waitText("alpha", ".state", "live");
|
||||
expect((await page.states("alpha")).slice(settled)).toEqual(["loading", "live"]);
|
||||
const frames = await page.frames("alpha");
|
||||
const since = frames.slice(frames.lastIndexOf("v1:2"));
|
||||
expect(since.slice(0, 2)).toEqual(["v1:2", "v1:12"]);
|
||||
expect(since.at(-1)).toBe("v2:13");
|
||||
expect(since.filter((frame) => typeof frame === "string" && frame.startsWith("failed"))).toEqual([]);
|
||||
// The native closed for the reload and reopened on the new code; documents and tables unchanged.
|
||||
const after = await hand.natives();
|
||||
expect(after.opens.slice(before.opens.length)).toEqual(["v2"]);
|
||||
expect(after.closes.slice(before.closes.length)).toEqual(["v1:reload"]);
|
||||
expect(await page.text("alpha", ".count")).toBe("13");
|
||||
expect(await page.result(await page.call("alpha", "visits"))).toEqual(ok(3));
|
||||
} finally {
|
||||
// A failed expectation leaves the call waiting: open it so the close does not wait for the drain.
|
||||
await hand.open("running");
|
||||
await app.app.close(context);
|
||||
}
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
it.each<Placement>(["worker", "server"])(
|
||||
"cuts a running call off after closeTimeout on the %s, then runs the waiting call on the new code",
|
||||
async (placement) => {
|
||||
const app = await startReloadApp({
|
||||
latch: LATCH_PLUGIN,
|
||||
counter: counterPlugin({ code: "v1", placement, native: true }),
|
||||
alpha: webPlugin({ name: "alpha", code: "1", counter: { placement, contract: "v1" } }),
|
||||
});
|
||||
try {
|
||||
await page.signIn(app, ["alpha"]);
|
||||
await page.waitText("alpha", ".state", "live");
|
||||
const hand = await latches(app, placement);
|
||||
const stuck = await page.call("alpha", "slow", "stuck");
|
||||
await hand.entered("stuck");
|
||||
const started = Date.now();
|
||||
await editAndApply(app, "counter", counterPlugin({ code: "v2", placement, native: true }));
|
||||
const arriving = await page.call("alpha", "code");
|
||||
const cut = await page.result(stuck);
|
||||
expect(Date.now() - started).toBeGreaterThanOrEqual(TIMEOUTS.closeTimeout - 100);
|
||||
expect(cut).toMatchObject({ status: "error", code: "released" });
|
||||
expect(await page.result(arriving)).toEqual(ok("v2"));
|
||||
await page.waitText("alpha", ".state", "live");
|
||||
} finally {
|
||||
await app.app.close(context);
|
||||
}
|
||||
},
|
||||
120_000,
|
||||
);
|
||||
});
|
||||
185
packages/chord/test/node/reload/rollout.test.ts
Normal file
185
packages/chord/test/node/reload/rollout.test.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
// Reload suite, the rollout across hosts (design §21 "What a user sees during a reload": "the server, then every
|
||||
// running worker by the same rules, then the clients' scripts") and the cases the table leaves out: a worker not
|
||||
// running during the rollout, a page connecting while a restart drains, two rollouts back to back, a rollout while the
|
||||
// page's link is down. Through real pages.
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
|
||||
import {
|
||||
edit,
|
||||
editAndApply,
|
||||
latches,
|
||||
ok,
|
||||
ReloadPage,
|
||||
rolloutFailures,
|
||||
rolloutFates,
|
||||
startReloadApp,
|
||||
} from "../fixtures/reload/app.ts";
|
||||
import { counterPlugin, LATCH_PLUGIN, summaryPlugin, webPlugin } from "../fixtures/reload/plugins.ts";
|
||||
import { Browser, CHROMIUM } from "../web/browser.ts";
|
||||
|
||||
/** The counter as the web plugins show it. */
|
||||
const COUNTER = { placement: "worker", contract: "v1" } as const;
|
||||
|
||||
describe.skipIf(CHROMIUM === undefined)("a rollout across hosts", () => {
|
||||
let browser: Browser;
|
||||
let page: ReloadPage;
|
||||
|
||||
beforeAll(async () => {
|
||||
browser = await Browser.start();
|
||||
page = new ReloadPage(browser);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
});
|
||||
|
||||
/** The app with the counter on the workers and `alpha` showing p1's, signed in, its counter at 2. */
|
||||
async function started(native = false) {
|
||||
const app = await startReloadApp({
|
||||
latch: LATCH_PLUGIN,
|
||||
counter: counterPlugin({ code: "v1", placement: "worker", native }),
|
||||
alpha: webPlugin({ name: "alpha", code: "1", counter: COUNTER }),
|
||||
});
|
||||
await page.signIn(app, ["alpha"]);
|
||||
await page.waitText("alpha", ".state", "live");
|
||||
expect(await page.result(await page.call("alpha", "add", 2))).toEqual(ok("v1:2"));
|
||||
await page.waitText("alpha", ".label", "v1:2");
|
||||
return app;
|
||||
}
|
||||
|
||||
it("reloads the server, then the running workers, then the clients; a worker not running starts on the new code", async () => {
|
||||
const app = await started();
|
||||
try {
|
||||
const result = await editAndApply(app, "counter", counterPlugin({ code: "v2", placement: "worker" }));
|
||||
expect(rolloutFailures(result)).toEqual([]);
|
||||
expect(result.steps.map(({ name, hosts }) => [name, hosts.map(({ host }) => host)])).toEqual([
|
||||
["server", ["server"]],
|
||||
["project", ["project|p1"]],
|
||||
["web", ["web"]],
|
||||
]);
|
||||
expect(rolloutFates(result)).toEqual([
|
||||
"project/project|p1: reload.store swapped",
|
||||
"project/project|p1: reload.counter(p1) swapped",
|
||||
]);
|
||||
// Project p2 had no worker during the rollout: its first hold starts one on the current set.
|
||||
await page.hold("p2", "alpha", 10_000, "p2");
|
||||
await page.waitState("p2", "live");
|
||||
expect(await page.result(await page.call("p2", "code"))).toEqual(ok("v2"));
|
||||
expect(await page.result(await page.call("p2", "add", 1))).toEqual(ok("v2:1"));
|
||||
expect(await page.result(await page.call("alpha", "code"))).toEqual(ok("v2"));
|
||||
} finally {
|
||||
await app.app.close(context);
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
it("binds a page that connects while a restart drains to the new code once it is live", async () => {
|
||||
const app = await started(true);
|
||||
const hand = await latches(app, "worker");
|
||||
const second = await Browser.start();
|
||||
try {
|
||||
const running = await page.call("alpha", "slowAdd", "running");
|
||||
await hand.entered("running");
|
||||
await editAndApply(app, "counter", counterPlugin({ code: "v2", placement: "worker", native: true }));
|
||||
// A second page signs in while the old code drains.
|
||||
const late = new ReloadPage(second);
|
||||
await late.signInAgain(app, ["alpha"]);
|
||||
const call = await late.call("alpha", "code");
|
||||
await hand.open("running");
|
||||
expect(await page.result(running)).toEqual(ok("v2:12"));
|
||||
expect(await late.result(call)).toEqual(ok("v2"));
|
||||
await late.waitText("alpha", ".label", "v2:12");
|
||||
await late.waitText("alpha", ".state", "live");
|
||||
await page.waitText("alpha", ".label", "v2:12");
|
||||
} finally {
|
||||
await hand.open("running");
|
||||
await second.close();
|
||||
await app.app.close(context);
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
it("ends two rollouts back to back on the second's code, the page following without failing", async () => {
|
||||
const app = await started();
|
||||
try {
|
||||
const settled = (await page.states("alpha")).length;
|
||||
await edit(app, "counter", counterPlugin({ code: "v2", placement: "worker" }));
|
||||
const first = app.app.applyChanges(context);
|
||||
await edit(app, "counter", counterPlugin({ code: "v3", placement: "worker" }));
|
||||
const second = app.app.applyChanges(context);
|
||||
expect(rolloutFailures(await first)).toEqual([]);
|
||||
expect(rolloutFailures(await second)).toEqual([]);
|
||||
await page.waitText("alpha", ".label", "v3:2");
|
||||
expect(await page.result(await page.call("alpha", "add", 1))).toEqual(ok("v3:3"));
|
||||
expect((await page.states("alpha")).slice(settled)).toEqual([]);
|
||||
expect(app.app.plugins.state.value.dirty).toEqual([]);
|
||||
} finally {
|
||||
await app.app.close(context);
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
it("brings a page whose link was down during the rollout onto the new scripts and the new code", async () => {
|
||||
const app = await started();
|
||||
try {
|
||||
await page.type("alpha", ".kept", "kept");
|
||||
await browser.evaluate("globalThis.page.holdReconnect(); globalThis.page.dropLink()");
|
||||
await browser.wait('globalThis.page.connection !== "connected"');
|
||||
await page.waitState("alpha", "loading");
|
||||
// The last values stay on screen while the link is down.
|
||||
expect(await page.text("alpha", ".label")).toBe("v1:2");
|
||||
|
||||
await edit(app, "counter", counterPlugin({ code: "v2", placement: "worker" }));
|
||||
await edit(app, "alpha", webPlugin({ name: "alpha", code: "2", counter: COUNTER }));
|
||||
const result = await app.app.applyChanges(context);
|
||||
expect(rolloutFailures(result)).toEqual([]);
|
||||
await browser.evaluate("globalThis.page.release()");
|
||||
|
||||
await browser.wait('globalThis.page.connection === "connected"');
|
||||
await page.waitText("alpha", ".code", "alpha:2");
|
||||
await page.waitText("alpha", ".label", "v2:2");
|
||||
await page.waitText("alpha", ".state", "live");
|
||||
expect(await page.value("alpha", ".kept")).toBe("kept");
|
||||
expect(await page.result(await page.call("alpha", "add", 1))).toEqual(ok("v2:3"));
|
||||
expect(await page.loads()).toBe(1);
|
||||
} finally {
|
||||
await app.app.close(context);
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
// A case the table leaves out (the audit's "a contract change between hosts, consumer first"): the counter's
|
||||
// contract moves to v2 without `accepts`, and the server's summary, which holds it, changes with it (it copies the
|
||||
// contract file). The server step runs first: the summary's new request meets the old worker serving v1.
|
||||
// Expected to fail until the open design point is settled: "a parked swap keeps the old instance serving" (under review).
|
||||
it.fails(
|
||||
"keeps the view of a server consumer bound while its worker provider moves to a new version with it",
|
||||
async () => {
|
||||
const app = await startReloadApp({
|
||||
latch: LATCH_PLUGIN,
|
||||
counter: counterPlugin({ code: "v1", placement: "worker" }),
|
||||
summary: summaryPlugin("s1"),
|
||||
alpha: webPlugin({
|
||||
name: "alpha",
|
||||
code: "1",
|
||||
counter: { placement: "server", contract: "v1" },
|
||||
summary: true,
|
||||
}),
|
||||
});
|
||||
try {
|
||||
await page.signIn(app, ["alpha"]);
|
||||
await page.waitText("alpha", ".label", "s1(0)");
|
||||
const settled = (await page.states("alpha")).length;
|
||||
const result = await editAndApply(
|
||||
app,
|
||||
"counter",
|
||||
counterPlugin({ code: "v2", placement: "worker", contract: "v2" }),
|
||||
);
|
||||
expect(rolloutFailures(result)).toEqual([]);
|
||||
await page.waitText("alpha", ".state", "live");
|
||||
expect(await page.result(await page.call("alpha", "code"))).toEqual(ok("s1"));
|
||||
expect((await page.states("alpha")).slice(settled)).toEqual([]);
|
||||
} finally {
|
||||
await app.app.close(context);
|
||||
}
|
||||
},
|
||||
120_000,
|
||||
);
|
||||
});
|
||||
131
packages/chord/test/node/reload/web.test.ts
Normal file
131
packages/chord/test/node/reload/web.test.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
// Reload suite, row "a web or terminal plugin" (design §21 "What a user sees during a reload"): the other plugins
|
||||
// untouched, scroll kept; the reloaded plugin's components remount, their state (`useState`) starts over while page
|
||||
// state (`usePageState`) survives; the reloaded plugin's calls as above. Through a real page with two web plugins and
|
||||
// the base bundle's own input.
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
|
||||
import { editAndApply, latches, ok, ReloadPage, rolloutFailures, startReloadApp } from "../fixtures/reload/app.ts";
|
||||
import { counterPlugin, LATCH_PLUGIN, webPlugin } from "../fixtures/reload/plugins.ts";
|
||||
import { Browser, CHROMIUM } from "../web/browser.ts";
|
||||
|
||||
/** The counter as the web plugins show it. */
|
||||
const COUNTER = { placement: "worker", contract: "v1" } as const;
|
||||
|
||||
describe.skipIf(CHROMIUM === undefined)("a reload of a web plugin", () => {
|
||||
let browser: Browser;
|
||||
let page: ReloadPage;
|
||||
|
||||
beforeAll(async () => {
|
||||
browser = await Browser.start();
|
||||
page = new ReloadPage(browser);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
});
|
||||
|
||||
it("remounts only the reloaded plugin's components, keeping page state, the other plugins and the scroll", async () => {
|
||||
const app = await startReloadApp({
|
||||
latch: LATCH_PLUGIN,
|
||||
counter: counterPlugin({ code: "v1", placement: "worker" }),
|
||||
alpha: webPlugin({ name: "alpha", code: "1", counter: COUNTER }),
|
||||
beta: webPlugin({ name: "beta", code: "1" }),
|
||||
});
|
||||
try {
|
||||
await page.signIn(app, ["alpha", "beta"]);
|
||||
await page.waitText("alpha", ".state", "live");
|
||||
expect(await page.result(await page.call("alpha", "add", 2))).toEqual(ok("v1:2"));
|
||||
await page.waitText("alpha", ".label", "v1:2");
|
||||
await page.type("alpha", ".draft", "alpha draft");
|
||||
await page.type("alpha", ".kept", "alpha kept");
|
||||
await page.type("beta", ".draft", "beta draft");
|
||||
await page.type("beta", ".kept", "beta kept");
|
||||
await browser.evaluate('document.getElementById("base-draft").focus()');
|
||||
await browser.type("base draft");
|
||||
// Marks on the DOM nodes that must survive, and a scroll position.
|
||||
await browser.evaluate(
|
||||
'document.querySelector("[data-view=beta] .draft").marked = true; document.querySelector("[data-view=alpha] .draft").marked = true; window.scrollTo(0, 1000)',
|
||||
);
|
||||
expect(await browser.evaluate<number>("window.scrollY")).toBe(1000);
|
||||
|
||||
const result = await editAndApply(app, "alpha", webPlugin({ name: "alpha", code: "2", counter: COUNTER }));
|
||||
expect(rolloutFailures(result)).toEqual([]);
|
||||
expect(result.steps.map(({ name }) => name)).toEqual(["server", "project", "web"]);
|
||||
|
||||
await page.waitText("alpha", ".code", "alpha:2");
|
||||
// The reloaded plugin: a new component (its state starts over), its page state kept, its counter shown again.
|
||||
expect(await page.value("alpha", ".draft")).toBe("");
|
||||
expect(await browser.evaluate('document.querySelector("[data-view=alpha] .draft").marked ?? false')).toBe(
|
||||
false,
|
||||
);
|
||||
expect(await page.value("alpha", ".kept")).toBe("alpha kept");
|
||||
await page.waitText("alpha", ".label", "v1:2");
|
||||
await page.waitText("alpha", ".state", "live");
|
||||
expect(await page.result(await page.call("alpha", "add", 1))).toEqual(ok("v1:3"));
|
||||
// Everything else untouched: the other plugin's component (the same DOM node), the base's input, the scroll.
|
||||
expect(await page.text("beta", ".code")).toBe("beta:1");
|
||||
expect(await page.value("beta", ".draft")).toBe("beta draft");
|
||||
expect(await browser.evaluate('document.querySelector("[data-view=beta] .draft").marked ?? false')).toBe(true);
|
||||
expect(await page.value("beta", ".kept")).toBe("beta kept");
|
||||
expect(await browser.evaluate<string>('document.getElementById("base-draft").value')).toBe("base draft");
|
||||
expect(await browser.evaluate<number>("window.scrollY")).toBe(1000);
|
||||
expect(await page.loads()).toBe(1);
|
||||
} finally {
|
||||
await app.app.close(context);
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
// Open design point (2026-11-04, with the owner): `useHold` releases the old component's reference when a plugin
|
||||
// reload remounts it, and the release aborts its calls in flight on the worker; yet "a component unmounted by a
|
||||
// reload has not left" (§21). Asserted as it is today.
|
||||
it("ends a call the reloaded plugin's old component made `released`, nothing committed (open: useHold's release on a reload's unmount aborts it)", async () => {
|
||||
const app = await startReloadApp({
|
||||
latch: LATCH_PLUGIN,
|
||||
counter: counterPlugin({ code: "v1", placement: "worker" }),
|
||||
alpha: webPlugin({ name: "alpha", code: "1", counter: COUNTER }),
|
||||
});
|
||||
const hand = await latches(app, "worker");
|
||||
try {
|
||||
await page.signIn(app, ["alpha"]);
|
||||
await page.waitText("alpha", ".state", "live");
|
||||
const running = await page.call("alpha", "slowAdd", "running");
|
||||
await hand.entered("running");
|
||||
await editAndApply(app, "alpha", webPlugin({ name: "alpha", code: "2", counter: COUNTER }));
|
||||
await page.waitText("alpha", ".code", "alpha:2");
|
||||
await hand.open("running");
|
||||
expect(await page.result(running)).toMatchObject({ status: "error", code: "released" });
|
||||
// Aborted on the worker: nothing committed.
|
||||
await page.waitText("alpha", ".state", "live");
|
||||
expect(await page.text("alpha", ".label")).toBe("v1:0");
|
||||
expect(await page.result(await page.call("alpha", "visits"))).toEqual(ok(0));
|
||||
} finally {
|
||||
await hand.open("running");
|
||||
await app.app.close(context);
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
// A case the table leaves out (the audit's "a plugin whose contributions change shape"): a web plugin's new code
|
||||
// contributes no view, then contributes it again; the other plugin's component stays mounted throughout.
|
||||
it("removes and restores a view whose plugin's contributions change, the other plugins untouched", async () => {
|
||||
const app = await startReloadApp({
|
||||
alpha: webPlugin({ name: "alpha", code: "1" }),
|
||||
beta: webPlugin({ name: "beta", code: "1" }),
|
||||
});
|
||||
try {
|
||||
await page.signIn(app, ["alpha", "beta"]);
|
||||
await page.type("beta", ".draft", "beta draft");
|
||||
await page.type("alpha", ".kept", "alpha kept");
|
||||
await editAndApply(app, "alpha", webPlugin({ name: "alpha", code: "2", hidden: true }));
|
||||
await browser.wait('document.querySelector("[data-view=alpha]") === null');
|
||||
expect(await page.value("beta", ".draft")).toBe("beta draft");
|
||||
await editAndApply(app, "alpha", webPlugin({ name: "alpha", code: "3" }));
|
||||
await page.waitText("alpha", ".code", "alpha:3");
|
||||
expect(await page.value("alpha", ".kept")).toBe("alpha kept");
|
||||
expect(await page.value("beta", ".draft")).toBe("beta draft");
|
||||
expect(await page.loads()).toBe(1);
|
||||
} finally {
|
||||
await app.app.close(context);
|
||||
}
|
||||
}, 120_000);
|
||||
});
|
||||
|
|
@ -1905,7 +1905,7 @@ describe('calls in flight across a `loading` unbind (decision "Calls in flight s
|
|||
await server.close(context);
|
||||
});
|
||||
|
||||
it("a failed unbind still rejects: a live failure answers its calls with the failure, later ones not_ready", async () => {
|
||||
it("a failed unbind still rejects: a live failure answers its calls with the failure, and calls crossing it too", async () => {
|
||||
const gate = Promise.withResolvers<void>();
|
||||
let self: Self<TestPrincipal> | undefined;
|
||||
const server = createHost<TestPrincipal>({ address: Server(), principal: { kind: "server" }, onError: () => {} });
|
||||
|
|
@ -1933,13 +1933,68 @@ describe('calls in flight across a `loading` unbind (decision "Calls in flight s
|
|||
const rejected = await peer.next("error");
|
||||
expect(rejected).toMatchObject({ call: 0, error: { error: { message: "it broke" } } });
|
||||
await peer.next("failed");
|
||||
// Told a failure, the hold takes no calls by the old names.
|
||||
// Told a failure, the hold takes no calls by the old names: one that crossed the `failed` gets that cause.
|
||||
call(peer, 1, "ping");
|
||||
expect(await peer.next("error")).toMatchObject({ call: 1, error: { error: { code: "not_ready" } } });
|
||||
expect(await peer.next("error")).toMatchObject({ call: 1, error: { error: { message: "it broke" } } });
|
||||
gate.resolve();
|
||||
await server.close(context);
|
||||
});
|
||||
|
||||
it("a call by the old variant's index crossing a fresh bound of another variant is decoded by the old names (audit F4)", async () => {
|
||||
const ran: string[] = [];
|
||||
const { server, peer, reload } = await keptServer({ n: 1, ran }, { ack: false });
|
||||
peer.ack();
|
||||
await reload({ n: 2, variant: "b", ran });
|
||||
const bound = await peer.next("bound");
|
||||
expect(bound.holds[0]!.variant).toBe(1);
|
||||
// Sent before the consumer applied that `bound`: by variant a's indexes (`bump` is 0, which is `ping` in b).
|
||||
call(peer, 0, "bump");
|
||||
call(peer, 1, "ping");
|
||||
await transported();
|
||||
expect(answersTo(peer, 0)).toMatchObject([
|
||||
{ kind: "error", error: { error: { code: "invalid", reason: "member" } } },
|
||||
]);
|
||||
expect(answersTo(peer, 1).map((answer) => answer.kind === "result" && partValue(answer.value!))).toEqual([2]);
|
||||
expect(ran).toEqual(["ping"]);
|
||||
// Applied and acknowledged: now by variant b's indexes.
|
||||
peer.ack();
|
||||
peer.send({ kind: "call", call: 2, hold: 0, method: wireNames(Kept, "b").methods.ping! });
|
||||
expect(partValue((await peer.next("result")).value!)).toBe(2);
|
||||
expect(ran).toEqual(["ping", "ping"]);
|
||||
expect(peer.closed).toBe(false);
|
||||
await server.close(context);
|
||||
});
|
||||
|
||||
it("a call crossing a removal's failed gets the removal's cause, never not_ready (audit F3)", async () => {
|
||||
const { server, peer } = await keptServer({ n: 1 });
|
||||
await server.reload([], context);
|
||||
const failed = await peer.next("failed");
|
||||
expect(failed.cause).toMatchObject({ kind: "waiting", error: { error: { code: "unavailable" } } });
|
||||
// Sent as a consumer sends it before the `failed` reaches it.
|
||||
call(peer, 0, "ping");
|
||||
const answer = await peer.next("error");
|
||||
expect(answer.error).toEqual(failed.cause.error);
|
||||
await server.close(context);
|
||||
});
|
||||
|
||||
it("a call crossing a version refusal's failed gets contract_mismatch, never not_ready (audit F3)", async () => {
|
||||
const { server, peer, reload } = await keptServer({ n: 1 });
|
||||
// The new code serves version 2 only: the version 1 hold is refused.
|
||||
const answer = async () => 2;
|
||||
const v2 = defineFacet<TestPrincipal>((h) =>
|
||||
h.serve(KeptV2, () => ({
|
||||
variant: "a",
|
||||
members: { label: constant({ text: "v2" }), bump: answer, ping: answer },
|
||||
})),
|
||||
);
|
||||
await reload(v2);
|
||||
const failed = await peer.next("failed");
|
||||
expect(failed.cause).toMatchObject({ kind: "waiting", error: { error: { code: "contract_mismatch" } } });
|
||||
call(peer, 0, "ping");
|
||||
expect(await peer.next("error")).toMatchObject({ call: 0, error: { error: { code: "contract_mismatch" } } });
|
||||
await server.close(context);
|
||||
});
|
||||
|
||||
describe("through a relay", () => {
|
||||
/** `Kept` placed on a session's worker. */
|
||||
const PlacedKept = remote("app.placed-kept", {
|
||||
|
|
|
|||
|
|
@ -48,8 +48,19 @@ function setOf(base: string, scripts: readonly RegisteredScript[]): ScriptSet {
|
|||
};
|
||||
}
|
||||
|
||||
/** A server serving `WebScripts` with `initial`, and a client started against it with `scripts` available to fetch. */
|
||||
async function setup(initial: readonly RegisteredScript[], available: readonly RegisteredScript[] = []) {
|
||||
/** A timer of the client's environment the test fires itself: its pause and its callback. */
|
||||
type ManualTimer = { readonly ms: number; readonly fire: () => void };
|
||||
|
||||
/**
|
||||
* A server serving `WebScripts` with `initial`, and a client started against it with `scripts` available to fetch;
|
||||
* `failing` fetches fail first (a network error), `manual` collects the environment's timers instead of running them.
|
||||
*/
|
||||
async function setup(
|
||||
initial: readonly RegisteredScript[],
|
||||
available: readonly RegisteredScript[] = [],
|
||||
more: { readonly failing?: number; readonly manual?: ManualTimer[] } = {},
|
||||
) {
|
||||
let failing = more.failing ?? 0;
|
||||
const server = createHost<TestPrincipal>({
|
||||
address: hostKind("server")(),
|
||||
principal: { kind: "server" },
|
||||
|
|
@ -81,6 +92,10 @@ async function setup(initial: readonly RegisteredScript[], available: readonly R
|
|||
},
|
||||
fetchScript: async (entry) => {
|
||||
fetched.push(entry.url);
|
||||
if (failing > 0) {
|
||||
failing--;
|
||||
throw new Error(`${entry.url}: network error`);
|
||||
}
|
||||
const found = available.find(({ plugin, digest }) => plugin === entry.name && digest === entry.digest);
|
||||
if (found === undefined) throw new Error(`no ${entry.url}`);
|
||||
return found;
|
||||
|
|
@ -90,6 +105,14 @@ async function setup(initial: readonly RegisteredScript[], available: readonly R
|
|||
environment: {
|
||||
now: () => Date.now(),
|
||||
after: (ms, callback) => {
|
||||
if (more.manual !== undefined) {
|
||||
const timer = { ms, fire: callback };
|
||||
more.manual.push(timer);
|
||||
return () => {
|
||||
const at = more.manual?.indexOf(timer) ?? -1;
|
||||
if (at >= 0) more.manual?.splice(at, 1);
|
||||
};
|
||||
}
|
||||
const timer = setTimeout(callback, ms);
|
||||
return () => clearTimeout(timer);
|
||||
},
|
||||
|
|
@ -157,6 +180,49 @@ describe("applying a new set in place", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("a failed update (audit F7)", () => {
|
||||
it("tries a set whose script did not load again after 1 s, then 2 s, until it applies", async () => {
|
||||
const a1 = script("@test/a", "d1", "hello");
|
||||
const a2 = script("@test/a", "d2", "hello again");
|
||||
const timers: ManualTimer[] = [];
|
||||
const { client, server, errors, fetched } = await setup([a1], [a2], { failing: 2, manual: timers });
|
||||
const greeting = await client.host.get(Greeting, {}, context);
|
||||
const retries = () => timers.filter(({ ms }) => ms === 1000 || ms === 2000);
|
||||
await publishTo(server, "web", setOf("B1", [a2]), context);
|
||||
await until(() => retries().length === 1);
|
||||
expect(errors.map(({ kind }) => kind)).toEqual(["update_failed"]);
|
||||
expect(retries()[0]!.ms).toBe(1000);
|
||||
retries()[0]!.fire();
|
||||
await until(() => retries().some(({ ms }) => ms === 2000));
|
||||
expect(errors.map(({ kind }) => kind)).toEqual(["update_failed", "update_failed"]);
|
||||
retries()
|
||||
.find(({ ms }) => ms === 2000)!
|
||||
.fire();
|
||||
await until(() => greeting.text.value === "hello again");
|
||||
expect(fetched).toEqual(["/@test/a.d2.js", "/@test/a.d2.js", "/@test/a.d2.js"]);
|
||||
await client.stop(context);
|
||||
await server.close(context);
|
||||
});
|
||||
|
||||
it("drops the retry of a set a newer one replaced, and applies the newer one", async () => {
|
||||
const a1 = script("@test/a", "d1", "hello");
|
||||
const a2 = script("@test/a", "d2", "hello again");
|
||||
const a3 = script("@test/a", "d3", "hello at last");
|
||||
const timers: ManualTimer[] = [];
|
||||
const { client, server, fetched } = await setup([a1], [a2, a3], { failing: 1, manual: timers });
|
||||
const greeting = await client.host.get(Greeting, {}, context);
|
||||
await publishTo(server, "web", setOf("B1", [a2]), context);
|
||||
await until(() => timers.some(({ ms }) => ms === 1000));
|
||||
await publishTo(server, "web", setOf("B1", [a3]), context);
|
||||
await until(() => greeting.text.value === "hello at last");
|
||||
// The retry was cancelled with the newer set: nothing is left to fire.
|
||||
expect(timers.some(({ ms }) => ms === 1000)).toBe(false);
|
||||
expect(fetched).toEqual(["/@test/a.d2.js", "/@test/a.d3.js"]);
|
||||
await client.stop(context);
|
||||
await server.close(context);
|
||||
});
|
||||
});
|
||||
|
||||
describe("outdated clients", () => {
|
||||
it("calls outdated for a set with another base", async () => {
|
||||
const { client, server, outdated } = await setup([]);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue