Skip to content
Open
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
41 changes: 25 additions & 16 deletions projects/hutch/src/e2e/crawl-bookmark-visual.e2e-local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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");
}
Expand All @@ -25,9 +31,10 @@ async function openReaderWithBookmark(page: Page): Promise<void> {
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", () => {
Expand Down Expand Up @@ -63,19 +70,21 @@ 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");
const openHeight = (await handle.boundingBox())?.height;
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");
});
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 3 additions & 1 deletion projects/hutch/src/e2e/e2e-server.main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,14 +190,15 @@ const SeedCrawledArticleBody = z.object({
url: z.string(),
title: z.string(),
contentFetchedAt: z.string(),
crawlVersions: z.array(z.string()).default([]),
})
server.post('/e2e/seed-crawled-article', async (req, res) => {
const parsed = SeedCrawledArticleBody.safeParse(req.body)
if (!parsed.success) {
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,
Expand All @@ -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 })
})

Expand Down
2 changes: 2 additions & 0 deletions projects/hutch/src/runtime/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type {
DeleteArticle,
FindArticleById,
FindArticleByUrl,
FindArticleCrawlVersions,
FindArticleFreshness,
FindArticleUrlById,
FindArticlesByUser,
Expand All @@ -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
Expand Down Expand Up @@ -134,6 +139,7 @@ export function initDynamoDbArticleStore(deps: {
deleteAllUserArticles: DeleteAllUserArticles;
updateArticleStatus: UpdateArticleStatus;
findArticleFreshness: FindArticleFreshness;
findArticleCrawlVersions: FindArticleCrawlVersions;
markArticleViewed: MarkArticleViewed;
markSummaryToggled: MarkSummaryToggled;
markReaderViewSucceeded: MarkReaderViewSucceeded;
Expand All @@ -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,
Expand Down Expand Up @@ -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" });
};
Expand Down Expand Up @@ -625,6 +641,7 @@ export function initDynamoDbArticleStore(deps: {
deleteAllUserArticles,
updateArticleStatus,
findArticleFreshness,
findArticleCrawlVersions,
markArticleViewed,
markSummaryToggled,
markReaderViewSucceeded,
Expand Down
5 changes: 5 additions & 0 deletions projects/hutch/src/runtime/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ import type {
DeleteArticle,
FindArticleById,
FindArticleByUrl,
FindArticleCrawlVersions,
FindArticleFreshness,
FindArticleUrlById,
FindArticlesByUser,
Expand Down Expand Up @@ -237,6 +238,7 @@ interface AppDependencies {
findArticleById: FindArticleById;
findArticleByUrl: FindArticleByUrl;
findArticleFreshness: FindArticleFreshness;
findArticleCrawlVersions: FindArticleCrawlVersions;
findArticleUrlById: FindArticleUrlById;
findArticlesByUser: FindArticlesByUser;
countArticlesByUser: CountArticlesByUser;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions projects/hutch/src/runtime/test-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export interface AdminRecrawlPageInput {
extensionInstallUrl?: string;
appOrigin: string;
recrawlFormAction?: string;
lastCrawledAt?: LocalTime;
crawlVersions?: LocalTime[];
}

/**
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 4 additions & 2 deletions projects/hutch/src/runtime/web/pages/admin/recrawl.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -29,6 +29,7 @@ export interface AdminRecrawlDependencies {
appOrigin: string;
findArticleByUrl: FindArticleByUrl;
findArticleFreshness: FindArticleFreshness;
findArticleCrawlVersions: FindArticleCrawlVersions;
readArticleContent: ReadArticleContent;
findGeneratedSummary: FindGeneratedSummary;
findArticleCrawlStatus: FindArticleCrawlStatus;
Expand Down Expand Up @@ -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);
};
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion projects/hutch/src/runtime/web/pages/queue/queue.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type {
DeleteArticle,
FindArticleById,
FindArticleByUrl,
FindArticleCrawlVersions,
FindArticleFreshness,
FindArticleUrlById,
FindArticlesByUser,
Expand Down Expand Up @@ -181,6 +182,7 @@ interface QueueDependencies {
findArticleById: FindArticleById;
findArticleByUrl: FindArticleByUrl;
findArticleFreshness: FindArticleFreshness;
findArticleCrawlVersions: FindArticleCrawlVersions;
findArticleUrlById: FindArticleUrlById;
saveArticle: SaveArticle;
deleteArticle: DeleteArticle;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <time> for the crawl instant");
expect(time.getAttribute("datetime")).toBe("2026-03-26T14:32:00.000Z");
expect(time.getAttribute("data-local-time")).toBe("short-datetime");
expect(time.textContent).toBe("26 Mar '26, 14:32");

await fixture.articleStore.setCrawlVersions({
url: "https://example.com/crawled-post",
versions: ["2026-07-10T09:14Z", "2026-06-28T22:01Z", "2026-03-26T14:32Z"],
});

const versioned = await agent.get(`/queue/${articleId}/view`);
const versionedDoc = new JSDOM(versioned.text).window.document;
const keys = Array.from(
versionedDoc.querySelectorAll("[data-test-crawl-bookmark-tab]"),
).map((el) => el.getAttribute("data-test-crawl-bookmark-tab"));
expect(keys).toEqual(["canonical", "2026-06-28T22:01Z", "2026-03-26T14:32Z"]);
expect(versionedDoc.querySelectorAll(".crawl-bookmark__badge").length).toBe(1);
for (const key of ["2026-06-28T22:01Z", "2026-03-26T14:32Z"]) {
const disabled = versionedDoc.querySelector(`[data-test-crawl-bookmark-tab="${key}"]`);
assert(disabled, `version tab ${key} must render`);
expect(disabled.getAttribute("aria-disabled")).toBe("true");
expect(disabled.classList.contains("crawl-bookmark__tab--disabled")).toBe(true);
}
});

it("should leave the article unread when opening the reader (the user must click the explicit Mark-as-read button)", async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export function ReaderPage(
* same toolbar; the variant carries the page body class that decides where it
* pins, so the markup and the CSS that pins it can never drift apart. */
renderActions: RenderReaderActions;
lastCrawledAt?: LocalTime;
crawlVersions?: LocalTime[];
},
): PageBody {
const articleId = article.id.value;
Expand Down Expand Up @@ -98,7 +98,7 @@ export function ReaderPage(
appOrigin: options.appOrigin,
topActionsHtml: actions.top.to("text/html").body,
bottomActionsHtml: actions.bottom.to("text/html").body,
lastCrawledAt: options.lastCrawledAt,
crawlVersions: options.crawlVersions,
extensionInstallUrl: options.extensionInstallUrl,
});
const shareBalloon = renderShareBalloon({
Expand Down
Loading
Loading