diff --git a/context/CONTEXT_2026-10-22_20-45-00_storage-cors-4.md b/context/CONTEXT_2026-10-22_20-45-00_storage-cors-4.md new file mode 100644 index 00000000..6101f46e --- /dev/null +++ b/context/CONTEXT_2026-10-22_20-45-00_storage-cors-4.md @@ -0,0 +1,5 @@ +- storage-cors-4: prevent obsolete registered feed results repainting deleted/replaced DOM. +- Baseline26e9a787; storage3 durable63e2b519 normalgateexit0, ancestry verified; close3 after durable lookup. +- Own coordinator only plus unique tests/notes. No app/view/engine/service/buffer edits. +- Plan: retain actual registered pipeline/JSDOM baseline; guard refresh/load ownership; cover async runtime/engine success+error, disposal/remount, telemetry and current controls; kill restored mutants; focused/lint; request serialized normal gate; verify durable landing. +- Definition of done: strict regressions+mutants, existing focused/lint, merged normal gate, durable status+ancestry. No live/browser qualification inferred. diff --git a/decisions/DECISIONS_2026-10-22_20-45-00_storage-cors-4.md b/decisions/DECISIONS_2026-10-22_20-45-00_storage-cors-4.md new file mode 100644 index 00000000..9ae26f90 --- /dev/null +++ b/decisions/DECISIONS_2026-10-22_20-45-00_storage-cors-4.md @@ -0,0 +1,6 @@ +- Per-app WeakMap request and loader tokens avoid exposing a public API. Capture actual view, mounted container and telemetry context; only eligible completion may publish metadata/map/render/telemetry/impressions/similar content. +- Capture mounted view.container (not mount adapter return); existing lightweight adapters need not expose a container. Real destroyed view loses its container and cannot be resurrected by completion. +- Preserve started engine caller result: stale success resolves original videos/metadata; stale engine error resolves existing fallback/error metadata. loadFeedVideos still resolves undefined. +- A request superseded while awaiting history preload never starts engine: resolve {videos: fallback, metadata: {reason, engine: "superseded"}}. This explicitly distinguishes a skipped run while preserving the resolved payload shape, and avoids new obsolete engine-stage subscriptions. No changes to current valid failure/unavailable fallback or content policies. +- Old service callbacks and recommendation activation must also match the original load/view/context, including a same-feed replacement. Loader continuations recheck after each await before state writes. +- No existing assertions changed. New failure injection wraps actual engine execution and rejects only after production stages, since moderation subscription rejection itself is deliberately caught by the production stage. diff --git a/docs/agents/backlog/storage-cors-3.json b/docs/agents/backlog/storage-cors-3.json index d70583e6..9f8a1beb 100644 --- a/docs/agents/backlog/storage-cors-3.json +++ b/docs/agents/backlog/storage-cors-3.json @@ -1,11 +1,12 @@ { "id": "storage-cors-3", "area": "storage-cors", - "status": "in-progress", + "status": "done", "title": "Audit feed/view tombstone stale-refresh regression gap with offline integration", "blockedOn": null, "addedOn": "2026-10-20", "filedAt": "7f99c0456931c917082d6a6680c79655221dcc82", "resumedOn": "2026-10-22", - "brief": "docs/agents/CAMPAIGN-2026-10-22.md" + "brief": "docs/agents/CAMPAIGN-2026-10-22.md", + "closedOn": "2026-10-22" } diff --git a/docs/agents/backlog/storage-cors-4.json b/docs/agents/backlog/storage-cors-4.json index 4efb3f56..afaede85 100644 --- a/docs/agents/backlog/storage-cors-4.json +++ b/docs/agents/backlog/storage-cors-4.json @@ -1,10 +1,10 @@ { - "id": "storage-cors-4", - "area": "storage-cors", - "status": "open", - "title": "Prevent obsolete registered feed completions restoring deleted or replaced DOM", - "blockedOn": "Implement separately only after existing storage3 candidate is frozen and submitted", - "addedOn": "2026-10-22", - "filedAt": "89f8923c6e83b79a2b8874f811cdff2af6a809d0", - "brief": "docs/agents/prompts/storage-cors.md" + "id": "storage-cors-4", + "area": "storage-cors", + "status": "in-progress", + "title": "Prevent obsolete registered feed completions restoring deleted or replaced DOM", + "blockedOn": null, + "addedOn": "2026-10-22", + "filedAt": "89f8923c6e83b79a2b8874f811cdff2af6a809d0", + "brief": "docs/agents/prompts/storage-cors.md" } diff --git a/docs/testing/storage-cors-4-integrity.md b/docs/testing/storage-cors-4-integrity.md new file mode 100644 index 00000000..00e48d5b --- /dev/null +++ b/docs/testing/storage-cors-4-integrity.md @@ -0,0 +1,38 @@ +# storage-cors-4 registered lifecycle integrity + +```yaml +test_integrity_note: + change_type: ["new_tests"] + scenarios: + - id: SCN-storage4-registered-completion + given: "Real registered Recent/Explore/For You pipelines and real JSDOM VideoListView/Card, with controlled moderation subscription IO" + when: "Older success or rejection resolves after a newer refresh, deletion, empty result, feed replacement, or disposal" + then: "Only eligible current request changes DOM, metadata, telemetry and presentation effects; stale caller still settles" + - id: SCN-storage4-current-controls + given: "A current registered pipeline or fallback and a live view" + when: "History or moderation IO is delayed, engine fails, or engine is unavailable" + then: "Live cards/fallback and metadata remain observable with natural completion" + - id: SCN-storage4-obsolete-load + given: "Retained onVideos callback or history preload belongs to replaced load" + when: "Same-feed replacement or different-feed load wins" + then: "Old callback starts no refresh; obsolete runtime starts no engine or presentation effects" + observable_outcomes: + - "Card DOM IDs, view container ownership, payloads, metadata identity, render/impression counts and telemetry calls" + determinism_controls: + - "Explicit deferred IO; synthetic offline events, fake subscription/storage adapters; no sleeps/retries/timeouts" + anti_cheat_rationale: + prevents: + - "hard-coded empty output via delayed/current/fallback controls" + - "decorator-only pipeline replacement via actual registered production stages" + - "success-only guards via delayed rejected engine with telemetry assertions" + - "per-feed counters ignoring view replacement via same-feed remount controls" + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` + +16 retained scenarios in tests/storage-feed-lifecycle.test.mjs. Auto-discovered by unchanged normal runner; no registration bypass. Uses existing actual client/buffer/service fixture, synthetic signatures and controlled adapters. Source, filters, moderation, timestamps, scoring/sorting and actual DOM rendering execute. First moderation setActiveEventIds containing original pauses; no custom decorator stage. Engine error tests wrap real run and reject after its production stages return. History preload exercises actual For You await. + +Nine isolated runtime mutants all exit1 naturally, cancelled0; restored in finally. Original coordinator fails12/16; ignoring request order fails4; ignoring view eligibility fails2; ignoring stale errors fails2; ignoring stale runtime fails1; suppressing current rendering fails9; obsolete load callbacks enabled fails1; allowing orphan completion fails11; dropping fallback fails3. Each retained scenario rejects original or a relevant broken control variant. No assertion relaxation or existing-test edit. + +Live network/signatures/moderation timing, full application router/auth teardown, native browser and manual release not verified. No new source/cache/deletion policy. Return choice and pre-engine supersession are documented in decisions/DECISIONS_2026-10-22_20-45-00_storage-cors-4.md. diff --git a/js/app/feedCoordinator.js b/js/app/feedCoordinator.js index 5281941d..9bdfaf8d 100644 --- a/js/app/feedCoordinator.js +++ b/js/app/feedCoordinator.js @@ -56,6 +56,14 @@ export function createFeedCoordinator(deps) { registerWatchHistoryFeed: registerWatchHistoryFeedFn, } = deps; + // Each app owns one presentation target. Requests may finish for their caller + // after losing that target, but must not publish into a newer feed/view. + const refreshRequests = new WeakMap(); + const feedLoads = new WeakMap(); + const ownsView = (app, view, container) => + app.videoListView === view && + (!container || view?.container === container); + return { registerRecentFeed() { if (!this.feedEngine || typeof this.feedEngine.registerFeed !== "function") { @@ -830,6 +838,17 @@ export function createFeedCoordinator(deps) { }, async refreshFeed(feedType, { reason, fallbackVideos } = {}) { + const request = {}; + refreshRequests.set(this, request); + const view = this.videoListView; + const container = view?.container; + const load = feedLoads.get(this); + const activeFeed = this.feedTelemetryState?.activeFeed; + const isCurrent = () => + refreshRequests.get(this) === request && + feedLoads.get(this) === load && + this.feedTelemetryState?.activeFeed === activeFeed && + ownsView(this, view, container); const normalizedReason = typeof reason === "string" ? reason : undefined; const fallback = Array.isArray(fallbackVideos) ? fallbackVideos : []; let runtime = {}; @@ -902,6 +921,11 @@ export function createFeedCoordinator(deps) { ); } + // A runtime preload can outlive its view before the engine even starts. + if (!isCurrent()) { + return { videos: fallback, metadata: { reason: normalizedReason, engine: "superseded" } }; + } + // 2. Check Engine if (!this.feedEngine || typeof this.feedEngine.run !== "function") { const metadata = metadataModifier({ @@ -930,6 +954,9 @@ export function createFeedCoordinator(deps) { if (normalizedReason) metadata.reason = normalizedReason; metadata = metadataModifier(metadata); + const payload = { videos, metadata }; + if (!isCurrent()) return payload; + this.latestFeedMetadata = metadata; this.videosMap = this.nostrService.getVideosMap(); if (this.videoListView) { @@ -941,7 +968,6 @@ export function createFeedCoordinator(deps) { this.updateFeedTelemetryMetadata(feedType, items, metadata); } - const payload = { videos, metadata }; this.renderVideoList(payload); return payload; }) @@ -955,6 +981,8 @@ export function createFeedCoordinator(deps) { error: true, }; metadata = metadataModifier(metadata); + const payload = { videos: fallback, metadata }; + if (!isCurrent()) return payload; this.latestFeedMetadata = metadata; this.videosMap = this.nostrService.getVideosMap(); @@ -966,7 +994,6 @@ export function createFeedCoordinator(deps) { this.updateFeedTelemetryMetadata(feedType, [], metadata); } - const payload = { videos: fallback, metadata }; this.renderVideoList(payload); return payload; }); @@ -1012,6 +1039,8 @@ export function createFeedCoordinator(deps) { */ async loadFeedVideos(feedType, forceFetch = false) { devLogger.log(`Starting loadFeedVideos(${feedType})... (forceFetch =`, forceFetch, ")"); + const load = {}; + feedLoads.set(this, load); this.setFeedTelemetryContext(feedType); const recommendationFeed = @@ -1102,6 +1131,13 @@ export function createFeedCoordinator(deps) { } } + const view = this.videoListView; + const viewContainer = view?.container; + const activeFeed = this.feedTelemetryState?.activeFeed; + const isCurrentLoad = () => + feedLoads.get(this) === load && + ownsView(this, view, viewContainer) && + this.feedTelemetryState?.activeFeed === activeFeed; let initialRefreshPromise = null; const videos = await this.nostrService.loadVideos({ @@ -1109,6 +1145,7 @@ export function createFeedCoordinator(deps) { blacklistedEventIds: this.blacklistedEventIds, isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey), onVideos: (payload, detail = {}) => { + if (!isCurrentLoad()) return; const promise = refreshMethod({ reason: detail?.reason, fallbackVideos: payload, @@ -1119,12 +1156,14 @@ export function createFeedCoordinator(deps) { }, }); + if (!isCurrentLoad()) return; if (initialRefreshPromise) { await initialRefreshPromise; } else if (!Array.isArray(videos) || videos.length === 0) { await refreshMethod({ reason: "initial", fallbackVideos: [] }); } + if (!isCurrentLoad()) return; this.videoSubscription = this.nostrService.getVideoSubscription() || null; this.videosMap = this.nostrService.getVideosMap(); if (this.videoListView) { @@ -1136,7 +1175,7 @@ export function createFeedCoordinator(deps) { // deliberately cache-first and must not wait for worker startup. if (exploreActivation) { void exploreActivation.then(() => { - if (!this.isFeedActive(feedType)) { + if (!isCurrentLoad()) { return; } return this.refreshFeed(feedType, { reason: "recommendation-data-ready" }); diff --git a/test_logs/TEST_LOG_2026-10-22_20-45-00_storage-cors-4.md b/test_logs/TEST_LOG_2026-10-22_20-45-00_storage-cors-4.md new file mode 100644 index 00000000..171bb573 --- /dev/null +++ b/test_logs/TEST_LOG_2026-10-22_20-45-00_storage-cors-4.md @@ -0,0 +1,9 @@ +- Startup20:39UTC; clean984c219c, mailbox unread0. Existing storage3 submission1792679488327-984c219cd2 durably63e2b519 with normalgateexit0; git merge-base --is-ancestor63e2b519 unstable exit0. Closed3 after durable proof; claimed4 after sync26e9a787. +- Fleet health20:40:39UTC: memory available64.4%, diskfree26.3%, CPU70C, swap52% (tight). No new heavy job. Client9 exclusive heavy slot from20:41:26Z; dispatch permits storage4 focused-only. +- Initial retained four-scenario baseline: `env -u NODE_TEST_CONTEXT node tests/storage-feed-lifecycle.test.mjs` exit1,2pass/2fail/cancelled0/199.723627ms at26e9a787 coordinator. Sameview deleted-card resurrection and remounted Explore overwrite retained. +- New final regression16pass/0fail/0cancelled naturalexit0/302.555724ms before strengthened absent-view effect assertions; final normal focused run below includes those strict assertions. +- Nine restored mutants: original pass4/fail12,ignore-order12/4,ignore-view14/2,stale-errors14/2,stale-runtime15/1,no-current-render7/9,obsolete-load15/1,allow-orphan5/11,drop-fallback13/3. Every variant exit1,cancelled0. Script /tmp/storage4-mutants.py, summary /tmp/storage4-mutants.log, individual /tmp/storage4-mutant-*.log; source restored in finally. +- `env -u NODE_TEST_CONTEXT node scripts/run-unit-tests.mjs tests/storage-feed-lifecycle.test.mjs tests/app/feedCoordinator.test.mjs tests/app/feedCoordinatorWatchHistoryRefresh.test.mjs tests/feed-engine.test.mjs tests/nostr-service-live-feed-notification.test.mjs` finalexit0,5files, new16scenarios; unchanged runner/discovery. /tmp/storage4-focused-final.log. +- `env -u NODE_TEST_CONTEXT npm run lint` finalexit0,11stages,read-only git-grep sandbox exception approved; /tmp/storage4-lint-final.log. git diff --check exit0. +- Fixture/setup development: `node --test` suppresses nested output under existing fixture behavior, use normal direct runner; initial direct baseline strict2fail retained. Two new error controls initially failed because actual moderation stage intentionally catches adapter errors; corrected controlled error injection to wrap real engine run and reject after actual production stages. Did not change expected contract or production stage. First broader run failed existing loadForYouVideos test because its mount adapter has no view.container; guard now captures actual view.container rather than return value, preserving adapter contract. Existing assertions untouched. Initial lint failed EPERM git grep; approved outside-sandbox read-only execution passes. +- No full/build/browser/submission for4 yet. Native browser/full router/auth teardown, live relay/signatures/cloud/manual release unverified. No extra full repetition of storage3. Storage3 closure and4 ownership metadata accompany separate storage4 candidate. diff --git a/tests/storage-feed-lifecycle.test.mjs b/tests/storage-feed-lifecycle.test.mjs new file mode 100644 index 00000000..19fa50ca --- /dev/null +++ b/tests/storage-feed-lifecycle.test.mjs @@ -0,0 +1,421 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { JSDOM } from "jsdom"; +import { + tombstoneFixture, + videoEvent, + ids +} from "./test-helpers/storage-tombstone-fixture.mjs"; +import * as feeds from "../js/feedEngine/index.js"; +import { createFeedCoordinator } from "../js/app/feedCoordinator.js"; +import { createPlaybackCoordinator } from "../js/app/playbackCoordinator.js"; +import { VideoListView } from "../js/ui/views/VideoListView.js"; +import { dedupeVideos } from "../js/utils/videoDeduper.js"; +import * as timestamps from "../js/utils/videoTimestamps.js"; +import { FEED_TYPES } from "../js/constants.js"; +const logger = { log() {}, warn() {}, error() {} }; +async function fixture(t, { failDelayed = false } = {}) { + const f = tombstoneFixture(t); + await f.load(); + await f.ingest( + videoEvent("original", 100), + videoEvent("unrelated", 110, { root: "other" }) + ); + const dom = new JSDOM( + '
', + { url: "http://fixture.invalid" } + ); + const root = dom.window.document.getElementById("videoList"); + const previousHTMLElement = globalThis.HTMLElement; + globalThis.HTMLElement = dom.window.HTMLElement; + t.after(() => { + if (previousHTMLElement === undefined) delete globalThis.HTMLElement; + else globalThis.HTMLElement = previousHTMLElement; + }); + const history = { loadLatest: async () => [] }; + const methods = createFeedCoordinator({ + ...feeds, + watchHistoryService: history, + devLogger: logger, + userLogger: logger, + nostrClient: f.client + }); + const playback = createPlaybackCoordinator({ + ...timestamps, + devLogger: logger, + userLogger: logger, + nostrClient: f.client + }); + let release, entered; + const reached = new Promise((r) => (entered = r)); + const gate = new Promise((r) => (release = r)); + let hold = true; + // Defer actual moderation-stage report-subscription IO, not a custom stage. + f.service.moderationService = { + async setActiveEventIds(activeIds) { + if (hold && activeIds.has("original")) { + hold = false; + entered(); + await gate; + } + } + }; + f.client.hydrateVideoHistory = async (video) => [ + { created_at: video.created_at, deleted: false } + ]; + const effects = []; + const app = { + ...methods, + nostrService: f.service, + feedEngine: feeds.createFeedEngine(), + videosMap: f.service.getVideosMap(), + blacklistedEventIds: new Set(), + buildRecentFeedRuntime: () => ({}), + buildExploreFeedRuntime: () => ({}), + buildForYouFeedRuntime: () => ({}), + isAuthorBlocked: () => false, + dedupeVideosByRoot: dedupeVideos, + getKnownVideoPostedAt: playback.getKnownVideoPostedAt, + resolveVideoPostedAt: playback.resolveVideoPostedAt, + cacheVideoRootCreatedAt: playback.cacheVideoRootCreatedAt, + decorateVideoModeration: (v) => v, + decorateVideoCreatorIdentity: (v) => v, + emitFeedImpressions(videos) { + effects.push(ids(videos)); + }, + updateModalSimilarContent() {}, + checkRelayHealthWarning() {}, + mountVideoListView() { + if (!this.videoListView) return null; + return this.videoListView.mount(root); + } + }; + const makeView = () => + new VideoListView({ + document: dom.window.document, + utils: { + dedupeVideos, + getKnownVideoPostedAt: (video) => app.getKnownVideoPostedAt(video) + } + }); + if (failDelayed) { + const run = app.feedEngine.run.bind(app.feedEngine); + let first = true; + app.feedEngine.run = async (...args) => { + const fail = first; + first = false; + const result = await run(...args); + if (fail) + throw new Error("controlled engine rejection after production stages"); + return result; + }; + } + app.videoListView = makeView(); + // View-count/zap subscription transport is disabled at its own UI adapter boundary. + app.videoListView.syncViewCountSubscriptions = () => {}; + assert.ok(app.registerRecentFeed()); + assert.ok(app.registerExploreFeed()); + const renders = []; + const originalRender = app.renderVideoList; + app.renderVideoList = function (payload) { + renders.push({ + active: this.feedTelemetryState?.activeFeed, + reason: payload.metadata?.reason, + ids: ids(payload.videos), + deleted: payload.videos.filter((v) => v.deleted).map((v) => v.id) + }); + return originalRender.call(this, payload); + }; + const runs = []; + const refresh = app.refreshFeed; + app.refreshFeed = function (...args) { + const p = refresh.apply(this, args); + runs.push(p); + return p; + }; + t.after(() => { + release(); + app.videoListView?.destroy(); + dom.window.close(); + }); + return { + ...f, + app, + root, + reached, + release, + runs, + renders, + makeView, + history, + effects, + domIds: () => + Array.from( + root.querySelectorAll('[data-component="video-card"]'), + (node) => node.dataset.videoId + ).sort() + }; +} +test("control: valid delayed registered recent pipeline renders live cards", async (t) => { + const f = await fixture(t); + const loading = f.app.loadFeedVideos("recent"); + await f.reached; + f.release(); + await loading; + assert.deepEqual(f.domIds(), ["original", "unrelated"]); + assert.equal(f.app.isFeedActive("recent"), true); +}); +test("registered recent pipeline must not restore deleted DOM card after a fresher subscription render", async (t) => { + const f = await fixture(t); + const loading = f.app.loadFeedVideos("recent"); + await f.reached; + await f.ingest(videoEvent("delete", 200, { deleted: true })); + await f.runs.at(-1); + assert.deepEqual(f.domIds(), ["unrelated"]); + f.release(); + await loading; + assert.deepEqual(f.domIds(), ["unrelated"]); +}); +test("registered recent result must not overwrite replacement Explore view after destroy/remount", async (t) => { + const f = await fixture(t); + const loading = f.app.loadFeedVideos("recent"); + await f.reached; + await f.ingest(videoEvent("delete", 200, { deleted: true })); + await f.runs.at(-1); + f.app.videoListView.destroy(); + f.app.videoListView = f.makeView(); + f.app.videoListView.syncViewCountSubscriptions = () => {}; + await f.app.loadFeedVideos(FEED_TYPES.EXPLORE); + assert.deepEqual(f.domIds(), ["unrelated"]); + assert.equal(f.app.isFeedActive(FEED_TYPES.EXPLORE), true); + f.release(); + await loading; + assert.deepEqual(f.domIds(), ["unrelated"]); + assert.equal( + f.app.videoListView.state.feedMetadata.sortOrder, + FEED_TYPES.EXPLORE + ); +}); +test("control: fully disposed view is not remounted when delayed pipeline completes", async (t) => { + const f = await fixture(t); + const loading = f.app.loadFeedVideos("recent"); + await f.reached; + const view = f.app.videoListView; + view.destroy(); + f.app.videoListView = null; + f.release(); + await loading; + assert.equal(view.container, null); + assert.equal(f.app.videoListView, null); + assert.equal(f.renders.length, 0); + assert.equal(f.effects.length, 0); + assert.deepEqual(f.domIds(), []); +}); + +test("late old failure returns its fallback without painting over the newer success", async (t) => { + const f = await fixture(t, { failDelayed: true }); + f.app.mountVideoListView(); + const old = f.app.refreshFeed("recent", { + fallbackVideos: [f.app.videosMap.get("original")] + }); + await f.reached; + await f.ingest(videoEvent("delete", 200, { deleted: true })); + await f.app.refreshFeed("recent", { reason: "newest" }); + const metadata = f.app.latestFeedMetadata; + const count = f.effects.length; + f.release(); + const result = await old; + assert.equal(result.metadata.error, true); + assert.deepEqual(ids(result.videos), ["original"]); + assert.deepEqual(f.domIds(), ["unrelated"]); + assert.equal(f.app.latestFeedMetadata, metadata); + assert.equal(f.effects.length, count); +}); +test("reverse completion order never paints the older result even while newest is pending", async (t) => { + const f = await fixture(t); + f.app.mountVideoListView(); + const old = f.app.refreshFeed("recent"); + await f.reached; + let releaseNew, entered; + const reached = new Promise((r) => (entered = r)); + const gate = new Promise((r) => (releaseNew = r)); + f.service.moderationService.setActiveEventIds = async () => { + entered(); + await gate; + }; + t.after(() => releaseNew()); + const newest = f.app.refreshFeed("recent", { reason: "newest" }); + await reached; + f.release(); + const oldResult = await old; + assert.deepEqual(ids(oldResult.videos), ["original", "unrelated"]); + assert.equal(f.renders.length, 0); + releaseNew(); + await newest; + assert.equal(f.renders.length, 1); + assert.equal(f.app.latestFeedMetadata.reason, "newest"); +}); +test("same feed remount preserves replacement metadata and suppresses old side effects", async (t) => { + const f = await fixture(t); + const old = f.app.loadFeedVideos("recent"); + await f.reached; + f.app.videoListView.destroy(); + f.app.videoListView = f.makeView(); + f.app.videoListView.syncViewCountSubscriptions = () => {}; + await f.app.loadFeedVideos("recent"); + const metadata = f.app.latestFeedMetadata; + const count = f.effects.length; + f.release(); + await old; + assert.equal(f.app.latestFeedMetadata, metadata); + assert.equal(f.effects.length, count); + assert.deepEqual(f.domIds(), ["original", "unrelated"]); +}); +test("destroyed view still referenced by app cannot be remounted by completion", async (t) => { + const f = await fixture(t); + const old = f.app.loadFeedVideos("recent"); + await f.reached; + const view = f.app.videoListView; + view.destroy(); + f.release(); + await old; + assert.equal(view.container, null); + assert.equal(f.renders.length, 0); + assert.equal(f.effects.length, 0); +}); +test("new empty result remains empty after older populated result", async (t) => { + const f = await fixture(t); + const old = f.app.loadFeedVideos("recent"); + await f.reached; + await f.ingest( + videoEvent("delete", 200, { deleted: true }), + videoEvent("delete-other", 210, { deleted: true, root: "other" }) + ); + await f.runs.at(-1); + assert.deepEqual(f.domIds(), []); + const count = f.effects.length; + f.release(); + await old; + assert.deepEqual(f.domIds(), []); + assert.equal(f.effects.length, count); +}); +test("current engine failure paints supplied live fallback and error metadata", async (t) => { + const f = await fixture(t, { failDelayed: true }); + f.app.mountVideoListView(); + const current = f.app.refreshFeed("recent", { + fallbackVideos: [f.app.videosMap.get("unrelated")] + }); + await f.reached; + f.release(); + const payload = await current; + assert.equal(payload.metadata.error, true); + assert.deepEqual(f.domIds(), ["unrelated"]); + assert.equal(f.effects.length, 1); +}); +test("current unavailable engine paints fallback", async (t) => { + const f = await fixture(t); + f.app.mountVideoListView(); + f.app.feedEngine = null; + const result = await f.app.refreshFeed("recent", { + fallbackVideos: [f.app.videosMap.get("unrelated")] + }); + assert.equal(result.metadata.engine, "unavailable"); + assert.deepEqual(f.domIds(), ["unrelated"]); + assert.equal(f.effects.length, 1); +}); +test("obsolete awaited runtime cannot start engine or alter current telemetry", async (t) => { + const f = await fixture(t); + f.app.mountVideoListView(); + f.app.setFeedTelemetryContext(FEED_TYPES.FOR_YOU); + let release, entered; + const reached = new Promise((r) => (entered = r)); + const gate = new Promise((r) => (release = r)); + f.history.loadLatest = async () => { + entered(); + await gate; + return []; + }; + t.after(() => release()); + const old = f.app.refreshFeed(FEED_TYPES.FOR_YOU, { fallbackVideos: [] }); + await reached; + // A different feed becomes current before history IO completes. + f.release(); + await f.app.loadFeedVideos(FEED_TYPES.EXPLORE); + const metadata = f.app.latestFeedMetadata; + const count = f.effects.length; + release(); + const result = await old; + assert.equal(result.metadata.engine, "superseded"); + assert.equal(f.app.latestFeedMetadata, metadata); + assert.equal(f.effects.length, count); + assert.equal(f.app.isFeedActive(FEED_TYPES.EXPLORE), true); +}); + +test("current awaited history runtime completes registered For You rendering and telemetry", async (t) => { + const f = await fixture(t); + assert.ok(f.app.registerForYouFeed()); + f.app.mountVideoListView(); + f.app.setFeedTelemetryContext(FEED_TYPES.FOR_YOU); + let entered, release; + const reached = new Promise((r) => (entered = r)); + const gate = new Promise((r) => (release = r)); + f.history.loadLatest = async () => { + entered(); + await gate; + return []; + }; + t.after(() => release()); + const current = f.app.refreshFeed(FEED_TYPES.FOR_YOU); + await reached; + release(); + await f.reached; + f.release(); + const result = await current; + assert.deepEqual(ids(result.videos), ["original", "unrelated"]); + assert.deepEqual(f.domIds(), ["original", "unrelated"]); + assert.equal(result.metadata.preserveOrder, true); +}); +for (const failDelayed of [false, true]) + test(`stale For You ${failDelayed ? "failure" : "success"} cannot update telemetry or impressions`, async (t) => { + const f = await fixture(t, { failDelayed }); + assert.ok(f.app.registerForYouFeed()); + f.app.mountVideoListView(); + f.app.setFeedTelemetryContext(FEED_TYPES.FOR_YOU); + const updates = []; + f.app.updateFeedTelemetryMetadata = (...args) => updates.push(args); + const old = f.app.refreshFeed(FEED_TYPES.FOR_YOU); + await f.reached; + f.app.videoListView.destroy(); + f.app.videoListView = f.makeView(); + f.app.videoListView.syncViewCountSubscriptions = () => {}; + await f.app.loadFeedVideos(FEED_TYPES.EXPLORE); + const count = f.effects.length; + const metadata = f.app.latestFeedMetadata; + f.release(); + await old; + assert.equal(updates.length, 0); + assert.equal(f.effects.length, count); + assert.equal(f.app.latestFeedMetadata, metadata); + }); +test("retained obsolete load callback cannot start refresh after a same-feed remount", async (t) => { + const f = await fixture(t); + const load = f.service.loadVideos.bind(f.service); + const callbacks = []; + f.service.loadVideos = (opts) => { + callbacks.push(opts.onVideos); + return load(opts); + }; + const old = f.app.loadFeedVideos("recent"); + await f.reached; + f.app.videoListView.destroy(); + f.app.videoListView = f.makeView(); + f.app.videoListView.syncViewCountSubscriptions = () => {}; + await f.app.loadFeedVideos("recent"); + const count = f.runs.length; + const effects = f.effects.length; + callbacks[0]([], { reason: "obsolete" }); + assert.equal(f.runs.length, count); + assert.equal(f.effects.length, effects); + f.release(); + await old; +}); diff --git a/todo/TODO_2026-10-22_20-45-00_storage-cors-4.md b/todo/TODO_2026-10-22_20-45-00_storage-cors-4.md new file mode 100644 index 00000000..b39ec70e --- /dev/null +++ b/todo/TODO_2026-10-22_20-45-00_storage-cors-4.md @@ -0,0 +1,5 @@ +- [x] Recover routing/mailbox/health; ff clean branch26e9a787; confirm storage3 durable normal landing63e2b519; close3, claim4. +- [x] Registered baseline2pass/2fail; smallest coordinator-only guard;16 scenarios pass. +- [x] Nine restored failing mutants; five-file focused and lint checks (final results in test log). +- [ ] Serialized heavy/full/normal slot, commit/merged-tree gate and durable landing. +- [ ] Close4 only after durable status and unstable ancestry; report no native/live/manual qualification.