From 3e920c7a389062d8ae6e8c71f6e5d8a552e20cdb Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Sun, 4 Oct 2026 06:16:48 +0530 Subject: [PATCH 1/3] Render articles before the background feed and shrink icons --- apps/admin/src/app/layout.tsx | 7 +- apps/admin/tests/browser/overview.mjs | 2 + apps/extensions/build.mjs | 3 +- apps/extensions/chrome/newtab.html | 1 + apps/extensions/tests/reader.browser.mjs | 27 ++++-- apps/web/src/app/articles/[slug]/page.tsx | 13 ++- apps/web/src/app/layout.tsx | 6 +- apps/web/tests/browser/feed.mjs | 28 ++++++ packages/theme/README.md | 6 +- packages/theme/assets/devfeed-icon-128.png | Bin 0 -> 4781 bytes packages/theme/assets/devfeed-icon-180.png | Bin 0 -> 7182 bytes packages/theme/assets/devfeed-icon-32.png | Bin 0 -> 1481 bytes scripts/assets/README.md | 9 ++ scripts/assets/browser-icons.mjs | 10 +++ scripts/testing/article-first.mjs | 99 +++++++++++++++++++++ 15 files changed, 198 insertions(+), 13 deletions(-) create mode 100644 packages/theme/assets/devfeed-icon-128.png create mode 100644 packages/theme/assets/devfeed-icon-180.png create mode 100644 packages/theme/assets/devfeed-icon-32.png create mode 100644 scripts/assets/browser-icons.mjs create mode 100644 scripts/testing/article-first.mjs diff --git a/apps/admin/src/app/layout.tsx b/apps/admin/src/app/layout.tsx index 450be5410..4d1f6afd5 100644 --- a/apps/admin/src/app/layout.tsx +++ b/apps/admin/src/app/layout.tsx @@ -2,15 +2,16 @@ import { connection } from "next/server"; import { BrowserTelemetry } from "@devfeed/telemetry/browser"; import { browserSettings } from "@devfeed/telemetry/receiver"; import type { Metadata } from "next"; -import brandMark from "@devfeed/theme/assets/devfeed-mark.png"; +import browserIcon from "@devfeed/theme/assets/devfeed-icon-32.png"; +import appleIcon from "@devfeed/theme/assets/devfeed-icon-180.png"; import { Toaster } from "@/components/atoms/sonner"; import { adminSiteTitle } from "@/lib/page-titles"; import "./globals.css"; export const metadata: Metadata = { icons: { - icon: { url: brandMark.src, type: "image/png" }, - apple: brandMark.src, + icon: { url: browserIcon.src, type: "image/png", sizes: "32x32" }, + apple: { url: appleIcon.src, type: "image/png", sizes: "180x180" }, }, title: { default: adminSiteTitle, template: `%s · ${adminSiteTitle}` }, description: "DevFeed site administration", diff --git a/apps/admin/tests/browser/overview.mjs b/apps/admin/tests/browser/overview.mjs index 2a569fa49..02832424c 100644 --- a/apps/admin/tests/browser/overview.mjs +++ b/apps/admin/tests/browser/overview.mjs @@ -5,6 +5,7 @@ import { mkdir, writeFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { build } from "esbuild"; import { chromium } from "playwright"; +import { checkBrowserIcons } from "../../../../scripts/testing/article-first.mjs"; // Run after npm run admin:build. All API traffic stays on disposable localhost servers. const root = fileURLToPath(new URL("../../../../", import.meta.url)); @@ -236,6 +237,7 @@ try { ); console.log("Opening", origin); await page.goto(origin); + await checkBrowserIcons(page, true); const response = await page.request.get(origin); assert.match(response.headers()["content-security-policy"], /script-src [^;]*'nonce-[^']+'/); assert.doesNotMatch( diff --git a/apps/extensions/build.mjs b/apps/extensions/build.mjs index 32d7d4193..70a2dce3a 100644 --- a/apps/extensions/build.mjs +++ b/apps/extensions/build.mjs @@ -8,7 +8,8 @@ if (!["chrome", "edge"].includes(browser)) throw new Error(`Unsupported browser: const output = `${root}/apps/extensions/dist/${browser}`; await mkdir(output, { recursive: true }); await cp(`${root}/apps/extensions/chrome`, output, { recursive: true }); -await cp(`${root}/packages/theme/assets/devfeed-mark.png`, `${output}/icon.png`); +await cp(`${root}/packages/theme/assets/devfeed-icon-128.png`, `${output}/icon.png`); +await cp(`${root}/packages/theme/assets/devfeed-icon-32.png`, `${output}/favicon.png`); await cp(`${root}/apps/web/public/tool-icons`, `${output}/tool-icons`, { recursive: true }); await build({ absWorkingDir: root, diff --git a/apps/extensions/chrome/newtab.html b/apps/extensions/chrome/newtab.html index e37a74aab..0eff24f93 100644 --- a/apps/extensions/chrome/newtab.html +++ b/apps/extensions/chrome/newtab.html @@ -4,6 +4,7 @@ DevFeed — New Tab + diff --git a/apps/extensions/tests/reader.browser.mjs b/apps/extensions/tests/reader.browser.mjs index 1fb816e90..78b9e0f97 100644 --- a/apps/extensions/tests/reader.browser.mjs +++ b/apps/extensions/tests/reader.browser.mjs @@ -12,6 +12,7 @@ import { checkEngagementPagination, } from "../../../scripts/testing/engagement-pagination.mjs"; import { checkPreviewBackground } from "../../../scripts/testing/preview-background.mjs"; +import { blockedFeed, checkArticleFirst } from "../../../scripts/testing/article-first.mjs"; import { searchFixture, checkSearchFilters, @@ -89,6 +90,7 @@ test( let engagementPagination = false; let failNextPage = true; let failArticle = true; + let articleFeedGate; await context.route("https://identity.example/authorize?**", (route) => route.fulfill({ contentType: "text/html", body: "

Sign-in provider

" }), ); @@ -100,6 +102,10 @@ test( ); await context.route("https://devfeed.tech/api/**", async (route) => { const url = new URL(route.request().url()); + const pendingFeed = articleFeedGate; + if (pendingFeed && (await pendingFeed.wait(url.pathname)) && pendingFeed.fail) { + return route.fulfill({ status: 503, json: {} }); + } requests.push(url); if ( engagementPagination && @@ -298,11 +304,22 @@ test( await retryPage.getByRole("button", { name: "Try again", exact: true }).click(); await retryPage.locator("#article-preview-title").waitFor(); await retryPage.close(); - const direct = await context.newPage(); - await direct.goto(page.url().split("#")[0] + "#/articles/direct-article"); - await direct.locator("#article-preview-title").waitFor(); - assert.ok(direct.url().endsWith("#/articles/direct-article")); - await direct.close(); + for (const fail of [false, true]) { + const direct = await context.newPage(); + articleFeedGate = blockedFeed(fail); + try { + await checkArticleFirst( + direct, + page.url().split("#")[0] + "#/articles/direct-article", + articleFeedGate, + ); + if (fail) assert.ok(direct.url().endsWith("#/articles/direct-article")); + } finally { + articleFeedGate.release(); + articleFeedGate = undefined; + await direct.close(); + } + } await page.bringToFront(); await page.goto(page.url().split("#")[0] + "#/latest"); await page.locator(".article-card").first().waitFor(); diff --git a/apps/web/src/app/articles/[slug]/page.tsx b/apps/web/src/app/articles/[slug]/page.tsx index 6e05a13e8..a28f5ad81 100644 --- a/apps/web/src/app/articles/[slug]/page.tsx +++ b/apps/web/src/app/articles/[slug]/page.tsx @@ -7,6 +7,9 @@ import { ArticleModal } from "@/components/article-modal"; import { parseFilters } from "@/lib/feed-query"; import { ArticlePreview } from "@/components/article-preview"; import { articleMetadata } from "@/lib/metadata"; +import { Suspense } from "react"; +import { UserShell } from "@/components/user-shell"; +import { LoadingSkeleton } from "@/components/loading-skeleton"; export const dynamic = "force-dynamic"; type Props = { params: Promise<{ slug: string }> }; export async function generateMetadata({ params }: Props): Promise { @@ -28,7 +31,15 @@ export default async function ArticlePage({ params }: Props) { ); return ( <> - {await FeedView({ filters: parseFilters({}), structuredData: false })} + + + + } + > + + diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 125958cf6..07a74501e 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -14,6 +14,8 @@ import { connection } from "next/server"; import { headers } from "next/headers"; import { analyticsMeasurementId, clarityProjectId, xPixelEnabled } from "@/lib/server/config"; import brandMark from "@devfeed/theme/assets/devfeed-mark.png"; +import browserIcon from "@devfeed/theme/assets/devfeed-icon-32.png"; +import appleIcon from "@devfeed/theme/assets/devfeed-icon-180.png"; import "./globals.css"; import "./reader-motion.css"; import "./article-share.css"; @@ -31,8 +33,8 @@ export async function generateMetadata(): Promise { metadataBase: new URL(canonicalUrl("/")), ...socialMetadata("DevFeed — Developer news", SITE_DESCRIPTION), icons: { - icon: { url: brandMark.src, type: "image/png" }, - apple: brandMark.src, + icon: { url: browserIcon.src, type: "image/png", sizes: "32x32" }, + apple: { url: appleIcon.src, type: "image/png", sizes: "180x180" }, }, title: { default: "DevFeed — Developer news", diff --git a/apps/web/tests/browser/feed.mjs b/apps/web/tests/browser/feed.mjs index 5ce8aa524..a32b3d673 100644 --- a/apps/web/tests/browser/feed.mjs +++ b/apps/web/tests/browser/feed.mjs @@ -19,6 +19,7 @@ import { promoPublicProfile, } from "../../../../scripts/testing/dev-card-promo.mjs"; import { checkPreviewBackground } from "../../../../scripts/testing/preview-background.mjs"; +import { blockedFeed, checkArticleFirst } from "../../../../scripts/testing/article-first.mjs"; import { checkFeedPreparation } from "../../../../scripts/testing/feed-preparation.mjs"; import { checkLanguagePreferences, @@ -64,6 +65,7 @@ const { article, topic, source } = await import( withManagedImage(article); const mustReadsFixture = dailyFixture(article); let mode = "ready"; +let articleFeedGate; let feedSettings = { view: "cards", content_types: ["news", "article", "tutorial", "release", "comparison", "opinion"], @@ -79,6 +81,12 @@ let savedTopicIds = [topic.id]; const fixture = createServer(async (req, res) => { const requestUrl = new URL(req.url, "http://localhost"); const path = requestUrl.pathname; + const pendingFeed = articleFeedGate; + if (pendingFeed && (await pendingFeed.wait(path)) && pendingFeed.fail) { + res.writeHead(503, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ detail: "Feed unavailable" })); + return; + } if (path.startsWith("/v1/user/must-reads")) { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(await mustReadsFixture.response(path.endsWith("/presentation")))); @@ -655,6 +663,26 @@ try { const preparationPage = await context.newPage(); await checkFeedPreparation(preparationPage, origin, article, `${output}/preparation`); await preparationPage.close(); + for (const fail of [false, true]) { + articleFeedGate = blockedFeed(fail); + const directContext = await browser.newContext({ viewport: { width: 1440, height: 1000 } }); + const directPage = await directContext.newPage(); + try { + await checkArticleFirst(directPage, `${origin}/articles/${article.slug}`, articleFeedGate, { + apple: true, + screenshot: `${root}/reports/article-first-web-${fail ? "failed" : "slow"}-feed.png`, + }); + if (!fail) await directPage.waitForURL(`${origin}/latest`); + assert.equal( + await directPage.locator('link[rel="canonical"]').getAttribute("href"), + fail ? `${origin}/articles/${article.slug}` : `${origin}/latest`, + ); + } finally { + articleFeedGate.release(); + articleFeedGate = undefined; + await directContext.close(); + } + } const retryContext = await browser.newContext({ viewport: { width: 390, height: 844 } }); const retryPage = await retryContext.newPage(); const retryResponse = await retryPage.goto(`${origin}/articles/retry-article`); diff --git a/packages/theme/README.md b/packages/theme/README.md index ddacaea48..1191043ab 100644 --- a/packages/theme/README.md +++ b/packages/theme/README.md @@ -20,7 +20,11 @@ check light/dark desktop and mobile screens, including menus, previews and setti Both frontend Dockerfiles include this package, and Compose watch tracks its files. `assets/` owns the original public-app logo files. Both apps statically import the -mark for their headers and browser icons. `brand.css` shares the logo sizing, +mark for their headers. Browser icons use dedicated 32-pixel PNGs and Apple touch +icons use 180-pixel PNGs. Chrome and Edge use the same 32-pixel favicon and a +128-pixel packaged icon/reader mark. Regenerate these compact variants from the +original with `node scripts/assets/browser-icons.mjs`; the original remains available +for artwork. `brand.css` shares the logo sizing, wordmark typography and dark-theme treatment; the supplied raster wordmark remains available for light-background artwork. See `docs/brand-assets.md` in the repo root. diff --git a/packages/theme/assets/devfeed-icon-128.png b/packages/theme/assets/devfeed-icon-128.png new file mode 100644 index 0000000000000000000000000000000000000000..202636afd5298a2ee0ab7559644f2f47fcd89a0f GIT binary patch literal 4781 zcmXw+2Rv2(|HsdD@3pTHva%wZ$Q~gQ%8FEk?7gnbz1QAkuS-Tmlu;yGii`9$bIni* zSs78tzJBNG|9?F0Ip_0v&*%H~IgiJEoOokH9RxK8H2?qzJ>Baj;4_?bQ9{A{&TRSt z06+m_{X5$Fu1KKL4C?Oz1AiD#AS?_pGlQW$VQ7*C42GgTKqF}jE>2HLF3^4ofLo}) zJCqdZ38sT#FmUVV4#Rkn>?m;I33^E`KXB_x3PZVpE>bBBsR*e~m^y$#fw^Y@?&N4U zQWh8n)*+Shg!_Y$o-jWTxUV}Y1LI;G4^L-x&xGm?s&bp%fe#2_(%@Sx`IumG^NCzuEi1hN0g3r^<_!}@?0 zxF2acI0kG)q9xhE#Ir$2f}bIg<_+{AA%Ud=kZ!k%R)tgM47%8cYoE zfrDOf*kB}haL@qoT+VVs{b1o}ScoqP9%KmghJ!UjQLr$7C}_lZ!$VPQ>f;x{w|vIW z#LyIg5|4;vP$H2CA;M0MAcrTA<0In9@gZnFxpfFW4Ue3O$YXgFjQ-WZUy&1|m%c~Z zu5zME2Y&i)aDj6zqDD@d2GuUaxxM2iRK>~7n+pt3b8`3RV;l$f4u!7kcKTa6vK8kZ z6N!I!xg<)q1+UVCUO_}MPn_RaBmS*AIpJOK`e9+_y!VzYh4`J=b3*laf_U_3Yb&gy z>Uir{^y-D9L$%%8u4^lS)I-}-=wga2BH7`ddN(v4Z`xLH67*rXPi3R#>%e}^Nz6&J zbIHgv4x@&tuZEyj)mDE`+NeA&0tai3RF8CKK+oUurTt#FVLuUjod3oHjc6J5&gm#LLQxCx?#K*~66m zwmbaT`uq-9x^NUn|sL1i$K045pKO#)-2M(aJfI3>#d#g z;O69{9H746?1CNkDIHZNa#1)=xUyPCh6f}CFbuE=9{SN18I>|@-zbcuks9jw89Frj zr(%lDA?V5tmHinZTVgyhk_D)ml>@RXE>$#NTIC-0~?|1 zNPvewEeJoTSO}H2o8byWU#)G##IQ&NUt0Kh9RlEPhf9%3GVfhYqOF9i3^To9r|nYB z7_@(=msMrhWW3QjAAIaXLYxLyyVsNp zji4ND`^E-K?Q5J`Z(@rI%6VCx(uQ6amd(IlsBAkAj(N~~?h_qH&!?CNIqvf11<`N*6E8%#-?xJ8#Ib`{&7`BxhMMF-{ZJG91 zhYr5w*#0gQ95H7-QAe`PQt-cHd!#mLCMu{Km*#SgDLq

oci07t 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 HBJ_U%?u0@0 literal 0 HcmV?d00001 diff --git a/packages/theme/assets/devfeed-icon-32.png b/packages/theme/assets/devfeed-icon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..a04b554af854f75508c41213b9125ac7d6850466 GIT binary patch literal 1481 zcmX9-3s4hR6n)8iyCHlmr8*^oN)ZtOk!nSRp@4)6@)@c|!8)2D46q~%+5AH?3BL*y zMJqC+lq!B^T3e-li&N{j6s$_9(gHd-rT$t!sAB-J1ngbh+0DLp-@WIabMB@#D|4ln z`%HI)kXLGobTzyK_{R;yuk_Ft9S8~Qv(nclr!yRSgRqfB`T59hM{nLBmgN_4$z;T! zq%|tEc{4Jw6jy)^DoV!?y$Wkt#3^WP0f7jo#Cj#xDdCjA^(snROzDasNEua>K`l10 z$i!d+OH9RZN-P{Kpb%~h_$DQ^;^U=i+Q3kTB4EPDOBf+w>QqF>5+l@>Kwo4pfg?&= zh>gWUBa3ycP|u?Hcw(I5U{)goBLpaO2@l98@HA)*oT>ngm{>OrLtFJ&&rVTQ5(Cc! z-AG^yeopzuCmU1(*n(=l9ZM=rA`>U#ib%B;Y4S1nre%;_ z1{$$N4Mq_lHdW4>qt)_kHO!Q7CDt<7!a*Zmk&&hKg|L9hLOyU7sdlA0vly6z zG+bdM77eK|k@`x0=v%D3)zwx~rpGoNFQ?Xqp$pK4twl2}<-&9L8qVrgXRbj47exdj zp#YDCxcM*Yh;U~3gbpuE?;IW879Lr>D9r!eOXps@Tx0gJU}4APf-v{t1)Vi)%lTpN zt>FjQ`dq?xPA)id?ux5sICv+!f7ZNx-4ksuGhEB}yDHl{8ZF~xLp#R1M!WlLp>=hk z5A&;@o={8+Mf2Ls&l0NRZ!66^ogZ(XU{tmjz^q_E9U6y3|F`F z#r$iub4Fl)$i&=$$!^EXPO;y}(csrZ3$g!^K9C_+r{ri6^6=pw!Oc517yRpHrb-jn zMqg9NTjt0`!f9>|xc!rchsfjN^s=SumJoT~;DdV&f0wKKRpkDR@)cLgdeYSU{$Uup zC9&DFxB6gr?Nu%?ZOPcs550BK3Xjzn9fS7V^Rs(wv2>NsI=V4w{mjoUHzxFIBSX60 z+ozqViOt$Ac=qjW<#$5Qr!i0EEx$MdqFyEV58MdYkh^`?hu9-sdU!4}EJL#O0 z);DkcuY*5oht%{<@SG}rSuPHBI3A#1t7 z*KZ!1)6|qIPEJ_AE^qIEpF2l+hi$&SETgmF|#`AX8C|B*iGoA&Xa7Dv9w^Y;?FCq(Dotw{1~!$Wd~0#kHslDz**c~sT1TemWb zS8+>Jw~iH-Do=RhC9#!14i?*_O(=BWRKfQjSB^R=Cmx@j<(u6vvHondv{W7XZT6=M zdi3;3WgrzT@!caiLm6WahWs9POz}P*NRj6qe)e#sdyzq|oAXz2M9}rYD`HQ1*auwO zJyE##4X<=bre#m>;je2df_$B8iXzf2T|0a{Mfhy#!4_)mpU;qFB literal 0 HcmV?d00001 diff --git a/scripts/assets/README.md b/scripts/assets/README.md index 47bfaafd8..d32bf65e5 100644 --- a/scripts/assets/README.md +++ b/scripts/assets/README.md @@ -12,3 +12,12 @@ nor a font download. Card text disables kerning to match the additive advances. use conservative widths; actual glyph appearance still depends on the viewer's system fonts. If the theme's Arial/Helvetica font family changes, update the metrics and validate the web app, standalone SVG/PNG exports, and both extensions together. + +## Browser icons + +Run `node scripts/assets/browser-icons.mjs` to regenerate the compact PNG variants +from `packages/theme/assets/devfeed-mark.png`: 32 pixels for browser favicons, +128 pixels for the Chrome/Edge package and reader mark, and 180 pixels for Apple +touch icons. Commit the generated assets with changes to the original mark. +Web/admin metadata and extension builds use these precomputed assets so icon +requests need no image transformation at runtime. diff --git a/scripts/assets/browser-icons.mjs b/scripts/assets/browser-icons.mjs new file mode 100644 index 000000000..dc2db544d --- /dev/null +++ b/scripts/assets/browser-icons.mjs @@ -0,0 +1,10 @@ +import { fileURLToPath } from "node:url"; +import sharp from "sharp"; + +const assets = fileURLToPath(new URL("../../packages/theme/assets/", import.meta.url)); +for (const size of [32, 128, 180]) { + await sharp(`${assets}devfeed-mark.png`) + .resize(size, size) + .png({ compressionLevel: 9, palette: true, colours: 128 }) + .toFile(`${assets}devfeed-icon-${size}.png`); +} diff --git a/scripts/testing/article-first.mjs b/scripts/testing/article-first.mjs new file mode 100644 index 000000000..be0505013 --- /dev/null +++ b/scripts/testing/article-first.mjs @@ -0,0 +1,99 @@ +import assert from "node:assert/strict"; + +export function blockedFeed(fail = false) { + let resume, started; + const pending = new Promise((resolve) => { + resume = resolve; + }); + const firstRequest = new Promise((resolve) => { + started = resolve; + }); + return { + fail, + released: false, + firstRequest, + async wait(path) { + if (!/^\/(?:api\/)?v1\/feed(?:\/options)?$/.test(path)) return false; + started(); + await pending; + return true; + }, + release() { + this.released = true; + resume(); + }, + }; +} + +export async function checkBrowserIcons(page, apple = false) { + for (const [rel, size, budget] of [ + ["icon", 32, 2048], + ...(apple ? [["apple-touch-icon", 180, 10_240]] : []), + ]) { + const asset = await page.evaluate(async (relation) => { + const link = document.head.querySelector(`link[rel="${relation}"]`); + const response = await fetch(link.href); + const bytes = new Uint8Array(await response.arrayBuffer()); + const dimensions = new DataView(bytes.buffer); + return { + status: response.status, + declaredSize: link.sizes.value, + signature: [...bytes.slice(0, 8)], + width: dimensions.getUint32(16), + height: dimensions.getUint32(20), + bytes: bytes.length, + }; + }, rel); + assert.equal(asset.status, 200); + assert.deepEqual(asset.signature, [137, 80, 78, 71, 13, 10, 26, 10]); + assert.equal(asset.declaredSize, `${size}x${size}`); + assert.equal(asset.width, size); + assert.equal(asset.height, size); + assert.ok(asset.bytes <= budget, `${rel} transfers ${asset.bytes} bytes (budget ${budget})`); + } +} + +/** Article content must be visible and interactive while feed requests remain blocked. */ +export async function checkArticleFirst(page, href, gate, { apple = false, screenshot } = {}) { + let timer; + try { + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.goto(href, { waitUntil: "commit", timeout: 10_000 }); + await page + .locator("dialog.article-modal[open] #article-preview-title") + .waitFor({ timeout: 5000 }); + await Promise.race([ + gate.firstRequest, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("No blocked background feed request")), 5000); + }), + ]); + clearTimeout(timer); + assert.equal(gate.released, false, "article renders before the feed completes"); + const title = await page.locator("#article-preview-title").textContent(); + assert.ok(title.trim()); + assert.equal( + await page.getByRole("button", { name: "Close preview", exact: true }).isEnabled(), + true, + ); + await checkBrowserIcons(page, apple); + if (screenshot) await page.screenshot({ path: screenshot, animations: "disabled" }); + if (gate.fail) { + gate.release(); + await page.getByRole("heading", { name: "Couldn’t load the feed", exact: true }).waitFor(); + assert.equal(await page.locator("#article-preview-title").textContent(), title); + assert.equal(await page.locator("dialog.article-modal[open]").count(), 1); + } else { + await page.getByRole("button", { name: "Close preview", exact: true }).click(); + await page + .locator("dialog.article-modal[open]") + .waitFor({ state: "detached", timeout: 1000 }); + assert.equal(gate.released, false, "dismissal does not wait for the background feed"); + gate.release(); + await page.locator(".article-card").first().waitFor(); + } + } finally { + clearTimeout(timer); + gate.release(); + } +} From 1551e2c5429a96943f16c9d979d1c77f8e17cdb2 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Sun, 4 Oct 2026 18:11:58 +0530 Subject: [PATCH 2/3] Cover article streaming and compact icon packaging --- apps/admin/tests/browser/overview.mjs | 2 +- apps/admin/tests/layout-metadata.test.ts | 21 +++ apps/extensions/build.mjs | 90 +++++----- apps/extensions/tests/reader.browser.mjs | 2 +- apps/web/tests/article-page.test.tsx | 163 ++++++++++++++++++ apps/web/tests/browser-icons.test.ts | 42 +++++ .../web/tests/browser}/article-first.mjs | 0 apps/web/tests/browser/feed.mjs | 2 +- apps/web/tests/extension-icons.test.ts | 32 ++++ apps/web/tests/layout-metadata.test.ts | 28 +++ apps/web/vitest.config.ts | 2 + scripts/assets/browser-icons.mjs | 21 ++- 12 files changed, 354 insertions(+), 51 deletions(-) create mode 100644 apps/admin/tests/layout-metadata.test.ts create mode 100644 apps/web/tests/article-page.test.tsx create mode 100644 apps/web/tests/browser-icons.test.ts rename {scripts/testing => apps/web/tests/browser}/article-first.mjs (100%) create mode 100644 apps/web/tests/extension-icons.test.ts create mode 100644 apps/web/tests/layout-metadata.test.ts diff --git a/apps/admin/tests/browser/overview.mjs b/apps/admin/tests/browser/overview.mjs index 02832424c..c88a446fa 100644 --- a/apps/admin/tests/browser/overview.mjs +++ b/apps/admin/tests/browser/overview.mjs @@ -5,7 +5,7 @@ import { mkdir, writeFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { build } from "esbuild"; import { chromium } from "playwright"; -import { checkBrowserIcons } from "../../../../scripts/testing/article-first.mjs"; +import { checkBrowserIcons } from "../../../web/tests/browser/article-first.mjs"; // Run after npm run admin:build. All API traffic stays on disposable localhost servers. const root = fileURLToPath(new URL("../../../../", import.meta.url)); diff --git a/apps/admin/tests/layout-metadata.test.ts b/apps/admin/tests/layout-metadata.test.ts new file mode 100644 index 000000000..772ed1ef5 --- /dev/null +++ b/apps/admin/tests/layout-metadata.test.ts @@ -0,0 +1,21 @@ +import { expect, it, vi } from "vitest"; +import { metadata } from "@/app/layout"; + +vi.mock("@devfeed/theme/assets/devfeed-icon-32.png", () => ({ + default: { src: "/assets/favicon-32.png" }, +})); +vi.mock("@devfeed/theme/assets/devfeed-icon-180.png", () => ({ + default: { src: "/assets/apple-180.png" }, +})); + +it("uses compact icons while keeping admin pages out of search results", () => { + expect(metadata.icons).toEqual({ + icon: { url: "/assets/favicon-32.png", type: "image/png", sizes: "32x32" }, + apple: { url: "/assets/apple-180.png", type: "image/png", sizes: "180x180" }, + }); + expect(metadata.robots).toEqual({ index: false, follow: false }); + expect(metadata.title).toEqual({ + default: "DevFeed Admin", + template: "%s · DevFeed Admin", + }); +}); diff --git a/apps/extensions/build.mjs b/apps/extensions/build.mjs index 70a2dce3a..41784c16b 100644 --- a/apps/extensions/build.mjs +++ b/apps/extensions/build.mjs @@ -1,46 +1,54 @@ import { build } from "esbuild"; import { cp, mkdir } from "node:fs/promises"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; const root = fileURLToPath(new URL("../../", import.meta.url)).replace(/\/$/, ""); -const browser = process.argv[2] ?? "chrome"; -if (!["chrome", "edge"].includes(browser)) throw new Error(`Unsupported browser: ${browser}`); -const output = `${root}/apps/extensions/dist/${browser}`; -await mkdir(output, { recursive: true }); -await cp(`${root}/apps/extensions/chrome`, output, { recursive: true }); -await cp(`${root}/packages/theme/assets/devfeed-icon-128.png`, `${output}/icon.png`); -await cp(`${root}/packages/theme/assets/devfeed-icon-32.png`, `${output}/favicon.png`); -await cp(`${root}/apps/web/public/tool-icons`, `${output}/tool-icons`, { recursive: true }); -await build({ - absWorkingDir: root, - entryPoints: { newtab: "apps/extensions/src/entry.tsx" }, - bundle: true, - outdir: output, - target: browser === "edge" ? "edge120" : "chrome120", - jsx: "automatic", - legalComments: "eof", - define: { "process.env.NODE_ENV": '"production"', __DEVFEED_BROWSER__: JSON.stringify(browser) }, - alias: { - "@": `${root}/apps/web/src`, - "next/link": `${root}/apps/extensions/src/link.tsx`, - "next/image": `${root}/apps/extensions/src/image.tsx`, - "next/navigation": `${root}/apps/extensions/src/navigation.tsx`, - }, - plugins: [ - { - name: "bundled-brand-mark", - setup(build) { - build.onResolve({ filter: /^@devfeed\/theme\/assets\/devfeed-mark\.png$/ }, () => ({ - path: "icon.png", - namespace: "brand", - })); - build.onLoad({ filter: /.*/, namespace: "brand" }, () => ({ - contents: 'export default { src: "icon.png" };', - loader: "js", - })); - }, +export async function buildExtension(browser = "chrome") { + if (!["chrome", "edge"].includes(browser)) throw new Error(`Unsupported browser: ${browser}`); + const output = `${root}/apps/extensions/dist/${browser}`; + await mkdir(output, { recursive: true }); + await cp(`${root}/apps/extensions/chrome`, output, { recursive: true }); + await cp(`${root}/packages/theme/assets/devfeed-icon-128.png`, `${output}/icon.png`); + await cp(`${root}/packages/theme/assets/devfeed-icon-32.png`, `${output}/favicon.png`); + await cp(`${root}/apps/web/public/tool-icons`, `${output}/tool-icons`, { recursive: true }); + await build({ + absWorkingDir: root, + entryPoints: { newtab: "apps/extensions/src/entry.tsx" }, + bundle: true, + outdir: output, + target: browser === "edge" ? "edge120" : "chrome120", + jsx: "automatic", + legalComments: "eof", + define: { + "process.env.NODE_ENV": '"production"', + __DEVFEED_BROWSER__: JSON.stringify(browser), + }, + alias: { + "@": `${root}/apps/web/src`, + "next/link": `${root}/apps/extensions/src/link.tsx`, + "next/image": `${root}/apps/extensions/src/image.tsx`, + "next/navigation": `${root}/apps/extensions/src/navigation.tsx`, }, - ], - minify: true, -}); -console.log(`Load unpacked: ${output}`); + plugins: [ + { + name: "bundled-brand-mark", + setup(build) { + build.onResolve({ filter: /^@devfeed\/theme\/assets\/devfeed-mark\.png$/ }, () => ({ + path: "icon.png", + namespace: "brand", + })); + build.onLoad({ filter: /.*/, namespace: "brand" }, () => ({ + contents: 'export default { src: "icon.png" };', + loader: "js", + })); + }, + }, + ], + minify: true, + }); + console.log(`Load unpacked: ${output}`); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await buildExtension(process.argv[2]); +} diff --git a/apps/extensions/tests/reader.browser.mjs b/apps/extensions/tests/reader.browser.mjs index 78b9e0f97..0c0931740 100644 --- a/apps/extensions/tests/reader.browser.mjs +++ b/apps/extensions/tests/reader.browser.mjs @@ -12,7 +12,7 @@ import { checkEngagementPagination, } from "../../../scripts/testing/engagement-pagination.mjs"; import { checkPreviewBackground } from "../../../scripts/testing/preview-background.mjs"; -import { blockedFeed, checkArticleFirst } from "../../../scripts/testing/article-first.mjs"; +import { blockedFeed, checkArticleFirst } from "../../web/tests/browser/article-first.mjs"; import { searchFixture, checkSearchFilters, diff --git a/apps/web/tests/article-page.test.tsx b/apps/web/tests/article-page.test.tsx new file mode 100644 index 000000000..1b469c585 --- /dev/null +++ b/apps/web/tests/article-page.test.tsx @@ -0,0 +1,163 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { createElement, type ReactNode } from "react"; +import { renderToReadableStream } from "react-dom/server"; +import ArticlePage, { generateMetadata } from "@/app/articles/[slug]/page"; +import { loadArticle } from "@/lib/article"; +import { getFeed, getFeedOptions } from "@/lib/api"; +import type { Article, FeedOptions, FeedPage } from "@/lib/types"; +import type { FeedContentProps } from "@/components/feed-content"; + +vi.mock("@/lib/article", () => ({ loadArticle: vi.fn() })); +vi.mock("@/lib/api", () => ({ getFeed: vi.fn(), getFeedOptions: vi.fn() })); +vi.mock("@/components/user-shell", () => ({ + UserShell: ({ children }: { children: ReactNode }) =>

{children}
, +})); +vi.mock("@/components/loading-skeleton", () => ({ + LoadingSkeleton: () =>

Loading background feed

, +})); +vi.mock("@/components/article-modal", () => ({ + ArticleModal: ({ children, slug }: { children: ReactNode; slug: string }) => ( + + {children} + + ), +})); +vi.mock("@/components/article-preview", () => ({ + ArticlePreview: ({ article }: { article: Article }) =>

{article.title}

, +})); +vi.mock("@/components/article-unavailable", () => ({ + ArticleUnavailable: ({ children }: { children: ReactNode }) => ( +
Article temporarily unavailable{children}
+ ), +})); +vi.mock("@/components/retry-feed", () => ({ RetryFeed: () => })); +vi.mock("@/components/feed-content", () => ({ + FeedContent: ({ feed }: FeedContentProps) => ( +
+ {feed.status === "fulfilled" ? feed.value.items[0]?.title : "Couldn’t load the feed"} +
+ ), +})); + +const article: Article = { + id: "article-id", + slug: "requested-article", + canonical_url: "https://publisher.example/story", + title: "Requested article", + summary: "A useful article", + ai_summary: null, + ai_description: null, + image_url: null, + author: null, + content_type: "news", + content_format: "article", + language: "en", + published_at: null, + feed_at: "2026-10-04T00:00:00Z", + tags: [], + sources: [], + topics: [], +}; +const params = () => ({ params: Promise.resolve({ slug: article.slug }) }); +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: Error) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} + +beforeEach(() => { + vi.mocked(loadArticle).mockReset().mockResolvedValue(article); + vi.mocked(getFeed).mockReset(); + vi.mocked(getFeedOptions).mockReset(); + vi.stubEnv("DEVFEED_USER_BASE_URL", "https://reader.example"); +}); +afterEach(() => vi.unstubAllEnvs()); + +it.each([false, true])( + "streams the requested article while feed and filters are pending (feed fails: %s)", + async (fails) => { + const feed = deferred(); + const filters = deferred(); + vi.mocked(getFeed).mockReturnValue(feed.promise); + vi.mocked(getFeedOptions).mockReturnValue(filters.promise); + const errors: unknown[] = []; + const page = await ArticlePage(params()); + const stream = await renderToReadableStream( + + {createElement("head")} + {page} + , + { + onError: (error) => { + errors.push(error); + }, + }, + ); + const reader = stream.getReader(); + const decoder = new TextDecoder(); + try { + const first = await reader.read(); + const shell = decoder.decode(first.value); + expect(first.done).toBe(false); + expect(shell).toContain("Requested article"); + expect(shell).toContain('data-slug="requested-article"'); + expect(shell).toContain("Loading background feed"); + expect(shell).not.toContain("Background article"); + expect(getFeed).toHaveBeenCalledTimes(1); + expect(getFeedOptions).toHaveBeenCalledTimes(1); + if (fails) feed.reject(new Error("Background feed unavailable")); + else + feed.resolve({ + items: [{ ...article, title: "Background article" }], + next_cursor: null, + }); + filters.resolve({ content_types: [], sources: [] }); + let remainder = ""; + for (;;) { + const chunk = await reader.read(); + if (chunk.done) break; + remainder += decoder.decode(chunk.value); + } + expect(remainder).toContain(fails ? "Couldn’t load the feed" : "Background article"); + expect(shell).toContain("Requested article"); + expect(errors).toEqual([]); + } finally { + feed.resolve({ items: [], next_cursor: null }); + filters.resolve({ content_types: [], sources: [] }); + await reader.cancel(); + } + }, +); + +it("renders an unavailable article with retry without loading the background feed", async () => { + vi.mocked(loadArticle).mockResolvedValue(null); + const stream = await renderToReadableStream(await ArticlePage(params())); + const html = await new Response(stream).text(); + expect(html).toContain("Article temporarily unavailable"); + expect(html).toContain("Try again"); + expect(html).toContain('data-slug="requested-article"'); + expect(getFeed).not.toHaveBeenCalled(); + expect(getFeedOptions).not.toHaveBeenCalled(); +}); + +it("publishes canonical article metadata independently of feed availability", async () => { + expect(await generateMetadata(params())).toMatchObject({ + title: "Requested article", + alternates: { canonical: "https://reader.example/articles/requested-article" }, + }); + expect(loadArticle).toHaveBeenCalledWith(article.slug); + expect(getFeed).not.toHaveBeenCalled(); + expect(getFeedOptions).not.toHaveBeenCalled(); +}); + +it("prevents indexing an unavailable article", async () => { + vi.mocked(loadArticle).mockResolvedValue(null); + expect(await generateMetadata(params())).toEqual({ + title: "Article temporarily unavailable", + robots: { index: false, follow: false }, + }); +}); diff --git a/apps/web/tests/browser-icons.test.ts b/apps/web/tests/browser-icons.test.ts new file mode 100644 index 000000000..68545f37b --- /dev/null +++ b/apps/web/tests/browser-icons.test.ts @@ -0,0 +1,42 @@ +import { afterEach, expect, it } from "vitest"; +import { cp, mkdtemp, readFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import sharp from "sharp"; +import { generateBrowserIcons } from "../../../scripts/assets/browser-icons.mjs"; + +const assets = fileURLToPath(new URL("../../../packages/theme/assets/", import.meta.url)); +const directories: string[] = []; +afterEach(async () => { + await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true }))); +}); + +it("generates reproducible PNGs within browser and extension transfer budgets", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "devfeed-icon-test-")); + directories.push(directory); + await cp(path.join(assets, "devfeed-mark.png"), path.join(directory, "devfeed-mark.png")); + await generateBrowserIcons(directory); + for (const [size, budget] of [ + [32, 2048], + [128, 6144], + [180, 10240], + ]) { + const name = `devfeed-icon-${size}.png`; + const generated = await readFile(path.join(directory, name)); + expect(await sharp(generated).metadata()).toMatchObject({ + format: "png", + width: size, + height: size, + }); + expect(generated.length).toBeLessThanOrEqual(budget); + expect(generated).toEqual(await readFile(path.join(assets, name))); + } +}); + +it("reports a missing original without creating placeholder icons", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "devfeed-icon-test-")); + directories.push(directory); + await expect(generateBrowserIcons(directory)).rejects.toThrow(); + await expect(readFile(path.join(directory, "devfeed-icon-32.png"))).rejects.toThrow(); +}); diff --git a/scripts/testing/article-first.mjs b/apps/web/tests/browser/article-first.mjs similarity index 100% rename from scripts/testing/article-first.mjs rename to apps/web/tests/browser/article-first.mjs diff --git a/apps/web/tests/browser/feed.mjs b/apps/web/tests/browser/feed.mjs index a32b3d673..a1a4083c0 100644 --- a/apps/web/tests/browser/feed.mjs +++ b/apps/web/tests/browser/feed.mjs @@ -19,7 +19,7 @@ import { promoPublicProfile, } from "../../../../scripts/testing/dev-card-promo.mjs"; import { checkPreviewBackground } from "../../../../scripts/testing/preview-background.mjs"; -import { blockedFeed, checkArticleFirst } from "../../../../scripts/testing/article-first.mjs"; +import { blockedFeed, checkArticleFirst } from "./article-first.mjs"; import { checkFeedPreparation } from "../../../../scripts/testing/feed-preparation.mjs"; import { checkLanguagePreferences, diff --git a/apps/web/tests/extension-icons.test.ts b/apps/web/tests/extension-icons.test.ts new file mode 100644 index 000000000..5724dece9 --- /dev/null +++ b/apps/web/tests/extension-icons.test.ts @@ -0,0 +1,32 @@ +import { expect, it } from "vitest"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import sharp from "sharp"; +import { buildExtension } from "../../extensions/build.mjs"; + +const extensions = fileURLToPath(new URL("../../extensions/dist/", import.meta.url)); +it.each(["chrome", "edge"])("packages compact, loadable %s icons", async (browser) => { + await buildExtension(browser); + for (const [name, size, budget] of [ + ["icon.png", 128, 6144], + ["favicon.png", 32, 2048], + ] as const) { + const image = await readFile(`${extensions}${browser}/${name}`); + expect(await sharp(image).metadata()).toMatchObject({ + format: "png", + width: size, + height: size, + }); + expect(image.length).toBeLessThanOrEqual(budget); + } + const document = await readFile(`${extensions}${browser}/newtab.html`, "utf8"); + expect(document).toContain('href="favicon.png"'); + expect(document).toContain('src="newtab.js"'); + const manifest = JSON.parse(await readFile(`${extensions}${browser}/manifest.json`, "utf8")); + expect(manifest.icons[128]).toBe("icon.png"); + expect((await readFile(`${extensions}${browser}/newtab.js`)).length).toBeGreaterThan(0); +}); + +it("rejects unsupported browser packages", async () => { + await expect(buildExtension("firefox")).rejects.toThrow("Unsupported browser: firefox"); +}); diff --git a/apps/web/tests/layout-metadata.test.ts b/apps/web/tests/layout-metadata.test.ts new file mode 100644 index 000000000..8bfe93b0f --- /dev/null +++ b/apps/web/tests/layout-metadata.test.ts @@ -0,0 +1,28 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { connection } from "next/server"; +import { generateMetadata } from "@/app/layout"; + +vi.mock("next/server", () => ({ connection: vi.fn() })); +vi.mock("@devfeed/theme/assets/devfeed-icon-32.png", () => ({ + default: { src: "/assets/favicon-32.png" }, +})); +vi.mock("@devfeed/theme/assets/devfeed-icon-180.png", () => ({ + default: { src: "/assets/apple-180.png" }, +})); + +afterEach(() => vi.unstubAllEnvs()); +it("uses compact icons and request-specific deployment metadata", async () => { + vi.stubEnv("DEVFEED_USER_BASE_URL", "https://reader.example"); + const metadata = await generateMetadata(); + expect(connection).toHaveBeenCalled(); + expect(metadata.metadataBase).toEqual(new URL("https://reader.example/")); + expect(metadata.icons).toEqual({ + icon: { url: "/assets/favicon-32.png", type: "image/png", sizes: "32x32" }, + apple: { url: "/assets/apple-180.png", type: "image/png", sizes: "180x180" }, + }); + expect(metadata.title).toEqual({ + default: "DevFeed — Developer news", + template: "%s · DevFeed", + }); + expect(metadata.robots).toEqual({ "max-image-preview": "large" }); +}); diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index a932bf6ff..759bf7d8c 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -24,6 +24,8 @@ export default defineConfig({ "src/**/*.{ts,tsx}", `${path.resolve(import.meta.dirname, "../../packages")}/*/src/**/*.{ts,tsx}`, `${path.resolve(import.meta.dirname, "../../packages/theme")}/*.ts`, + path.resolve(import.meta.dirname, "../../scripts/assets/browser-icons.mjs"), + path.resolve(import.meta.dirname, "../extensions/build.mjs"), ], exclude: ["**/*.d.ts"], }, diff --git a/scripts/assets/browser-icons.mjs b/scripts/assets/browser-icons.mjs index dc2db544d..ba9298199 100644 --- a/scripts/assets/browser-icons.mjs +++ b/scripts/assets/browser-icons.mjs @@ -1,10 +1,17 @@ -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import sharp from "sharp"; -const assets = fileURLToPath(new URL("../../packages/theme/assets/", import.meta.url)); -for (const size of [32, 128, 180]) { - await sharp(`${assets}devfeed-mark.png`) - .resize(size, size) - .png({ compressionLevel: 9, palette: true, colours: 128 }) - .toFile(`${assets}devfeed-icon-${size}.png`); +export async function generateBrowserIcons( + assets = fileURLToPath(new URL("../../packages/theme/assets/", import.meta.url)), +) { + for (const size of [32, 128, 180]) { + await sharp(`${assets}/devfeed-mark.png`) + .resize(size, size) + .png({ compressionLevel: 9, palette: true, colours: 128 }) + .toFile(`${assets}/devfeed-icon-${size}.png`); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await generateBrowserIcons(); } From 19e737e53d835566a14f8da58be58ab9fae1f1cd Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Sun, 4 Oct 2026 18:19:27 +0530 Subject: [PATCH 3/3] Include icon generator in reader build context --- apps/web/Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 581a6b7f4..6c1f3b3fc 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -17,6 +17,7 @@ COPY packages/telemetry packages/telemetry COPY apps/web apps/web # Reader type checks include the shared extension analytics regression test. COPY apps/extensions apps/extensions +COPY scripts/assets/browser-icons.mjs scripts/assets/browser-icons.mjs RUN mkdir -p apps/web/public && npm run web:build && node packages/telemetry/scripts/private-sourcemaps.mjs apps/web/.next FROM ${DOCKERHUB_PREFIX}alpine:3.24@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b AS runtime