From 4e7a3834f6583fcb313b9dc67acb6993f3547590 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 11:24:58 +0000 Subject: [PATCH 1/2] feat(reader): list dated crawl versions in the reader bookmark The reader's crawl bookmark now lists dated crawl versions of an article across all three reader surfaces (public /view, owner reader, admin recrawl) for every visitor, anonymous included. Each content-changing crawl is snapshotted into a per-minute S3 folder and appended to a capped crawlVersions log on the article row; the reader renders the newest 10 newest-first, the first as the current version (badge) and the rest greyed and aria-disabled. Write side records a version inline at the three promotion sites (select, recrawl, refresh) only when the canonical content actually changed: CopyObject of the winning tier source to content-versions/{enc}/{minute}/content.html, then a compare-and-swap append of the minute id. Same-minute re-records are idempotent overwrites. Also fixes a pre-existing stale-canonical bug: refresh now rewrites the canonical object when the readable text changed even if the winner tier did not flip, so version snapshots stay consistent with what readers see. Read side threads a new findArticleCrawlVersions provider through the reader, falling back to the single contentFetchedAt instant for pre-feature articles. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B1UAavvi4Casx8YNAibjp6 --- .../e2e/crawl-bookmark-visual.e2e-local.ts | 41 +++-- .../crawl-bookmark-dark-chromium-linux.png | Bin 2636 -> 4137 bytes .../crawl-bookmark-light-chromium-linux.png | Bin 2937 -> 4724 bytes projects/hutch/src/e2e/e2e-server.main.ts | 4 +- projects/hutch/src/runtime/app.ts | 2 + .../dynamodb-article-store.test.ts | 26 +++ .../article-store/dynamodb-article-store.ts | 17 ++ projects/hutch/src/runtime/server.ts | 5 + projects/hutch/src/runtime/test-app.ts | 1 + .../web/pages/admin/recrawl.component.ts | 4 +- .../runtime/web/pages/admin/recrawl.page.ts | 6 +- .../src/runtime/web/pages/queue/queue.page.ts | 5 +- .../pages/queue/queue.reader.route.test.ts | 22 +++ .../web/pages/reader/reader.component.ts | 4 +- .../runtime/web/pages/view/view.component.ts | 4 +- .../src/runtime/web/pages/view/view.page.ts | 5 +- .../runtime/web/pages/view/view.route.test.ts | 24 +++ .../article-body.component.test.ts | 24 +++ .../article-body/article-body.component.ts | 4 +- .../crawl-bookmark.client.test.ts | 13 +- .../crawl-bookmark.component.test.ts | 72 ++++++-- .../crawl-bookmark.component.ts | 40 +++-- .../crawl-bookmark/crawl-bookmark.styles.css | 33 +++- .../crawl-bookmark.template.html | 2 +- .../article-reader/article-reader.test.ts | 42 ++++- .../shared/article-reader/article-reader.ts | 30 +++- .../article-reader/article-reader.types.ts | 13 +- .../dep-bundles/select-content.test.ts | 4 +- .../src/runtime/dep-bundles/select-content.ts | 22 +++ .../select-content/crawl-versions.test.ts | 41 +++++ .../domain/select-content/crawl-versions.ts | 11 ++ .../recrawl-content-extracted-handler.test.ts | 69 +++++++- .../recrawl-content-extracted-handler.ts | 19 ++- .../refresh-content-extracted-handler.test.ts | 49 ++++++ .../refresh-content-extracted-handler.ts | 16 +- ...lect-most-complete-content-handler.test.ts | 65 ++++++++ .../select-most-complete-content-handler.ts | 18 +- .../select-content/summary-recovery.test.ts | 2 + .../find-canonical-content-hash.ts | 39 +++++ .../record-crawl-version.test.ts | 156 ++++++++++++++++++ .../article-store/record-crawl-version.ts | 81 +++++++++ .../select-most-complete-content.main.ts | 16 ++ .../src/index.test.ts | 46 +++++- .../article-resource-unique-id/src/index.ts | 7 + .../provider-contracts/src/article-store.ts | 10 ++ src/packages/test-fixtures/src/fixture.ts | 2 + .../in-memory-article-store.test.ts | 27 +++ .../article-store/in-memory-article-store.ts | 19 +++ .../web-test-harness/src/bundle.types.ts | 3 + 49 files changed, 1075 insertions(+), 90 deletions(-) create mode 100644 projects/save-link/src/runtime/domain/select-content/crawl-versions.test.ts create mode 100644 projects/save-link/src/runtime/domain/select-content/crawl-versions.ts create mode 100644 projects/save-link/src/runtime/providers/article-store/find-canonical-content-hash.ts create mode 100644 projects/save-link/src/runtime/providers/article-store/record-crawl-version.test.ts create mode 100644 projects/save-link/src/runtime/providers/article-store/record-crawl-version.ts diff --git a/projects/hutch/src/e2e/crawl-bookmark-visual.e2e-local.ts b/projects/hutch/src/e2e/crawl-bookmark-visual.e2e-local.ts index 242bf9dcd..ec220cf7a 100644 --- a/projects/hutch/src/e2e/crawl-bookmark-visual.e2e-local.ts +++ b/projects/hutch/src/e2e/crawl-bookmark-visual.e2e-local.ts @@ -6,17 +6,23 @@ const E2E_PORT = process.env.E2E_PORT; assert(E2E_PORT, "E2E_PORT must be set by the Playwright webServer config"); const BASE_URL = `http://127.0.0.1:${E2E_PORT}`; -// A fixed instant so the "Last crawled at" label never drifts with wall-clock -// time. toAbsoluteShortDateTime formats it in UTC (see local-time.format.ts), -// and the pinned timezoneId below makes the client-side re-localisation resolve -// to the same UTC value — so the rendered label is byte-stable across runs. -const CONTENT_FETCHED_AT = "2026-03-26T14:32:00.000Z"; +// Fixed instants so the version labels never drift with wall-clock time. +// toAbsoluteShortDateTime formats them in UTC (see local-time.format.ts), and +// the pinned timezoneId below makes the client-side re-localisation resolve to +// the same UTC value — so the rendered labels are byte-stable across runs. +const CONTENT_FETCHED_AT = "2026-07-10T09:14:00.000Z"; +const CRAWL_VERSIONS = ["2026-07-10T09:14Z", "2026-06-28T22:01Z", "2026-03-26T14:32Z"]; const ARTICLE_URL = "https://example.com/crawl-bookmark-visual"; const CANONICAL_PATH = "example.com/crawl-bookmark-visual"; async function seedCrawledArticle(page: Page): Promise { const response = await page.request.post(`${BASE_URL}/e2e/seed-crawled-article`, { - data: { url: ARTICLE_URL, title: "Crawl Bookmark Visual", contentFetchedAt: CONTENT_FETCHED_AT }, + data: { + url: ARTICLE_URL, + title: "Crawl Bookmark Visual", + contentFetchedAt: CONTENT_FETCHED_AT, + crawlVersions: CRAWL_VERSIONS, + }, }); assert.equal(response.status(), 201, "seed endpoint must create the crawled article"); } @@ -25,9 +31,10 @@ async function openReaderWithBookmark(page: Page): Promise { await page.goto(`${BASE_URL}/view/${CANONICAL_PATH}`, { waitUntil: "domcontentloaded" }); await page.waitForSelector(".crawl-bookmark__tabs"); await waitForBrandFonts(page, ["Inter"]); - // The label the seed pinned; waiting on it settles the client re-localisation - // before the pixel capture so the text can't change mid-screenshot. - await expect(page.locator(".crawl-bookmark__time")).toHaveText("26 Mar '26, 14:32"); + // The newest version's label; waiting on it settles the client re-localisation + // before the pixel capture so the text can't change mid-screenshot. `.first()` + // disambiguates the several version rows for strict-mode locators. + await expect(page.locator(".crawl-bookmark__time").first()).toHaveText("10 Jul '26, 09:14"); } test.describe("Crawl bookmark visual regression", () => { @@ -63,12 +70,13 @@ test.describe("Crawl bookmark visual regression", () => { ); }); - // The handle collapses to just its grip when the disclosure closes (mobile / - // narrow viewports), so its height must not depend on the open state — a - // regression here reintroduces the short-sliver-vs-tall-capsule mismatch that - // PR #936 caused by dropping the handle's fixed height. Compared numerically - // (not via a screenshot) so it needs no per-platform PNG baseline. - test("the handle is the same height whether the capsule is open or collapsed", async ({ page }) => { + // A multi-version capsule grows when open to seat its dated tabs, and the + // handle stretches with it (verified by the alignment test above). When the + // disclosure closes (mobile / narrow viewports) the handle must still collapse + // to a stable, clickable grip at the capsule's 54px min-height — never the thin + // sliver PR #936 caused by dropping the handle's fixed height. Compared + // numerically (not via a screenshot) so it needs no per-platform PNG baseline. + test("the collapsed handle keeps its fixed grip height while the open capsule grows to fit versions", async ({ page }) => { await seedCrawledArticle(page); await openReaderWithBookmark(page); const handle = page.locator(".crawl-bookmark__handle"); @@ -76,6 +84,7 @@ test.describe("Crawl bookmark visual regression", () => { await page.locator(".crawl-bookmark").evaluate((el) => el.removeAttribute("open")); const collapsedHeight = (await handle.boundingBox())?.height; assert.ok(openHeight && collapsedHeight, "handle must have a measurable height in both states"); - assert.equal(collapsedHeight, openHeight, "collapsed handle must match the open capsule height"); + assert.ok(openHeight > collapsedHeight, "the open capsule grows taller than the collapsed grip"); + assert.equal(collapsedHeight, 54, "collapsed handle stays at the 54px min-height grip, not a sliver"); }); }); diff --git a/projects/hutch/src/e2e/crawl-bookmark-visual.e2e-local.ts-snapshots/crawl-bookmark-dark-chromium-linux.png b/projects/hutch/src/e2e/crawl-bookmark-visual.e2e-local.ts-snapshots/crawl-bookmark-dark-chromium-linux.png index 375fc1e5994f21d2bbd6c9634c763c3c5d6c997b..5adcc3bf1b3e55fc5d57dbb4211d853d84170b09 100644 GIT binary patch literal 4137 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 literal 2636 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 { const parsed = SeedCrawledArticleBody.safeParse(req.body) @@ -197,7 +198,7 @@ server.post('/e2e/seed-crawled-article', async (req, res) => { res.status(400).json({ error: parsed.error.flatten() }) return } - const { url, title, contentFetchedAt } = parsed.data + const { url, title, contentFetchedAt, crawlVersions } = parsed.data const hostname = new URL(url).hostname await fixture.articleStore.saveArticleGlobally({ url, @@ -211,6 +212,7 @@ server.post('/e2e/seed-crawled-article', async (req, res) => { }) await fixture.articleCrawl.markCrawlReady({ url }) await fixture.articleStore.setContentFetchedAt({ url, at: contentFetchedAt }) + await fixture.articleStore.setCrawlVersions({ url, versions: crawlVersions }) res.status(201).json({ ok: true }) }) diff --git a/projects/hutch/src/runtime/app.ts b/projects/hutch/src/runtime/app.ts index 41ba27f7c..24b8e1940 100644 --- a/projects/hutch/src/runtime/app.ts +++ b/projects/hutch/src/runtime/app.ts @@ -388,6 +388,7 @@ function initProviders() { markSummaryPending: summaryStore.markSummaryPending, findArticleCrawlStatus: crawlStore.findArticleCrawlStatus, findArticleFreshness: articleStore.findArticleFreshness, + findArticleCrawlVersions: articleStore.findArticleCrawlVersions, markCrawlPending: crawlStore.markCrawlPending, forceMarkCrawlPending: crawlStore.forceMarkCrawlPending, refreshArticleIfStale, @@ -653,6 +654,7 @@ function initProviders() { markSummaryPending: summaryStore.markSummaryPending, findArticleCrawlStatus: crawlStore.findArticleCrawlStatus, findArticleFreshness: articleStore.findArticleFreshness, + findArticleCrawlVersions: articleStore.findArticleCrawlVersions, markCrawlPending: crawlStore.markCrawlPending, forceMarkCrawlPending: crawlStore.forceMarkCrawlPending, refreshArticleIfStale, diff --git a/projects/hutch/src/runtime/providers/article-store/dynamodb-article-store.test.ts b/projects/hutch/src/runtime/providers/article-store/dynamodb-article-store.test.ts index 7807d44dd..38fb1118e 100644 --- a/projects/hutch/src/runtime/providers/article-store/dynamodb-article-store.test.ts +++ b/projects/hutch/src/runtime/providers/article-store/dynamodb-article-store.test.ts @@ -719,6 +719,32 @@ describe("initDynamoDbArticleStore freshness, notification state, content and ur expect(freshness).toBeNull(); }); + it("findArticleCrawlVersions maps the stored minute-id log to crawl versions, newest first", async () => { + const { client } = createFakeClient({ + GetCommand: { + default: { + Item: { crawlVersions: ["2026-07-10T09:41Z", "2026-06-28T22:01Z", "2026-03-26T14:32Z"] }, + }, + }, + }); + + const versions = await initStore(client).findArticleCrawlVersions(URL); + + expect(versions).toEqual([ + { crawledAtMinute: "2026-07-10T09:41Z" }, + { crawledAtMinute: "2026-06-28T22:01Z" }, + { crawledAtMinute: "2026-03-26T14:32Z" }, + ]); + }); + + it("findArticleCrawlVersions returns an empty list for a pre-feature row with no log", async () => { + const { client } = createFakeClient({ GetCommand: { default: { Item: undefined } } }); + + const versions = await initStore(client).findArticleCrawlVersions(URL); + + expect(versions).toEqual([]); + }); + it("findUserArticleNotificationState hydrates every timestamp the gate reads", async () => { const { client } = createFakeClient({ GetCommand: { diff --git a/projects/hutch/src/runtime/providers/article-store/dynamodb-article-store.ts b/projects/hutch/src/runtime/providers/article-store/dynamodb-article-store.ts index 27351c9c3..753648f38 100644 --- a/projects/hutch/src/runtime/providers/article-store/dynamodb-article-store.ts +++ b/projects/hutch/src/runtime/providers/article-store/dynamodb-article-store.ts @@ -22,6 +22,7 @@ import type { DeleteArticle, FindArticleById, FindArticleByUrl, + FindArticleCrawlVersions, FindArticleFreshness, FindArticleUrlById, FindArticlesByUser, @@ -47,6 +48,10 @@ const ArticleFreshnessRow = z.object({ contentFetchedAt: dynamoField(z.string()), }); +const ArticleCrawlVersionsRow = z.object({ + crawlVersions: dynamoField(z.array(z.string())), +}); + /** `routeId` column holds the `ReaderArticleHashId.value` (32-char hex). The Zod schema rehydrates it into a `ReaderArticleHashId` instance on read. * * `savedAt` is the public-row freshness anchor: when the row is first created @@ -134,6 +139,7 @@ export function initDynamoDbArticleStore(deps: { deleteAllUserArticles: DeleteAllUserArticles; updateArticleStatus: UpdateArticleStatus; findArticleFreshness: FindArticleFreshness; + findArticleCrawlVersions: FindArticleCrawlVersions; markArticleViewed: MarkArticleViewed; markSummaryToggled: MarkSummaryToggled; markReaderViewSucceeded: MarkReaderViewSucceeded; @@ -148,6 +154,7 @@ export function initDynamoDbArticleStore(deps: { const unverifiedArticles = defineDynamoTable({ client, tableName, schema: UnverifiedArticleRow }); const articleContent = defineDynamoTable({ client, tableName, schema: ArticleContentRow }); const articleFreshness = defineDynamoTable({ client, tableName, schema: ArticleFreshnessRow }); + const articleCrawlVersions = defineDynamoTable({ client, tableName, schema: ArticleCrawlVersionsRow }); const userArticles = defineDynamoTable({ client, tableName: userArticlesTableName, @@ -487,6 +494,15 @@ export function initDynamoDbArticleStore(deps: { } } + const findArticleCrawlVersions: FindArticleCrawlVersions = async (url) => { + const articleResourceUniqueId = ArticleResourceUniqueId.parse(url); + const row = await articleCrawlVersions.get( + { url: articleResourceUniqueId.value }, + { projection: ["crawlVersions"] }, + ); + return (row?.crawlVersions ?? []).map((crawledAtMinute) => ({ crawledAtMinute })); + }; + const markArticleViewed: MarkArticleViewed = async ({ userId, url, at }) => { await stampUserArticleIfStillSaved({ userId, url, at, updateExpression: "SET viewedAt = :at" }); }; @@ -625,6 +641,7 @@ export function initDynamoDbArticleStore(deps: { deleteAllUserArticles, updateArticleStatus, findArticleFreshness, + findArticleCrawlVersions, markArticleViewed, markSummaryToggled, markReaderViewSucceeded, diff --git a/projects/hutch/src/runtime/server.ts b/projects/hutch/src/runtime/server.ts index be82666fc..f96f93925 100644 --- a/projects/hutch/src/runtime/server.ts +++ b/projects/hutch/src/runtime/server.ts @@ -74,6 +74,7 @@ import type { DeleteArticle, FindArticleById, FindArticleByUrl, + FindArticleCrawlVersions, FindArticleFreshness, FindArticleUrlById, FindArticlesByUser, @@ -237,6 +238,7 @@ interface AppDependencies { findArticleById: FindArticleById; findArticleByUrl: FindArticleByUrl; findArticleFreshness: FindArticleFreshness; + findArticleCrawlVersions: FindArticleCrawlVersions; findArticleUrlById: FindArticleUrlById; findArticlesByUser: FindArticlesByUser; countArticlesByUser: CountArticlesByUser; @@ -975,6 +977,7 @@ export function createApp(dependencies: AppDependencies): Express { findArticleById: deps.findArticleById, findArticleByUrl: deps.findArticleByUrl, findArticleFreshness: deps.findArticleFreshness, + findArticleCrawlVersions: deps.findArticleCrawlVersions, findArticleUrlById: deps.findArticleUrlById, saveArticle: deps.saveArticle, deleteArticle: deps.deleteArticle, @@ -1054,6 +1057,7 @@ export function createApp(dependencies: AppDependencies): Express { appOrigin, findArticleByUrl: deps.findArticleByUrl, findArticleFreshness: deps.findArticleFreshness, + findArticleCrawlVersions: deps.findArticleCrawlVersions, readArticleContent: deps.readArticleContent, findGeneratedSummary: deps.findGeneratedSummary, markSummaryPending: deps.markSummaryPending, @@ -1077,6 +1081,7 @@ export function createApp(dependencies: AppDependencies): Express { appOrigin, findArticleByUrl: deps.findArticleByUrl, findArticleFreshness: deps.findArticleFreshness, + findArticleCrawlVersions: deps.findArticleCrawlVersions, readArticleContent: deps.readArticleContent, findGeneratedSummary: deps.findGeneratedSummary, findArticleCrawlStatus: deps.findArticleCrawlStatus, diff --git a/projects/hutch/src/runtime/test-app.ts b/projects/hutch/src/runtime/test-app.ts index 584aac57d..5ff615802 100644 --- a/projects/hutch/src/runtime/test-app.ts +++ b/projects/hutch/src/runtime/test-app.ts @@ -123,6 +123,7 @@ function flattenFixtureToAppDependencies( findArticleById: fixture.articleStore.findArticleById, findArticleByUrl: fixture.articleStore.findArticleByUrl, findArticleFreshness: fixture.articleStore.findArticleFreshness, + findArticleCrawlVersions: fixture.articleStore.findArticleCrawlVersions, findArticleUrlById: fixture.articleStore.findArticleUrlById, findArticlesByUser: fixture.articleStore.findArticlesByUser, countArticlesByUser: fixture.articleStore.countArticlesByUser, diff --git a/projects/hutch/src/runtime/web/pages/admin/recrawl.component.ts b/projects/hutch/src/runtime/web/pages/admin/recrawl.component.ts index d64d10aa6..84a8dd11d 100644 --- a/projects/hutch/src/runtime/web/pages/admin/recrawl.component.ts +++ b/projects/hutch/src/runtime/web/pages/admin/recrawl.component.ts @@ -65,7 +65,7 @@ export interface AdminRecrawlPageInput { extensionInstallUrl?: string; appOrigin: string; recrawlFormAction?: string; - lastCrawledAt?: LocalTime; + crawlVersions?: LocalTime[]; } /** @@ -99,7 +99,7 @@ export function AdminRecrawlPage(input: AdminRecrawlPageInput): PageBody { appOrigin: input.appOrigin, topActionsHtml: actions.top.to("text/html").body, bottomActionsHtml: actions.bottom.to("text/html").body, - lastCrawledAt: input.lastCrawledAt, + crawlVersions: input.crawlVersions, }); const tierBadge = renderTierBadge(input.contentSourceTier); diff --git a/projects/hutch/src/runtime/web/pages/admin/recrawl.page.ts b/projects/hutch/src/runtime/web/pages/admin/recrawl.page.ts index f3d7e3f78..b2c0123ad 100644 --- a/projects/hutch/src/runtime/web/pages/admin/recrawl.page.ts +++ b/projects/hutch/src/runtime/web/pages/admin/recrawl.page.ts @@ -6,7 +6,7 @@ import type { ForceMarkCrawlPending, MarkCrawlPending, } from "@packages/provider-contracts/article-crawl"; -import type { FindArticleByUrl, FindArticleFreshness } from "@packages/provider-contracts/article-store"; +import type { FindArticleByUrl, FindArticleCrawlVersions, FindArticleFreshness } from "@packages/provider-contracts/article-store"; import type { ReadArticleContent } from "@packages/provider-contracts/article-store"; import type { FindGeneratedSummary, @@ -29,6 +29,7 @@ export interface AdminRecrawlDependencies { appOrigin: string; findArticleByUrl: FindArticleByUrl; findArticleFreshness: FindArticleFreshness; + findArticleCrawlVersions: FindArticleCrawlVersions; readArticleContent: ReadArticleContent; findGeneratedSummary: FindGeneratedSummary; findArticleCrawlStatus: FindArticleCrawlStatus; @@ -170,7 +171,7 @@ function handleShowRecrawlPage( contentSourceTier: existing.contentSourceTier, extensionInstallUrl: extensionInstallUrlIfMissing(req), recrawlFormAction, - lastCrawledAt: state.lastCrawledAt, + crawlVersions: state.crawlVersions, }), await deps.buildBannerState(req)).to("text/html"); res.status(html.statusCode).type("html").send(html.body); }; @@ -265,6 +266,7 @@ export function initAdminRecrawlRoutes(deps: AdminRecrawlDependencies): Router { readArticleContent: deps.readArticleContent, findArticleByUrl: deps.findArticleByUrl, findArticleFreshness: deps.findArticleFreshness, + findArticleCrawlVersions: deps.findArticleCrawlVersions, appOrigin: deps.appOrigin, formatDocumentTitle: formatRecrawlDocumentTitle, summaryOpen: false, diff --git a/projects/hutch/src/runtime/web/pages/queue/queue.page.ts b/projects/hutch/src/runtime/web/pages/queue/queue.page.ts index 0bd1da85a..36b60f625 100644 --- a/projects/hutch/src/runtime/web/pages/queue/queue.page.ts +++ b/projects/hutch/src/runtime/web/pages/queue/queue.page.ts @@ -27,6 +27,7 @@ import type { DeleteArticle, FindArticleById, FindArticleByUrl, + FindArticleCrawlVersions, FindArticleFreshness, FindArticleUrlById, FindArticlesByUser, @@ -181,6 +182,7 @@ interface QueueDependencies { findArticleById: FindArticleById; findArticleByUrl: FindArticleByUrl; findArticleFreshness: FindArticleFreshness; + findArticleCrawlVersions: FindArticleCrawlVersions; findArticleUrlById: FindArticleUrlById; saveArticle: SaveArticle; deleteArticle: DeleteArticle; @@ -376,6 +378,7 @@ export function initQueueRoutes(deps: QueueDependencies): Router { readArticleContent: deps.readArticleContent, findArticleByUrl: deps.findArticleByUrl, findArticleFreshness: deps.findArticleFreshness, + findArticleCrawlVersions: deps.findArticleCrawlVersions, appOrigin: deps.appOrigin, formatDocumentTitle: formatReaderDocumentTitle, summaryOpen: false, @@ -502,7 +505,7 @@ export function initQueueRoutes(deps: QueueDependencies): Router { extensionInstallUrl: extensionInstallUrlIfMissing(req), backLink: VIEW_BACK_LINK, renderActions: deps.stickyReader, - lastCrawledAt: state.lastCrawledAt, + crawlVersions: state.crawlVersions, }), { ...(await deps.buildBannerState(req)), showExtensionSuggestionBanner, diff --git a/projects/hutch/src/runtime/web/pages/queue/queue.reader.route.test.ts b/projects/hutch/src/runtime/web/pages/queue/queue.reader.route.test.ts index 58b782b67..32fba87db 100644 --- a/projects/hutch/src/runtime/web/pages/queue/queue.reader.route.test.ts +++ b/projects/hutch/src/runtime/web/pages/queue/queue.reader.route.test.ts @@ -148,11 +148,33 @@ describe("Queue routes", () => { const afterDoc = new JSDOM(after.text).window.document; const tab = afterDoc.querySelector('[data-test-crawl-bookmark-tab="canonical"]'); assert(tab, "the canonical bookmark tab must render once contentFetchedAt exists"); + expect(tab.classList.contains("crawl-bookmark__tab--current")).toBe(true); + expect(tab.getAttribute("aria-disabled")).toBe("false"); + expect(tab.querySelector(".crawl-bookmark__badge")?.textContent).toBe("current"); const time = tab.querySelector("time"); assert(time, "the bookmark tab must carry a