Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions demo/e2e/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import devicePairingMock from "./scenarios/device-pairing-mock.ts";
import soloPairing from "./scenarios/solo-pairing.ts";
import soloPersistence from "./scenarios/solo-persistence.ts";
import soloErase from "./scenarios/solo-erase.ts";
import soloResumeSync from "./scenarios/solo-resume-sync.ts";
import soloEphemeral from "./scenarios/solo-ephemeral.ts";
import soloStorage from "./scenarios/solo-storage.ts";
import soloGdrive from "./scenarios/solo-gdrive.ts";
Expand Down Expand Up @@ -132,6 +133,16 @@ const SCENARIOS: Scenario[] = [
// fault is in the erasure rather than in anything the device does
// between ceremonies.
soloErase,
// AND THE STATE BETWEEN CEREMONIES, which is where the account lives
// most of the time: both devices of a paired account close and reopen,
// and sync has to come back WITHOUT a ceremony. It runs after
// solo-pairing (which proves the ceremony) and after solo-persistence
// (which proves a device survives a reload with the same transport
// address) because it depends on both: a failure here with those two
// green says the fault is in the resume wiring — the device directory,
// the role read out of it, the acceptor and the dial — rather than in
// anything either of them covers.
soloResumeSync,
soloEphemeral,
// THE WORKER HOST'S STORAGE EGRESS (STORAGE-EGRESS.md's T-E): the same
// sheet the two device-store scenarios above just proved a device
Expand Down
84 changes: 81 additions & 3 deletions demo/e2e/scenarios/solo-pairing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,38 @@ import { act, assert, assertEquals, SOLO_KEYS } from "../util.ts";
// The solo pages' shared driving surface — the `__solo` root, the
// sandboxed todomvc frame, and the pacing rule for typing into it. Three
// scenarios drive this page now; see e2e/solo-util.ts.
import { addTodo, appFrame, solo, stripPersonal, todoRows, until, WAITS } from "../solo-util.ts";
import {
addTodo,
appFrame,
nameApp,
solo,
stripPersonal,
todoRows,
until,
WAITS,
} from "../solo-util.ts";
// The strip's own two lines and the pet icon on them — page-generic DOM
// reads, so they work on the solo page exactly as they do on the demo's.
import { stripMarkIcon, stripText } from "../util.ts";

/** The account's own face: the name A commits before pairing and the
* anchor colour it picks. Both must be on B's strip after B joins — and
* the hue is deliberately not the 265 both pages are seeded with, so an
* unchanged strip cannot pass the assertion by accident. */
const ACCOUNT_NAME = "Ada";
const ACCOUNT_HUE = 175;
/** And A's own GLYPH. From the visor's own vocabulary (visor.ts's
* VISOR_ICONS) and deliberately NOT ⛨ — the default an unset record
* renders — so a B whose icon never synced cannot pass by wearing the
* fallback. It is also outside `APP_MARK_ICONS`, so nothing can confuse
* the user's glyph with the app's mark below. */
const ACCOUNT_ICON = "\u263E"; // ☾ U+263E LAST QUARTER MOON

/** What A calls the app, through the naming ceremony. The mark that
* goes with it is whatever the ceremony offers (a fresh random draw —
* see `nameApp`), so it is captured at ceremony time rather than named
* here. */
const APP_PETNAME = "the tasks";

const scenario: Scenario = {
name: "solo-pairing",
Expand Down Expand Up @@ -120,7 +144,7 @@ const scenario: Scenario = {
15_000,
);
await pageA.evaluate(
([who, hue]) => {
([who, hue, glyph]) => {
const input = document.getElementById("visor-settings-name") as HTMLInputElement | null;
if (input) input.value = who as string;
// A hue that is certainly NOT the seeded 265 both pages boot
Expand All @@ -129,11 +153,18 @@ const scenario: Scenario = {
(document.querySelector(
`.settings-hues button[data-hue="${hue}"]`,
) as HTMLButtonElement | null)?.click();
// AND THE GLYPH, off the sheet's own picker row — the same
// gesture as the hue, one control along. It rides the same
// commit, so the write-through carries name, colour and icon
// in one `us-profile-set`.
(document.querySelector(
`.settings-icons button[data-glyph="${glyph}"]`,
) as HTMLButtonElement | null)?.click();
(document.querySelector(".settings-sheet .cred-row button:first-child") as
| HTMLButtonElement
| null)?.click();
},
[ACCOUNT_NAME, ACCOUNT_HUE] as [string, number],
[ACCOUNT_NAME, ACCOUNT_HUE, ACCOUNT_ICON] as [string, number, string],
);
const personal = await until(
[pageA],
Expand All @@ -148,6 +179,7 @@ const scenario: Scenario = {
personal.anchorColour.includes(String(ACCOUNT_HUE)),
`A's anchor took the picked colour: ${JSON.stringify(personal)}`,
);
assertEquals(personal.icon, ACCOUNT_ICON, "A's own glyph on A's bar");
});

await act("two todos typed into A's real app frame reach A's engine", async () => {
Expand All @@ -166,6 +198,27 @@ const scenario: Scenario = {
assert(titles.includes("call the bank"), `A's todos: ${JSON.stringify(titles)}`);
});

// WHAT A CALLS THE APP, captured at ceremony time: the sheet's offers
// are a fresh random draw, so the glyph is the ceremony's answer
// rather than this file's guess (see `nameApp`).
let appMark = "";

await act("A names the app through the visor's own naming ceremony", async () => {
// THE HONEST PATH AGAIN, and the other direction the account
// syncs: the trust table's mark, not the identity record's. A taps
// the strip's context cluster, types a name, picks a glyph and
// Saves — solo.ts's `onNamed` then writes it through to the
// account (`us-mark-put`). What B does with it is the claim below.
appMark = await nameApp(pageA, APP_PETNAME);
assertEquals(
await stripMarkIcon(pageA),
appMark,
"A's strip wears the mark A just picked",
);
const top = (await stripText(pageA)).top;
assert(top.includes(APP_PETNAME), `A's strip names the app: ${JSON.stringify(top)}`);
});

// --- PAGE B: a genuinely separate device ---------------------------
//
// `ctx.fresh()` a SECOND time. The harness supports it (each call
Expand Down Expand Up @@ -312,6 +365,31 @@ const scenario: Scenario = {
personal.anchorColour.includes(String(ACCOUNT_HUE)),
`B's anchor took the account's colour: ${JSON.stringify(personal)}`,
);
// AND A'S GLYPH. It crossed as UTF-8 bytes in the account's
// profile (engine.wit's `us-profile.icon`) and was vetted against
// the visor's vocabulary on the way in; ⛨ here would mean B is
// wearing the default, i.e. that nothing arrived.
assertEquals(personal.icon, ACCOUNT_ICON, "the account's glyph on B's bar");
});

await act("B's app wears A's petname and A's mark, from the account's table", async () => {
// THE MARKS HALF OF THE SAME SYNC, and the beat that used to be
// missing: B's engine had the mark all along (the act at the end
// of this scenario proves the document converged), but nothing
// seeded B's own trust table from it, so B mounted the app UNNAMED
// — a paired device greeting the user's own app as a stranger.
// Read off the strip, because that is where the user would see it.
const top = await until(
[pageA, pageB],
"A's petname for the app on B's strip",
async () => {
const t = (await stripText(pageB)).top;
return t.includes(APP_PETNAME) ? t : false;
},
WAITS.converge,
);
assert(top.includes(APP_PETNAME), `B's strip: ${JSON.stringify(top)}`);
assertEquals(await stripMarkIcon(pageB), appMark, "A's mark on B's strip");
});

await act("a todo added on B appears on A", async () => {
Expand Down
32 changes: 32 additions & 0 deletions demo/e2e/scenarios/solo-persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,14 @@ const scenario: Scenario = {
assert(trace.includes("promoted:until-reseal"), `trace: ${JSON.stringify(trace)}`);
});

// THE TRANSPORT ADDRESS, READ BEFORE THE RELOAD. Bind happens at
// boot and off the critical path, so it is polled rather than
// assumed present (solo.ts's `myEndpoint`).
const endpointBefore = await until([page], "the bound endpoint", async () => {
const id = (await solo(page, "endpointId")) as string;
return id !== "" ? id : false;
}, WAITS.converge);

// A CHECKPOINT ON PURPOSE, so the reload below is a claim about
// persistence rather than a race with the worker's 500 ms debounce.
// (The debounce would almost always win; "almost always" is how a
Expand Down Expand Up @@ -207,6 +215,30 @@ const scenario: Scenario = {
assert(titles.includes("call the bank"), `resumed todos: ${JSON.stringify(titles)}`);
});

await act("the iroh endpoint id is the SAME device address as before", async () => {
// THE SIXTH CLAIM, and the one the account's other devices depend
// on. In iroh the key IS the address, so before the endpoint key
// was persisted every bind minted a fresh id and a peer that had
// recorded this device's address could never reach it again —
// which made "both devices reloaded, then re-found each other"
// impossible by construction, not by bug.
//
// What makes this a real assertion rather than a tautology: the
// reload above tore the page AND the SharedWorker down, so the
// endpoint here was bound by a new worker global from a key it
// loaded out of the device namespace, through the engine's
// `device-identity.endpoint-key-pair` import.
const endpointAfter = await until([page], "the rebound endpoint", async () => {
const id = (await solo(page, "endpointId")) as string;
return id !== "" ? id : false;
}, WAITS.converge);
assertEquals(
endpointAfter,
endpointBefore,
"the device's transport address must survive a real reload",
);
});

await act("the reseal control asks what should unseal this device", async () => {
await solo(page, "openDevice");
const sheet = await until([page], "the kept-device sheet", async () => {
Expand Down
Loading
Loading