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..c88a446fa 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 "../../../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)); @@ -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/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 32d7d4193..41784c16b 100644 --- a/apps/extensions/build.mjs +++ b/apps/extensions/build.mjs @@ -1,45 +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-mark.png`, `${output}/icon.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/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..0c0931740 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 "../../web/tests/browser/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/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 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/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/apps/web/tests/browser/article-first.mjs b/apps/web/tests/browser/article-first.mjs new file mode 100644 index 000000000..be0505013 --- /dev/null +++ b/apps/web/tests/browser/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(); + } +} diff --git a/apps/web/tests/browser/feed.mjs b/apps/web/tests/browser/feed.mjs index 5ce8aa524..a1a4083c0 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 "./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/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/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 000000000..202636afd Binary files /dev/null and b/packages/theme/assets/devfeed-icon-128.png differ diff --git a/packages/theme/assets/devfeed-icon-180.png b/packages/theme/assets/devfeed-icon-180.png new file mode 100644 index 000000000..4740a005e Binary files /dev/null and b/packages/theme/assets/devfeed-icon-180.png differ diff --git a/packages/theme/assets/devfeed-icon-32.png b/packages/theme/assets/devfeed-icon-32.png new file mode 100644 index 000000000..a04b554af Binary files /dev/null and b/packages/theme/assets/devfeed-icon-32.png differ 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..ba9298199 --- /dev/null +++ b/scripts/assets/browser-icons.mjs @@ -0,0 +1,17 @@ +import { fileURLToPath, pathToFileURL } from "node:url"; +import sharp from "sharp"; + +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(); +}