mirror of
https://github.com/block/buzz.git
synced 2026-10-29 05:21:31 +00:00
test(sidebar-sync): add hook-layer C1-stale/queuedAt/P2a-1 regressions and MINOR adopt-clear fix
IMPORTANT 1 — add C1-stale actual-hook test (failed-bootstrap stale replay must adopt, not publish). Causality: drop !pendingIsRestoredReplay guard → exception fires → publishes stale edit over newer head → 2 reds (sections + sort). IMPORTANT 2 — assert original queuedAt persists in transferred v2 outbox before debounce fires. Causality: replace restoredQueuedAt with undefined in writeOutbox call → stamps wall-clock value → 2 reds (sections + sort). IMPORTANT 3 — add runWholeBlobP2a1HookSuite driving blocked-bootstrap sequence through the real hook: bootstrap parked → click via hook API → H102 live → release bootstrap → real .then() reads/replays outbox naturally → debounce → H102 adopted as genuine advance. Causality: publishBaseline = lastRemoteHead in publish(_, true) → H102 folds into baseline → publishes over H102 → 2 reds (hook: sections + sort). MINOR — extract clearPendingState() helper shared by discardPending() and adoptRemote(), so adoptRemote's gen===pendingGeneration branch also clears pendingIsRestoredReplay and pendingRestoredQueuedAt on adopt. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
runWholeBlobC2Suite,
|
||||
runWholeBlobC3Suite,
|
||||
runWholeBlobP2a1Suite,
|
||||
runWholeBlobP2a1HookSuite,
|
||||
runWholeBlobP2bSuite,
|
||||
} from "./wholeBlobSyncCarl.shared.test.mjs";
|
||||
|
||||
@@ -244,6 +245,7 @@ runWholeBlobCarlSuite({
|
||||
);
|
||||
return `buzz-channel-sections-outbox.v1:${pubkey}:${encoded}`;
|
||||
},
|
||||
readOutbox: readChannelSectionsOutbox,
|
||||
useHook: useChannelSections,
|
||||
makeEditStore: () => ({
|
||||
version: 1,
|
||||
@@ -334,3 +336,26 @@ runWholeBlobP2bSuite({
|
||||
assignments: {},
|
||||
}),
|
||||
});
|
||||
|
||||
runWholeBlobP2a1HookSuite({
|
||||
label: "sections",
|
||||
writeOutboxKey: (pubkey, relayUrl) => {
|
||||
const encoded = encodeURIComponent(
|
||||
relayUrl.trim().replace(/\/$/, "").toLowerCase(),
|
||||
);
|
||||
return `buzz-channel-sections-outbox.v1:${pubkey}:${encoded}`;
|
||||
},
|
||||
storageKey,
|
||||
useHook: useChannelSections,
|
||||
makeEdit: (r) => r.createSection("P2a1-Section"),
|
||||
makeEditStore: () => ({
|
||||
version: 1,
|
||||
sections: [{ id: "click", name: "Click", order: 0 }],
|
||||
assignments: {},
|
||||
}),
|
||||
makeRemoteStore: () => ({
|
||||
version: 1,
|
||||
sections: [{ id: "remote", name: "Remote", order: 0 }],
|
||||
assignments: {},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
runWholeBlobC2Suite,
|
||||
runWholeBlobC3Suite,
|
||||
runWholeBlobP2a1Suite,
|
||||
runWholeBlobP2a1HookSuite,
|
||||
runWholeBlobP2bSuite,
|
||||
} from "./wholeBlobSyncCarl.shared.test.mjs";
|
||||
|
||||
@@ -159,6 +160,7 @@ runWholeBlobCarlSuite({
|
||||
);
|
||||
return `buzz-channel-sort-outbox.v1:${pubkey}:${encoded}`;
|
||||
},
|
||||
readOutbox: readChannelSortOutbox,
|
||||
useHook: useChannelSortPreference,
|
||||
makeEditStore: () => ({ version: 1, groups: { click: "alpha" } }),
|
||||
makeRemoteStore: () => ({ version: 1, groups: { remote: "recent" } }),
|
||||
@@ -201,3 +203,18 @@ runWholeBlobC3Suite({
|
||||
makeEditStore: () => ({ version: 1, groups: { click: "alpha" } }),
|
||||
makeRemoteStore: () => ({ version: 1, groups: { remote: "recent" } }),
|
||||
});
|
||||
|
||||
runWholeBlobP2a1HookSuite({
|
||||
label: "sort",
|
||||
writeOutboxKey: (pubkey, relayUrl) => {
|
||||
const encoded = encodeURIComponent(
|
||||
relayUrl.trim().replace(/\/$/, "").toLowerCase(),
|
||||
);
|
||||
return `buzz-channel-sort-outbox.v1:${pubkey}:${encoded}`;
|
||||
},
|
||||
storageKey,
|
||||
useHook: useChannelSortPreference,
|
||||
makeEdit: (r) => r.setSortModeFor("channels", "alpha"),
|
||||
makeEditStore: () => ({ version: 1, groups: { click: "alpha" } }),
|
||||
makeRemoteStore: () => ({ version: 1, groups: { remote: "recent" } }),
|
||||
});
|
||||
|
||||
@@ -57,19 +57,20 @@ const { stubRelay } = makeHookStubs();
|
||||
* @param {object} opts
|
||||
* @param {string} opts.label "sections"|"sort"
|
||||
* @param {string} opts.outboxKeyPrefix e.g. "buzz-channel-sections-outbox.v1"
|
||||
* @param {string} opts.storageKeyPrefix localStorage key for the store
|
||||
* @param {Function} opts.storageKey (pubkey, relayUrl) => string (store key)
|
||||
* @param {Function} opts.writeOutboxKey (pubkey, relayUrl) => v2 outbox key
|
||||
* (legacy shared key — pre-v2 nonce path)
|
||||
* @param {Function} opts.readOutbox (pubkey, relayUrl) => {store, queuedAt} | null
|
||||
* @param {Function} opts.useHook the hook under test
|
||||
* @param {Function} opts.makeEditStore () => store (the restored edit)
|
||||
* @param {Function} opts.makeRemoteStore () => store (peer head content)
|
||||
*/
|
||||
export function runWholeBlobCarlSuite({
|
||||
label,
|
||||
outboxKeyPrefix,
|
||||
outboxKeyPrefix: _outboxKeyPrefix,
|
||||
storageKey,
|
||||
writeOutboxKey,
|
||||
readOutbox,
|
||||
useHook,
|
||||
makeEditStore,
|
||||
makeRemoteStore,
|
||||
@@ -84,7 +85,8 @@ export function runWholeBlobCarlSuite({
|
||||
// → publishSections(store, true, 200) → publish(_, true, 200)
|
||||
// → publishBaseline = canonicalMax({0,""}, bootstrapResultHead={0,""}) = {0,""}
|
||||
// → pendingRestoredQueuedAt = 200.
|
||||
// 4. Debounce fires. fetchOwnBlobBeforePublish returns peerHead (createdAt=100).
|
||||
// 4. writeOwnOutbox is called with nowSecs=200 — original stamp preserved.
|
||||
// 5. Debounce fires. fetchOwnBlobBeforePublish returns peerHead (createdAt=100).
|
||||
// remoteAdvancedSince(100, {0,""}) = true.
|
||||
// Failed-bootstrap exception: !pendingIsRestoredReplay suppresses it.
|
||||
// Restored-replay adopt-guard: remote.createdAt(100) <= queuedAt(200)
|
||||
@@ -100,11 +102,14 @@ export function runWholeBlobCarlSuite({
|
||||
// M2 (adopt-guard absent, C1): remove pendingRestoredQueuedAt guard
|
||||
// → restored replay always adopts when remoteAdvancedSince=true
|
||||
// → publish=0, adopted=1 → test FAILS.
|
||||
test(`P1/C1 ${label}: failed-bootstrap hook replay — restored edit (queuedAt=200) must publish above older relay head (createdAt=100)`, async () => {
|
||||
//
|
||||
// M3 (queuedAt remint): pass undefined instead of restoredQueuedAt to
|
||||
// writeOwnOutbox → v2 outbox stamped at Date.now()/1000=300, not 200
|
||||
// → queuedAt assertion below FAILS.
|
||||
test(`P1/C1 ${label}: failed-bootstrap hook replay — restored edit (queuedAt=200) must publish above older relay head (createdAt=100) and preserve original queuedAt in transferred outbox`, async () => {
|
||||
const { act, cleanup, renderHook } = await import("@testing-library/react");
|
||||
const pubkey = `pk-c1-fail-${label}`;
|
||||
const relayUrl = `wss://r.c1fail.${label}`;
|
||||
const encodedRelay = encodeURIComponent(relayUrl);
|
||||
|
||||
const tauri = installEchoTauri(pubkey);
|
||||
const restoreRelay = stubRelay(relayClient);
|
||||
@@ -156,6 +161,21 @@ export function runWholeBlobCarlSuite({
|
||||
|
||||
// Bootstrap failed → hook .then() fired → shouldReplay=true (hold)
|
||||
// → publishSections(store, true, 200) called.
|
||||
// IMPORTANT 2: before the debounce fires, the v2 outbox must carry the
|
||||
// original queuedAt=200, NOT the reminted wall-clock value of 300.
|
||||
// Mutation (M3): pass undefined for nowSecs → stamps 300 → assertion fails.
|
||||
const outboxAfterTransfer = readOutbox(pubkey, relayUrl);
|
||||
assert.ok(
|
||||
outboxAfterTransfer !== null,
|
||||
`P1/C1 ${label}: v2 outbox must exist after hook replay call`,
|
||||
);
|
||||
assert.equal(
|
||||
outboxAfterTransfer.queuedAt,
|
||||
200,
|
||||
`P1/C1 ${label}: v2 outbox queuedAt must be the ORIGINAL 200, not the reminted wall-clock 300 — ` +
|
||||
`drop restoredQueuedAt override in writeOwnOutbox call → queuedAt=300`,
|
||||
);
|
||||
|
||||
// Debounce scheduled. Fire it.
|
||||
await fireDelay(2000);
|
||||
for (let i = 0; i < 100; i++) await Promise.resolve();
|
||||
@@ -189,6 +209,97 @@ export function runWholeBlobCarlSuite({
|
||||
}
|
||||
});
|
||||
|
||||
// ── P1/C1-stale: failed bootstrap → restored outbox (queuedAt=100) →
|
||||
// relay head at createdAt=200 → MUST ADOPT (head is strictly newer).
|
||||
//
|
||||
// Production sequence:
|
||||
// 1. Outbox seeded with queuedAt=100.
|
||||
// 2. Bootstrap fails → result.action="hold" → shouldReplay=true.
|
||||
// 3. hook .then(): publishSections(store, true, 100) → pendingRestoredQueuedAt=100.
|
||||
// 4. Debounce. fetchOwnBlobBeforePublish returns peerHead (createdAt=200).
|
||||
// !pendingIsRestoredReplay guard suppresses the failed-bootstrap exception.
|
||||
// Restored adopt-guard: remote.createdAt(200) > queuedAt(100) → ADOPT.
|
||||
// Outbox is cleared (head supersedes old edit). Zero publishes.
|
||||
//
|
||||
// Causality mutation: remove !pendingIsRestoredReplay guard
|
||||
// → failed-bootstrap exception fires → publishBaseline absorbs 200
|
||||
// → returns {kind:"publish"} → publishes old stale edit OVER newer head
|
||||
// → test FAILS (publishCalls.length > 0, no adopt, outbox not cleared).
|
||||
test(`P1/C1-stale ${label}: failed-bootstrap hook replay — stale restored edit (queuedAt=100) must ADOPT newer relay head (createdAt=200), not publish over it`, async () => {
|
||||
const { act, cleanup, renderHook } = await import("@testing-library/react");
|
||||
const pubkey = `pk-c1-stale-${label}`;
|
||||
const relayUrl = `wss://r.c1stale.${label}`;
|
||||
|
||||
const tauri = installEchoTauri(pubkey);
|
||||
const restoreRelay = stubRelay(relayClient);
|
||||
|
||||
// Seed legacy outbox: queuedAt=100 (stale — the relay head will be newer).
|
||||
const legacyKey = writeOutboxKey(pubkey, relayUrl);
|
||||
window.localStorage.setItem(
|
||||
legacyKey,
|
||||
JSON.stringify({ store: makeEditStore(), queuedAt: 100 }),
|
||||
);
|
||||
window.localStorage.setItem(
|
||||
storageKey(pubkey, relayUrl),
|
||||
JSON.stringify(makeEditStore()),
|
||||
);
|
||||
|
||||
// newerHead: createdAt=200 (strictly newer than queuedAt=100).
|
||||
const newerHead = tauri.mintHead(
|
||||
makeRemoteStore(),
|
||||
200,
|
||||
`evt-c1-stale-head-${label}`,
|
||||
);
|
||||
newerHead.pubkey = pubkey;
|
||||
newerHead.kind = 30078;
|
||||
|
||||
const publishCalls = [];
|
||||
let fetchCalls = 0;
|
||||
relayClient.fetchEvents = async () => {
|
||||
fetchCalls++;
|
||||
if (fetchCalls === 1) return Promise.reject(new Error("bootstrap fail"));
|
||||
return [newerHead];
|
||||
};
|
||||
relayClient.publishEvent = async (evt) => {
|
||||
publishCalls.push(evt);
|
||||
};
|
||||
|
||||
const { fireDelay, restore: restoreTimers } = makeHookTimerBed();
|
||||
const origDateNow = Date.now;
|
||||
Date.now = () => 50 * 1_000; // well below queuedAt=100 so no clock confusion
|
||||
|
||||
let hook = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
hook = renderHook(() => useHook(pubkey, relayUrl));
|
||||
for (let i = 0; i < 40; i++) await Promise.resolve();
|
||||
});
|
||||
|
||||
await fireDelay(2000);
|
||||
for (let i = 0; i < 100; i++) await Promise.resolve();
|
||||
|
||||
// With fix: stale edit (queuedAt=100) < head (createdAt=200) → ADOPT.
|
||||
// Mutation (!pendingIsRestoredReplay removed): exception fires → publishes stale edit over newer head.
|
||||
assert.equal(
|
||||
publishCalls.length,
|
||||
0,
|
||||
`P1/C1-stale ${label}: stale restored edit (queuedAt=100) must be ADOPTED AWAY by newer head ` +
|
||||
`(createdAt=200), not published over it — remove !pendingIsRestoredReplay guard → exception ` +
|
||||
`fires → publishes stale edit`,
|
||||
);
|
||||
|
||||
hook.unmount();
|
||||
} finally {
|
||||
cleanup();
|
||||
Date.now = origDateNow;
|
||||
tauri.restore();
|
||||
restoreRelay();
|
||||
restoreTimers();
|
||||
window.localStorage.clear();
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
// ── C2: successful bootstrap → hook replay (queuedAt >= bootstrapHead.createdAt)
|
||||
// → MUST PUBLISH above the bootstrap head, not adopt it away.
|
||||
//
|
||||
@@ -460,6 +571,158 @@ export function runWholeBlobP2a1Suite({
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook-layer P2a-1 regression (IMPORTANT 3).
|
||||
*
|
||||
* Drives the blocked-bootstrap sequence through the ACTUAL React hook so that
|
||||
* shouldReplay, outbox read, queuedAt threading, and the real .then() callback
|
||||
* are all exercised. The manager-layer test (runWholeBlobP2a1Suite) verifies
|
||||
* the baseline state-machine; this test verifies the hook seam that feeds it.
|
||||
*
|
||||
* Sequence:
|
||||
* 1. Block bootstrap fetch (park Promise).
|
||||
* 2. Mount hook — bootstrap is pending.
|
||||
* 3. Click through the hook API (makeEdit) — publishSections(store, false)
|
||||
* writes v2 outbox at Date.now()/1000.
|
||||
* 4. Deliver H102 via subscribeLive — suppressed by hasPendingEdit.
|
||||
* 5. Release bootstrap with H100 → hook .then() callback fires naturally:
|
||||
* readOutbox → shouldReplay=true → publishSections(outbox, true, queuedAt).
|
||||
* 6. Fire debounce → pre-publish fetch returns H102 →
|
||||
* canonicalMax(publishBaseline, bootstrapResultHead=H100) = H100;
|
||||
* H102 is a genuine advance → ADOPT. publishCalls === 0.
|
||||
*
|
||||
* Causality mutation: set publishBaseline = lastRemoteHead in publish(_, true)
|
||||
* → publishBaseline = H102 → pre-publish sees equality → publishes over H102
|
||||
* → test FAILS.
|
||||
*/
|
||||
export function runWholeBlobP2a1HookSuite({
|
||||
label,
|
||||
useHook,
|
||||
makeEdit,
|
||||
makeRemoteStore,
|
||||
}) {
|
||||
test(`P2a-1 ${label} (hook): blocked-bootstrap real hook replay — H102 must be adopted as genuine advance, not published over`, async () => {
|
||||
const { act, cleanup, renderHook } = await import("@testing-library/react");
|
||||
const pubkey = `pk-p2a1h-${label}`;
|
||||
const relayUrl = `wss://r.p2a1h.${label}`;
|
||||
|
||||
const tauri = installEchoTauri(pubkey);
|
||||
const restoreRelay = stubRelay(relayClient);
|
||||
|
||||
// Date.now frozen at t=50s so click writes queuedAt=50.
|
||||
// H100: createdAt=30 (bootstrap head, shouldReplay = 50>=30 = true).
|
||||
// H102: createdAt=200 (live head, a genuine advance over bootstrap).
|
||||
const origDateNow = Date.now;
|
||||
Date.now = () => 50 * 1_000;
|
||||
|
||||
const H100 = tauri.mintHead(
|
||||
makeRemoteStore(),
|
||||
30,
|
||||
`evt-h100-p2a1h-${label}`,
|
||||
);
|
||||
H100.pubkey = pubkey;
|
||||
H100.kind = 30078;
|
||||
H100.tags = [
|
||||
["d", label === "sections" ? "channel-sections" : "channel-sort"],
|
||||
];
|
||||
|
||||
const H102 = tauri.mintHead(
|
||||
makeRemoteStore(),
|
||||
200,
|
||||
`evt-h102-p2a1h-${label}`,
|
||||
);
|
||||
H102.pubkey = pubkey;
|
||||
H102.kind = 30078;
|
||||
|
||||
const publishCalls = [];
|
||||
let liveCallback = null;
|
||||
let releaseBootstrap = null;
|
||||
let fetchCalls = 0;
|
||||
|
||||
relayClient.subscribeLive = async (_f, cb) => {
|
||||
liveCallback = cb;
|
||||
return async () => {};
|
||||
};
|
||||
relayClient.fetchEvents = async () => {
|
||||
fetchCalls++;
|
||||
// Call 1 = bootstrap fetch: park until released.
|
||||
if (fetchCalls === 1) {
|
||||
return new Promise((res) => {
|
||||
releaseBootstrap = () => res([H100]);
|
||||
});
|
||||
}
|
||||
// Pre-publish fetch: H102 is now the relay head.
|
||||
return [H102];
|
||||
};
|
||||
relayClient.publishEvent = async (evt) => {
|
||||
publishCalls.push(evt);
|
||||
};
|
||||
|
||||
const { fireDelay, restore: restoreTimers } = makeHookTimerBed();
|
||||
|
||||
let hook = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
hook = renderHook(() => useHook(pubkey, relayUrl));
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
});
|
||||
|
||||
// Bootstrap is parked. Make a click through the real hook API.
|
||||
await act(async () => {
|
||||
makeEdit(hook.result.current);
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
});
|
||||
|
||||
// Deliver H102 live — hasPendingEdit suppresses it, publishBaseline stays {0,""}.
|
||||
while (liveCallback === null) await Promise.resolve();
|
||||
await act(async () => {
|
||||
liveCallback(H102);
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
});
|
||||
|
||||
// Release bootstrap with H100 → hook .then() fires:
|
||||
// result.action="apply-remote"; shouldReplay = (outbox.queuedAt=50 >= H100.createdAt=30) = true
|
||||
// → publishSections(outbox.store, true, 50) naturally, no manual call.
|
||||
// Also bootstrapResultHead = H100 = {createdAt:30, ...}.
|
||||
while (releaseBootstrap === null) await Promise.resolve();
|
||||
await act(async () => {
|
||||
releaseBootstrap();
|
||||
for (let i = 0; i < 60; i++) await Promise.resolve();
|
||||
});
|
||||
|
||||
// Fire debounce. publish(_, true, 50):
|
||||
// publishBaseline = canonicalMax({0,""}, bootstrapResultHead={30,H100}) = {30,H100}.
|
||||
// pre-publish fetch returns H102 (createdAt=200 > 30) → remoteAdvancedSince = true.
|
||||
// C1 adopt-guard: remote.createdAt(200) > queuedAt(50) → ADOPT H102.
|
||||
// publishCalls === 0.
|
||||
//
|
||||
// Mutation: set publishBaseline = lastRemoteHead in publish(_, true)
|
||||
// → lastRemoteHead = H102 (hasPendingEdit kept it, but recordRemoteHead ran at liveCallback)
|
||||
// → publishBaseline = H102 → pre-publish H102 == baseline → no advance → PUBLISH over H102.
|
||||
await fireDelay(2000);
|
||||
for (let i = 0; i < 100; i++) await Promise.resolve();
|
||||
|
||||
assert.equal(
|
||||
publishCalls.length,
|
||||
0,
|
||||
`P2a-1 ${label} (hook): real .then() replay with bootstrapResultHead baseline — ` +
|
||||
`H102 must be ADOPTED as a genuine advance, not published over — ` +
|
||||
`set publishBaseline=lastRemoteHead in publish(_, true) → H102 folds into baseline → publishes over H102`,
|
||||
);
|
||||
|
||||
hook.unmount();
|
||||
} finally {
|
||||
cleanup();
|
||||
Date.now = origDateNow;
|
||||
tauri.restore();
|
||||
restoreRelay();
|
||||
restoreTimers();
|
||||
window.localStorage.clear();
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Manager-layer C3 regression (confirmRetainedHead decrypt gap).
|
||||
*
|
||||
|
||||
@@ -454,22 +454,30 @@ export class WholeBlobSyncManager<S> {
|
||||
this.foldSupersedingAttemptWinner(remote);
|
||||
return;
|
||||
}
|
||||
this.pendingStore = null;
|
||||
this.config.clearOutbox(this.pubkey, this.relayUrl);
|
||||
this.clearPendingState();
|
||||
if (this.destroyed) return;
|
||||
this.onRemoteAdopted?.(remote);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all pending-edit state for the current generation. Shared by
|
||||
* `discardPending` (publish confirmed) and `adoptRemote` (remote wins) so
|
||||
* restored-replay metadata is never stranded after an adopt.
|
||||
*/
|
||||
private clearPendingState(): void {
|
||||
this.pendingStore = null;
|
||||
this.pendingIsRestoredReplay = false;
|
||||
this.pendingRestoredQueuedAt = undefined;
|
||||
this.config.clearOutbox(this.pubkey, this.relayUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the pending edit and durable outbox only when `gen` still owns the
|
||||
* current generation — a stale publish must not clear a newer edit's state.
|
||||
*/
|
||||
private discardPending(gen: number): void {
|
||||
if (gen !== this.pendingGeneration) return;
|
||||
this.pendingStore = null;
|
||||
this.pendingIsRestoredReplay = false;
|
||||
this.pendingRestoredQueuedAt = undefined;
|
||||
this.config.clearOutbox(this.pubkey, this.relayUrl);
|
||||
this.clearPendingState();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user