From 6e6001da46aa06fcdd3abd72c042a23d1938170c Mon Sep 17 00:00:00 2001 From: BrandonML Date: Fri, 11 Sep 2026 10:47:28 -0400 Subject: [PATCH 01/16] perf(newtab): use a Set for seen-id lookup in refresh() Array.includes() inside a .filter() is O(N*M); a Set makes each membership check O(1). Bounded today by the 100-card cap, but free and zero-risk. Co-Authored-By: Claude Sonnet 5 --- extension/newtab.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/extension/newtab.js b/extension/newtab.js index 50348fe..de8f343 100644 --- a/extension/newtab.js +++ b/extension/newtab.js @@ -300,7 +300,8 @@ async function refresh(location, locationLabel) { // Only the *seen* cards are dropped on a refresh — whatever the user // hasn't looked at yet survives and is topped up with new cards below, // rather than being discarded wholesale. - const keptUnseenCards = isRepeatLocation ? feedCache.cards.filter((card) => !priorSeenIds.includes(card.id)) : []; + const priorSeenIdSet = new Set(priorSeenIds); + const keptUnseenCards = isRepeatLocation ? feedCache.cards.filter((card) => !priorSeenIdSet.has(card.id)) : []; let page = isRepeatLocation ? (feedCache.page || 1) + 1 : 1; let feed = await fetchCatsPage(location, page); let mergedCards = mergeCards(keptUnseenCards, feed.cards || [], priorSeenIds); From cc82823b03e4533b2b04e0fa6a2a1afbacc3d5f0 Mon Sep 17 00:00:00 2001 From: BrandonML Date: Fri, 11 Sep 2026 10:47:59 -0400 Subject: [PATCH 02/16] refactor(server): extract getBestPicture out of normalizeCards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure readability change, no behavior difference — normalizeCards() already handles relationships, timestamps, fee normalization, and URL handling, so pulling picture selection into its own function keeps the main per-animal loop easier to follow. Co-Authored-By: Claude Sonnet 5 --- server/rescuegroups.js | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/server/rescuegroups.js b/server/rescuegroups.js index 1a3ac6f..25869ba 100644 --- a/server/rescuegroups.js +++ b/server/rescuegroups.js @@ -98,22 +98,25 @@ function relationshipResources(relationship, type, index) { return ids.map((item) => index.get(`${type}:${item.id}`)).filter(Boolean); } +// Pictures are pre-sorted by `order`; returns the first one whose large/ +// original URL actually normalizes to something usable, or null if none do. +function getBestPicture(pictures) { + for (const item of pictures.map((p) => p.attributes || {})) { + const imageUrl = normalizeUrl(item.large?.url || item.original?.url); + if (imageUrl) return { picture: item, imageUrl }; + } + return null; +} + export function normalizeCards(payload) { const index = includedIndex(payload.included); return (payload.data || []).map((animal) => { const relationships = animal.relationships || {}; const pictures = relationshipResources(relationships.pictures, "pictures", index) .sort((a, b) => (a.attributes?.order ?? Number.MAX_SAFE_INTEGER) - (b.attributes?.order ?? Number.MAX_SAFE_INTEGER)); - let picture, imageUrl; - for (const item of pictures.map((p) => p.attributes || {})) { - const rawImg = item.large?.url || item.original?.url; - imageUrl = normalizeUrl(rawImg); - if (imageUrl) { - picture = item; - break; - } - } - if (!picture) return null; + const best = getBestPicture(pictures); + if (!best) return null; + const { picture, imageUrl } = best; const org = relationshipResources(relationships.orgs, "orgs", index)[0]; const attrs = animal.attributes || {}; const updatedAt = newestTimestamp(attrs.updatedDate, attrs.updatedAt); From 57b676ed71d82f2322f28b70c757c173df815e54 Mon Sep 17 00:00:00 2001 From: BrandonML Date: Fri, 11 Sep 2026 10:48:29 -0400 Subject: [PATCH 03/16] refactor(options): add $ DOM-lookup helper, matching newtab.js newtab.js already defines const \$ = (id) => document.getElementById(id) and uses it throughout; options.js had 5 separate getElementById calls instead. Purely additive, no behavior change. Co-Authored-By: Claude Sonnet 5 --- extension/options.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/extension/options.js b/extension/options.js index b7948ab..dfe1be5 100644 --- a/extension/options.js +++ b/extension/options.js @@ -2,11 +2,12 @@ import { classifyRefreshError } from "./error-messages.js"; import { BACKEND_URL } from "./config.js"; import { locationFromBrowser } from "./location.js"; -const form = document.getElementById("settings-form"); -const zip = document.getElementById("zip"); -const useLocation = document.getElementById("use-location"); -const saved = document.getElementById("saved"); -const closeSettings = document.getElementById("close-settings"); +const $ = (id) => document.getElementById(id); +const form = $("settings-form"); +const zip = $("zip"); +const useLocation = $("use-location"); +const saved = $("saved"); +const closeSettings = $("close-settings"); // Same reasoning as newtab.js's showNotice(): #saved reserves visible box // space (min-height + flex, via the shared .notice class) even with no From 0d7d79bbc6890221c3c49af16b6811c73cef8955 Mon Sep 17 00:00:00 2001 From: BrandonML Date: Fri, 11 Sep 2026 10:51:34 -0400 Subject: [PATCH 04/16] feat(newtab): add report-issue link to generic refresh failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit showNotice() already supported an extensible {linkText, linkAction} pattern (used for "open-settings"); adds a "report-issue" action that opens the GitHub issues page. Wired into the non-ZIP branch of _start()'s refresh-failure handler only — a ZIP-validation failure already carries enough detail to know it's a user issue, so it keeps the existing settings-shortcut link instead (per GH issue #26). Also exports isInvalidZipError() from error-messages.js so the caller can make that branch decision without re-deriving classifyRefreshError's internal classification. Test harness fix: newtab.test.js/options.test.js inline error-messages.js into a plain + + + diff --git a/cws/screenshot-frame.source.html b/cws/screenshot-frame.source.html new file mode 100644 index 0000000..aef7035 --- /dev/null +++ b/cws/screenshot-frame.source.html @@ -0,0 +1,200 @@ + + + + + +Tabby screenshot frame + + + + + +
+
+
+

Tabby

+
+ + +
+
+ + + + + +
+
+ + + + From d1f5b660430f3727221466995f969133b7dc64e7 Mon Sep 17 00:00:00 2001 From: BrandonML Date: Fri, 11 Sep 2026 12:46:35 -0400 Subject: [PATCH 12/16] incriment version number --- manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifest.json b/manifest.json index 20f38e6..f076cf2 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Tabby: New Tab for Adoptable Cats", - "version": "1.1.0", + "version": "2.0.0", "description": "See one real, nearby adoptable cat on every new tab.", "permissions": [ "storage", From 604195205d23aba757865a080fa24f11a06b3be2 Mon Sep 17 00:00:00 2001 From: BrandonML Date: Fri, 11 Sep 2026 16:08:48 -0400 Subject: [PATCH 13/16] Fix detached-looking notice link and add explore link to no-results notice The "no results" notice's link rendered as a separate flex item with a fixed gap from the message text (`.notice { display:flex; gap:6px }` treated the trailing text node and the button as sibling row items), so it looked like a disconnected chip rather than part of the sentence. The message text also duplicated "zip code" once as plain text and once as the link label. Wrap the message and any links in one `.notice-body` span so they flow as normal wrapped text, and let a message with multiple links (a "{token}" placeholder per link) splice each button in at its exact spot instead of only ever appending after the message. The two "no cats found" messages now also offer an "explore another city" link alongside "zip code", reusing the existing explore flow via a new shared startExplore() helper instead of duplicating it. Fixes #29. Co-Authored-By: Claude Sonnet 5 --- extension/newtab.css | 4 ++ extension/newtab.js | 93 +++++++++++++++++++++++++++++++------------- test/newtab.test.js | 74 +++++++++++++++++++++++++++++++---- 3 files changed, 136 insertions(+), 35 deletions(-) diff --git a/extension/newtab.css b/extension/newtab.css index 241334c..d47eb02 100644 --- a/extension/newtab.css +++ b/extension/newtab.css @@ -199,6 +199,10 @@ input { display: block; width: 100%; padding: 10px; margin-top: 4px; border: 1px } .notice.notice-error { color: var(--stamp); } .notice a { color: inherit; } +/* Message text plus any links wrap together as one run of text -- see + buildNoticeLinkButton()/showNotice() in newtab.js for why this can't just + be a flex item like the icon (GitHub issue #29). */ +.notice-body { flex: 1 1 auto; min-width: 0; } .notice-link { color: var(--moss-dark); font-weight: 600; text-decoration: underline; text-underline-offset: 2px; background: none; border: none; padding: 0; cursor: pointer; font: inherit; } .notice-link:hover { background: none; } diff --git a/extension/newtab.js b/extension/newtab.js index 45ff7f0..b512562 100644 --- a/extension/newtab.js +++ b/extension/newtab.js @@ -30,6 +30,11 @@ const PORTRAIT_ANALYSIS_MIN_CONFIDENCE = 1.1; const PORTRAIT_ANALYSIS_TIMEOUT_MS = 5000; let inFlight = null; const $ = (id) => document.getElementById(id); +const ZIP_SETTINGS_LINK = { text: "zip code", action: "open-settings" }; +const NO_RESULTS_LINKS = [ + { ...ZIP_SETTINGS_LINK, token: "zip" }, + { text: "explore another city", action: "start-explore", token: "explore" } +]; // Well-known US metro coordinates, chosen for broad RescueGroups coverage. // Not individually spot-checked against the live API — a location with no @@ -77,7 +82,28 @@ function showExploreBanner(label) { function hideExploreBanner() { $("explore-banner").hidden = true; } -function showNotice(message, { linkText = null, linkAction = null, type = "info" } = {}) { +function buildNoticeLinkButton(link) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "notice-link"; + button.dataset.action = link.action; + button.textContent = link.text; + button.addEventListener("click", (event) => { + event.preventDefault(); + if (link.action === "open-settings") openSettings(); + else if (link.action === "report-issue") window.open("https://github.com/BrandonML/tabby/issues", "_blank", "noopener,noreferrer"); + else if (link.action === "start-explore") startExplore(); + }); + return button; +} + +// `links` is `[{ text, action, token? }]`. When a link has a `token` and the +// message contains a matching `{token}`, the button is spliced in at that +// exact spot (needed for a message with more than one link, e.g. "...try a +// different {zip} or {explore}."). Otherwise every link is appended after +// the message in order -- the common single-link case, unchanged from +// before this supported multiple links. +function showNotice(message, { links = [], type = "info" } = {}) { const notice = $("notice"); if (!notice) return; @@ -96,26 +122,37 @@ function showNotice(message, { linkText = null, linkAction = null, type = "info" notice.appendChild(icon); } - if (linkText && linkAction) { - notice.appendChild(document.createTextNode(`${message} `)); - - const button = document.createElement("button"); - button.type = "button"; - button.className = "notice-link"; - button.dataset.action = linkAction; - button.textContent = linkText; - - button.addEventListener("click", (event) => { - event.preventDefault(); - if (linkAction === "open-settings") openSettings(); - else if (linkAction === "report-issue") window.open("https://github.com/BrandonML/tabby/issues", "_blank", "noopener,noreferrer"); + // The message and any links live in one wrapping element so they flow as + // normal text -- a link mid-sentence, wrapping with the words around it -- + // instead of `.notice`'s flex layout treating each one as its own row item + // with a fixed gap, which is what made a trailing link look like a + // detached chip rather than part of the sentence (GitHub issue #29). + const body = document.createElement("span"); + body.className = "notice-body"; + + const hasMatchingToken = links.some((link) => link.token && message.includes(`{${link.token}}`)); + if (links.length > 0 && hasMatchingToken) { + const tokenPattern = /\{(\w+)\}/g; + let lastIndex = 0; + let match; + while ((match = tokenPattern.exec(message))) { + if (match.index > lastIndex) body.appendChild(document.createTextNode(message.slice(lastIndex, match.index))); + const link = links.find((l) => l.token === match[1]); + body.appendChild(link ? buildNoticeLinkButton(link) : document.createTextNode(match[0])); + lastIndex = tokenPattern.lastIndex; + } + if (lastIndex < message.length) body.appendChild(document.createTextNode(message.slice(lastIndex))); + } else if (links.length > 0) { + body.appendChild(document.createTextNode(`${message} `)); + links.forEach((link, i) => { + body.appendChild(buildNoticeLinkButton(link)); + if (i < links.length - 1) body.appendChild(document.createTextNode(" ")); }); - - notice.appendChild(button); - return; + } else { + body.appendChild(document.createTextNode(message)); } - notice.appendChild(document.createTextNode(message)); + notice.appendChild(body); } function readingFormat(value) { if (!value) return ""; @@ -426,7 +463,7 @@ async function refresh(location, locationLabel) { if (!mergedCards.length) { setCardVisible(false); $("location-panel").hidden = true; - showNotice(`No available cats were found within ${nextCache.radiusMiles} miles. Try using a different zip code instead.`, { linkText: "zip code", linkAction: "open-settings", type: "error" }); + showNotice(`No available cats were found within ${nextCache.radiusMiles} miles. Try using a different {zip} or {explore}.`, { links: NO_RESULTS_LINKS, type: "error" }); return; } // Every card in mergedCards is guaranteed unseen (seen ones were dropped, @@ -456,13 +493,13 @@ async function _start({ requestLocation = false } = {}) { await storageSet({ feedCache: { ...feedCache, seenIds: nextSeenIds } }); renderCard(selected, { stale: shouldRefresh, locationLabel }); } else if (feedCache && !feedCache.cards?.length) { - showNotice(`No available cats were found within ${feedCache.radiusMiles || 0} miles. Try using a different zip code instead.`, { linkText: "zip code", linkAction: "open-settings", type: "error" }); + showNotice(`No available cats were found within ${feedCache.radiusMiles || 0} miles. Try using a different {zip} or {explore}.`, { links: NO_RESULTS_LINKS, type: "error" }); } const location = await resolveLocation(resolvedSettings, requestLocation); if (!location) { $("location-panel").hidden = false; if (requestLocation) { - showNotice("Unable to determine your location. Try entering a zip code instead.", { linkText: "zip code", linkAction: "open-settings", type: "error" }); + showNotice("Unable to determine your location. Try entering a {zip} instead.", { links: [{ ...ZIP_SETTINGS_LINK, token: "zip" }], type: "error" }); } return; } @@ -475,8 +512,8 @@ async function _start({ requestLocation = false } = {}) { // a user issue, so it keeps the settings shortcut instead — the // report-issue link is for failures that might actually be our bug. const noticeOptions = isInvalidZipError(error.message) - ? { linkText: "zip code", linkAction: "open-settings", type: "error" } - : { linkText: "Report an issue", linkAction: "report-issue", type: "error" }; + ? { links: [ZIP_SETTINGS_LINK], type: "error" } + : { links: [{ text: "Report an issue", action: "report-issue" }], type: "error" }; showNotice(finalMessage, noticeOptions); if (!feedCache?.cards?.length) $("location-panel").hidden = false; } @@ -528,6 +565,11 @@ async function exploreArea() { } } +async function startExplore() { + showNotice("Exploring a new area…"); + await exploreArea(); +} + function showAnotherExploreCard() { if (!exploreBatch) return; const { selected, nextSeenIds } = nextCard(exploreBatch.cards, exploreBatch.seenIds); @@ -553,10 +595,7 @@ $("use-location").addEventListener("click", async () => { await start({ requestLocation: true }); }); $("open-settings").addEventListener("click", openSettings); -$("explore").addEventListener("click", async () => { - showNotice("Exploring a new area…"); - await exploreArea(); -}); +$("explore").addEventListener("click", startExplore); $("show-another-explore-cat").addEventListener("click", () => showAnotherExploreCard()); $("back-to-my-area").addEventListener("click", async () => { exploreBatch = null; diff --git a/test/newtab.test.js b/test/newtab.test.js index 45511f3..f4260d7 100644 --- a/test/newtab.test.js +++ b/test/newtab.test.js @@ -53,32 +53,59 @@ describe('newtab.js DOM manipulation', () => { it('showNotice creates safe DOM elements without innerHTML', () => { // Call showNotice on the window context - window.showNotice("Hello World", { linkText: "Click Me", linkAction: "open-settings" }); + window.showNotice("Hello World", { links: [{ text: "Click Me", action: "open-settings" }] }); const notice = document.getElementById("notice"); - assert.equal(notice.childNodes.length, 2); + assert.equal(notice.childNodes.length, 1); + const body = notice.childNodes[0]; + assert.equal(body.className, "notice-body"); + assert.equal(body.childNodes.length, 2); - const textNode = notice.childNodes[0]; + const textNode = body.childNodes[0]; assert.equal(textNode.nodeType, 3); // TEXT_NODE assert.equal(textNode.textContent, "Hello World "); - const button = notice.childNodes[1]; + const button = body.childNodes[1]; assert.equal(button.tagName, "BUTTON"); assert.equal(button.textContent, "Click Me"); assert.equal(button.className, "notice-link"); assert.equal(button.dataset.action, "open-settings"); // Attempt an XSS - window.showNotice("", { linkText: "", linkAction: '">XSS' }); - assert.equal(notice.childNodes[0].textContent, " "); - assert.equal(notice.childNodes[1].textContent, ""); - assert.equal(notice.childNodes[1].dataset.action, '\">XSS'); + window.showNotice("", { links: [{ text: "", action: '">XSS' }] }); + const body2 = notice.childNodes[0]; + assert.equal(body2.childNodes[0].textContent, " "); + assert.equal(body2.childNodes[1].textContent, ""); + assert.equal(body2.childNodes[1].dataset.action, '\">XSS'); // Ensure no HTML elements were created by accident assert.equal(notice.querySelector('img'), null); assert.equal(notice.querySelector('script'), null); }); + it('showNotice splices multiple links inline via {token} placeholders', () => { + window.showNotice("Try a different {zip} or {explore}.", { + links: [ + { token: "zip", text: "zip code", action: "open-settings" }, + { token: "explore", text: "explore another city", action: "start-explore" } + ], + type: "error" + }); + + const notice = document.getElementById("notice"); + const body = notice.querySelector('.notice-body'); + const links = body.querySelectorAll('.notice-link'); + assert.equal(links.length, 2); + assert.equal(links[0].textContent, "zip code"); + assert.equal(links[0].dataset.action, "open-settings"); + assert.equal(links[1].textContent, "explore another city"); + assert.equal(links[1].dataset.action, "start-explore"); + // The full sentence -- including the surrounding words -- must survive + // as one continuous run of text with the links spliced in place, not + // just the two link labels floating with the rest of the text dropped. + assert.equal(body.textContent, "Try a different zip code or explore another city."); + }); + it('renderCard creates safe DOM elements without innerHTML', () => { const cardData = { name: "", @@ -548,6 +575,37 @@ describe('newtab.js DOM manipulation', () => { assert.ok(notice.classList.contains('notice-error'), "an empty-results notice needs the user to act, so it should read as an error"); }); + it('offers both a zip-code and an explore-another-city link on empty results (GitHub issue #29)', async () => { + window.fetch = async () => ({ + ok: true, + json: async () => ({ cards: [], radiusMiles: 5 }) + }); + + await window.refresh({ postalcode: '12345' }); + const notice = document.getElementById("notice"); + const links = notice.querySelectorAll('.notice-link'); + assert.equal(links.length, 2); + assert.equal(links[0].textContent, 'zip code'); + assert.equal(links[0].dataset.action, 'open-settings'); + assert.equal(links[1].textContent, 'explore another city'); + assert.equal(links[1].dataset.action, 'start-explore'); + // The links must read as part of one flowing sentence, not floating + // text disconnected from the surrounding message. + assert.equal(notice.querySelector('.notice-body').textContent, 'No available cats were found within 5 miles. Try using a different zip code or explore another city.'); + + let fetchedBody; + window.fetch = async (url, opts) => { + fetchedBody = JSON.parse(opts.body); + return { ok: true, json: async () => ({ cards: [{ id: 'explore-1', name: 'ExploreCat' }], radiusMiles: 25 }) }; + }; + links[1].dispatchEvent(new window.Event('click')); + await new Promise(r => setTimeout(r, 10)); + + assert.ok(fetchedBody, 'clicking "explore another city" should trigger the same explore fetch as the header Explore button'); + assert.equal(document.getElementById('card').querySelector('h1').textContent, 'ExploreCat'); + assert.equal(document.getElementById('explore-banner').hidden, false); + }); + it('resets to page 1 when a later page comes back exhausted and empty', async () => { window.chrome.storage.local.get = async () => ({ feedCache: { cards: [{ id: '1' }], fetchedAt: Date.now(), location: { postalcode: '12345' }, page: 4, seenIds: ['1'] } From 0c4c3c67cbfd2b97766df9f5439f950e66553b09 Mon Sep 17 00:00:00 2001 From: BrandonML Date: Fri, 11 Sep 2026 16:16:07 -0400 Subject: [PATCH 14/16] Add "Share this cat" via the Web Share API Adds a "Share {name}" button to the card that calls navigator.share() with the cat's details, a Tabby tagline + Chrome Web Store link, and its RescueGroups profile URL. The photo is attached as a real file when the platform supports it (confirmed live on Chrome desktop from inside the actual newtab.html origin: canShare({ files }) === true). RescueGroups' CDN has no CORS headers, so the photo can't be fetched client-side any more than it could be drawn to canvas (see the content-aware-crop comment in server/index.js) -- a second hostname-locked proxy, GET /api/photo-share, fetches it server-side at a real, presentable 640px width (vs. the existing /api/photo-thumb's 100px analysis-only size, too small to actually look like a photo in a share sheet) and streams it back with CORS headers. Both proxies now share one buildPhotoProxyUrl/sendPhotoProxy implementation. Degrades in two steps when the ideal path isn't available: link+text share without the photo if canShare rejects it or the proxy fetch fails, then a clipboard-copy fallback if navigator.share itself fails or isn't present at all (the button doesn't render in that last case). Closes #27. Co-Authored-By: Claude Sonnet 5 --- README.md | 1 + extension/newtab.css | 15 ++++- extension/newtab.js | 95 +++++++++++++++++++++++++++++--- server/index.js | 38 ++++++++++--- test/newtab.test.js | 115 ++++++++++++++++++++++++++++++++++++++ test/photo-share.test.js | 116 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 362 insertions(+), 18 deletions(-) create mode 100644 test/photo-share.test.js diff --git a/README.md b/README.md index 99a087c..448c3a8 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Tabby is a Manifest V3 Chrome extension that replaces the new tab page with a ne - Server-side 25 -> 75 -> 150 -> 250 mile radius ladder, escalating on cumulative deduplicated results until 40 unique cats are found. - RescueGroups `available/cats/haspic` query, nearest-first sorting, picture validation, organization join, and safe profile-url fallback. - Content-aware crop for portrait photos: a row-wise edge-energy heuristic finds the likely subject band instead of always anchoring to the top, via a small hostname-locked analysis-thumbnail proxy (`GET /api/photo-thumb`) that works around RescueGroups' CDN sending no CORS headers. +- "Share this cat" via the Web Share API, attaching the actual photo (fetched through a second hostname-locked, share-sized proxy, `GET /api/photo-share`, for the same CORS reason as the crop analysis above) alongside the cat's details, a Tabby tagline, and its RescueGroups profile link. Degrades to a link-only native share if the photo can't be attached, and to a clipboard-copy if the platform has no Web Share API at all. - No third-party runtime dependencies; Node's built-in test runner. ## Run locally diff --git a/extension/newtab.css b/extension/newtab.css index 241334c..7b67890 100644 --- a/extension/newtab.css +++ b/extension/newtab.css @@ -138,12 +138,14 @@ h1 { margin: 0; font-weight: 700; color: var(--ink); } .rescue { font-size: 0.8125rem; font-weight: 400; color: var(--ink); margin: 0 0 1.375rem; } .rescue a { color: var(--moss-dark); text-decoration: underline; text-decoration-thickness: 1.5px; text-underline-offset: 2px; } +.card-actions { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; } + .profile { display: flex; align-items: center; justify-content: center; + flex: 1 1 auto; width: min(100%, 220px); - margin: 0 auto; text-decoration: none; background: var(--moss); color: var(--paper); @@ -154,6 +156,17 @@ h1 { margin: 0; font-weight: 700; color: var(--ink); } } .profile:hover { background: var(--moss-dark); } +.share { + flex: 1 1 auto; + width: min(100%, 220px); + background: transparent; + color: var(--moss-dark); + border: 1.5px solid var(--moss); + padding: 10.5px 16px; + font-size: 0.95rem; +} +.share:hover { background: var(--moss); color: var(--paper); } + @media (max-width: 360px) { .photo { width: calc(100% - 32px); margin: 16px 16px 0; } .content { padding: 14px 16px 18px; } diff --git a/extension/newtab.js b/extension/newtab.js index 45ff7f0..5945a13 100644 --- a/extension/newtab.js +++ b/extension/newtab.js @@ -28,6 +28,9 @@ const TALL_PORTRAIT_HEIGHT_RATIO = 1.35; // signal (a flat/textureless photo scores at or near 1.0). const PORTRAIT_ANALYSIS_MIN_CONFIDENCE = 1.1; const PORTRAIT_ANALYSIS_TIMEOUT_MS = 5000; +const PHOTO_SHARE_TIMEOUT_MS = 6000; +const TABBY_CWS_URL = "https://chromewebstore.google.com/detail/tabby-new-tab-for-adoptab/elfpnkoboidkgahmoggodpnmekfodcig"; +const TABBY_TAGLINE = "Meet an adoptable cat every time you open a new tab."; let inFlight = null; const $ = (id) => document.getElementById(id); @@ -341,14 +344,31 @@ function renderCard(card, { stale = false, exploreLabel = null, locationLabel = } content.appendChild(rescueP); - if (profileUrl) { - const profileA = document.createElement("a"); - profileA.className = "profile"; - profileA.href = profileUrl; - profileA.target = "_blank"; - profileA.rel = "noreferrer"; - profileA.textContent = "View profile"; - content.appendChild(profileA); + const shareUrl = profileUrl || rescueUrl; + if (profileUrl || shareUrl) { + const actions = document.createElement("div"); + actions.className = "card-actions"; + + if (profileUrl) { + const profileA = document.createElement("a"); + profileA.className = "profile"; + profileA.href = profileUrl; + profileA.target = "_blank"; + profileA.rel = "noreferrer"; + profileA.textContent = "View profile"; + actions.appendChild(profileA); + } + + if (shareUrl && typeof navigator.share === "function") { + const shareButton = document.createElement("button"); + shareButton.type = "button"; + shareButton.className = "share"; + shareButton.textContent = `Share ${card.name}`; + shareButton.addEventListener("click", () => shareCard(card, shareUrl)); + actions.appendChild(shareButton); + } + + content.appendChild(actions); } cardContainer.appendChild(content); @@ -356,6 +376,65 @@ function renderCard(card, { stale = false, exploreLabel = null, locationLabel = showNotice(stale ? "Showing a recent saved match while we refresh." : ""); } +function buildShareText(card) { + const meta = [card.breed, card.age, card.sex].filter(Boolean).join(", "); + const intro = meta ? `${card.name} (${meta}) is looking for a home at ${card.rescueName}.` : `${card.name} is looking for a home at ${card.rescueName}.`; + return `${intro}\n\n${TABBY_TAGLINE} Get Tabby: ${TABBY_CWS_URL}`; +} + +const IMAGE_CONTENT_TYPE_EXTENSIONS = { "image/jpeg": "jpg", "image/png": "png", "image/webp": "webp", "image/gif": "gif" }; + +// RescueGroups' CDN has no CORS headers (see applyContentAwareCrop's comment +// above), so a client-side fetch of the photo itself would be opaque/blocked +// the same way a canvas read would be -- routing through our own +// /api/photo-share proxy (CORS-safe, hostname-locked, forced to a +// share-appropriate size server-side) is what makes a real File object +// obtainable here at all. +async function fetchSharePhoto(imageUrl) { + const backendUrl = BACKEND_URL.replace(/\/$/, ""); + const response = await fetch(`${backendUrl}/api/photo-share?url=${encodeURIComponent(imageUrl)}`, { signal: AbortSignal.timeout(PHOTO_SHARE_TIMEOUT_MS) }); + if (!response.ok) throw new Error("Could not fetch photo for sharing."); + const blob = await response.blob(); + const extension = IMAGE_CONTENT_TYPE_EXTENSIONS[blob.type] || "jpg"; + return new File([blob], `cat.${extension}`, { type: blob.type || "image/jpeg" }); +} + +async function copyShareTextFallback(text, url) { + try { + await navigator.clipboard.writeText(`${text}\n${url}`); + showNotice("Copied to clipboard."); + } catch (error) { + console.error("[tabby]", error); + showNotice("Unable to share right now.", { type: "error" }); + } +} + +// Tries to attach the actual photo (issue #27 calls this the most important +// part of the share), then degrades in two steps if that's not possible: +// first to a link-only native share, then -- if navigator.share itself +// fails or was never available -- to copying the details to the clipboard. +async function shareCard(card, shareUrl) { + const text = buildShareText(card); + const shareData = { title: `Meet ${card.name}`, text, url: shareUrl }; + + try { + const photoFile = await fetchSharePhoto(card.imageUrl); + if (navigator.canShare?.({ files: [photoFile] })) { + shareData.files = [photoFile]; + } + } catch (error) { + console.error("[tabby]", error); // Photo unavailable -- share the link and text without it. + } + + try { + await navigator.share(shareData); + } catch (error) { + if (error?.name === "AbortError") return; // The user closed the share sheet -- not a failure. + console.error("[tabby]", error); + await copyShareTextFallback(text, shareUrl); + } +} + async function resolveLocation(settings, promptForLocation) { const savedLocation = settings?.location; if (savedLocation && Number.isFinite(savedLocation.lat) && Number.isFinite(savedLocation.lon)) { diff --git a/server/index.js b/server/index.js index f96d040..2f446bb 100644 --- a/server/index.js +++ b/server/index.js @@ -208,8 +208,14 @@ async function bodyOf(request) { const PHOTO_THUMB_ALLOWED_HOST = "cdn.rescuegroups.org"; const PHOTO_THUMB_WIDTH = 100; const PHOTO_THUMB_MAX_BYTES = 200 * 1024; // generous for a ~100px-wide jpeg - -export function buildPhotoThumbUrl(rawUrl) { +// A shared photo (GitHub issue #27) is actually looked at, unlike the +// analysis-only thumbnail above -- 100px would render as a blurry postage +// stamp in a share sheet. 640px matches a typical social-preview image size; +// the byte cap is scaled up to match a jpeg at that size with headroom. +const PHOTO_SHARE_WIDTH = 640; +const PHOTO_SHARE_MAX_BYTES = 700 * 1024; + +function buildPhotoProxyUrl(rawUrl, width) { let parsed; try { parsed = new URL(String(rawUrl)); @@ -219,14 +225,23 @@ export function buildPhotoThumbUrl(rawUrl) { if (parsed.protocol !== "https:" || parsed.hostname !== PHOTO_THUMB_ALLOWED_HOST) return null; // Discard any caller-supplied query (including a caller-supplied width) // before enforcing our own -- the whole point is that this can only ever - // request a small image, never the real one. + // request a fixed, known-safe image size, never whatever the caller asks + // for, so this can't become a general-purpose open proxy. parsed.search = ""; - parsed.searchParams.set("width", String(PHOTO_THUMB_WIDTH)); + parsed.searchParams.set("width", String(width)); return parsed.toString(); } -async function sendPhotoThumb(response, requestOrigin, rawUrl) { - const upstreamUrl = buildPhotoThumbUrl(rawUrl); +export function buildPhotoThumbUrl(rawUrl) { + return buildPhotoProxyUrl(rawUrl, PHOTO_THUMB_WIDTH); +} + +export function buildPhotoShareUrl(rawUrl) { + return buildPhotoProxyUrl(rawUrl, PHOTO_SHARE_WIDTH); +} + +async function sendPhotoProxy(response, requestOrigin, rawUrl, buildUrl, maxBytes) { + const upstreamUrl = buildUrl(rawUrl); if (!upstreamUrl) return send(response, 400, { error: "Invalid photo URL." }, requestOrigin); try { @@ -241,7 +256,7 @@ async function sendPhotoThumb(response, requestOrigin, rawUrl) { // than streaming with a running byte-counter) matches that existing // trust level instead of adding a second, inconsistent defense here. const buffer = Buffer.from(await upstreamResponse.arrayBuffer()); - if (buffer.length > PHOTO_THUMB_MAX_BYTES) return send(response, 502, { error: "Photo too large." }, requestOrigin); + if (buffer.length > maxBytes) return send(response, 502, { error: "Photo too large." }, requestOrigin); response.writeHead(200, { "Content-Type": contentType, @@ -252,7 +267,7 @@ async function sendPhotoThumb(response, requestOrigin, rawUrl) { }); response.end(buffer); } catch (error) { - console.error("[tabby-server] photo-thumb proxy failed", { message: error.message }); + console.error("[tabby-server] photo proxy failed", { message: error.message }); return send(response, 502, { error: "Unable to fetch photo." }, requestOrigin); } } @@ -276,7 +291,12 @@ export const server = createServer(async (request, response) => { if (request.url.startsWith("/api/photo-thumb")) { if (request.method !== "GET") return send(response, 405, { error: "Method Not Allowed" }, origin, { "Allow": "GET" }); const requestUrl = new URL(request.url, "http://internal"); - return sendPhotoThumb(response, origin, requestUrl.searchParams.get("url") || ""); + return sendPhotoProxy(response, origin, requestUrl.searchParams.get("url") || "", buildPhotoThumbUrl, PHOTO_THUMB_MAX_BYTES); + } + if (request.url.startsWith("/api/photo-share")) { + if (request.method !== "GET") return send(response, 405, { error: "Method Not Allowed" }, origin, { "Allow": "GET" }); + const requestUrl = new URL(request.url, "http://internal"); + return sendPhotoProxy(response, origin, requestUrl.searchParams.get("url") || "", buildPhotoShareUrl, PHOTO_SHARE_MAX_BYTES); } if (request.url !== "/api/nearby-cats") return send(response, 404, { error: "Not found" }, origin); if (request.method !== "POST") return send(response, 405, { error: "Method Not Allowed" }, origin, { "Allow": "POST" }); diff --git a/test/newtab.test.js b/test/newtab.test.js index 45511f3..f952489 100644 --- a/test/newtab.test.js +++ b/test/newtab.test.js @@ -136,6 +136,121 @@ describe('newtab.js DOM manipulation', () => { assert.equal(h1.classList.contains('name-long'), false); }); }); + describe('Share this cat (GitHub issue #27)', () => { + const shareCardData = { + name: "Luna", + breed: "Tabby", + age: "Adult", + sex: "Female", + rescueName: "Happy Paws Rescue", + rescueUrl: "https://rescue.org", + profileUrl: "https://rescuegroups.org/animals/luna", + imageUrl: "https://cdn.rescuegroups.org/pic.jpg" + }; + + it('does not render a share button when the platform has no Web Share API', () => { + delete window.navigator.share; + window.renderCard(shareCardData); + assert.equal(document.querySelector('#card .share'), null); + }); + + it('renders a "Share {name}" button when navigator.share is available', () => { + window.navigator.share = async () => {}; + window.renderCard(shareCardData); + const shareButton = document.querySelector('#card .share'); + assert.ok(shareButton); + assert.equal(shareButton.textContent, 'Share Luna'); + assert.equal(shareButton.tagName, 'BUTTON'); + }); + + it('shares the photo, text and profile link together when the platform supports file attachments', async () => { + window.navigator.canShare = () => true; + let sharedData; + window.navigator.share = async (data) => { sharedData = data; }; + window.fetch = async (url) => { + assert.ok(url.includes('/api/photo-share?url='), 'must fetch through the CORS-safe photo-share proxy, not the CDN directly'); + return { ok: true, blob: async () => new window.Blob(["fake-photo-bytes"], { type: "image/jpeg" }) }; + }; + + window.renderCard(shareCardData); + document.querySelector('#card .share').dispatchEvent(new window.Event('click')); + await new Promise(r => setTimeout(r, 10)); + + assert.ok(sharedData, 'navigator.share should have been called'); + assert.equal(sharedData.title, 'Meet Luna'); + assert.equal(sharedData.url, 'https://rescuegroups.org/animals/luna'); + assert.ok(sharedData.text.includes('Luna')); + assert.ok(sharedData.text.includes('Happy Paws Rescue')); + assert.ok(sharedData.text.includes('Meet an adoptable cat every time you open a new tab.'), 'must include the Tabby tagline'); + assert.ok(sharedData.text.includes('chromewebstore.google.com'), 'must promote the Tabby listing'); + assert.equal(sharedData.files.length, 1); + assert.ok(sharedData.files[0] instanceof window.File); + assert.equal(sharedData.files[0].type, 'image/jpeg'); + }); + + it('shares without a photo file when canShare rejects file attachments', async () => { + window.navigator.canShare = () => false; + let sharedData; + window.navigator.share = async (data) => { sharedData = data; }; + window.fetch = async () => ({ ok: true, blob: async () => new window.Blob(["x"], { type: "image/jpeg" }) }); + + window.renderCard(shareCardData); + document.querySelector('#card .share').dispatchEvent(new window.Event('click')); + await new Promise(r => setTimeout(r, 10)); + + assert.ok(sharedData); + assert.equal(sharedData.files, undefined); + assert.equal(sharedData.url, 'https://rescuegroups.org/animals/luna'); + }); + + it('shares without a photo file when the photo-share proxy fetch fails', async () => { + let sharedData; + window.navigator.canShare = () => true; + window.navigator.share = async (data) => { sharedData = data; }; + window.fetch = async () => ({ ok: false }); + window.console.error = () => {}; + + window.renderCard(shareCardData); + document.querySelector('#card .share').dispatchEvent(new window.Event('click')); + await new Promise(r => setTimeout(r, 10)); + + assert.ok(sharedData, 'a failed photo fetch should not block sharing the link and text'); + assert.equal(sharedData.files, undefined); + }); + + it('treats the user cancelling the native share sheet as a no-op, not an error', async () => { + window.navigator.canShare = () => true; + window.navigator.share = async () => { const err = new Error('cancelled'); err.name = 'AbortError'; throw err; }; + window.navigator.clipboard = { writeText: async () => { throw new Error('should not be called'); } }; + window.fetch = async () => ({ ok: true, blob: async () => new window.Blob(["x"], { type: "image/jpeg" }) }); + const loggedErrors = []; + window.console.error = (...args) => { loggedErrors.push(args); }; + + window.renderCard(shareCardData); + document.querySelector('#card .share').dispatchEvent(new window.Event('click')); + await new Promise(r => setTimeout(r, 10)); + + assert.equal(loggedErrors.length, 0, 'a user-cancelled share should not be logged as an error'); + assert.equal(document.getElementById('notice').textContent, ''); + }); + + it('falls back to copying the details to the clipboard when navigator.share fails for a real reason', async () => { + window.navigator.canShare = () => true; + window.navigator.share = async () => { throw new Error('share failed'); }; + let clipboardText; + window.navigator.clipboard = { writeText: async (text) => { clipboardText = text; } }; + window.fetch = async () => ({ ok: true, blob: async () => new window.Blob(["x"], { type: "image/jpeg" }) }); + window.console.error = () => {}; + + window.renderCard(shareCardData); + document.querySelector('#card .share').dispatchEvent(new window.Event('click')); + await new Promise(r => setTimeout(r, 10)); + + assert.ok(clipboardText.includes('Luna')); + assert.ok(clipboardText.includes('https://rescuegroups.org/animals/luna')); + assert.ok(document.getElementById('notice').textContent.includes('Copied to clipboard')); + }); + }); describe('showNotice error vs. informational tone', () => { it('defaults to informational: no notice-error class, no warning icon', () => { window.showNotice("Finding your location…"); diff --git a/test/photo-share.test.js b/test/photo-share.test.js new file mode 100644 index 0000000..f009d75 --- /dev/null +++ b/test/photo-share.test.js @@ -0,0 +1,116 @@ +import { describe, it, beforeEach, afterEach, mock } from "node:test"; +import assert from "node:assert"; +import http from "node:http"; +import { server, buildPhotoShareUrl } from "../server/index.js"; + +describe("buildPhotoShareUrl", () => { + it("forces a share-sized width on a valid RescueGroups CDN URL", () => { + const result = buildPhotoShareUrl("https://cdn.rescuegroups.org/1409/pictures/animals/22664/22664830/103576412.jpg"); + assert.strictEqual(result, "https://cdn.rescuegroups.org/1409/pictures/animals/22664/22664830/103576412.jpg?width=640"); + }); + + it("discards a caller-supplied width/query instead of trusting it", () => { + const result = buildPhotoShareUrl("https://cdn.rescuegroups.org/pic.jpg?width=5000&foo=bar"); + assert.strictEqual(result, "https://cdn.rescuegroups.org/pic.jpg?width=640"); + }); + + it("rejects a URL on a different host (would otherwise be an open proxy)", () => { + assert.strictEqual(buildPhotoShareUrl("https://evil.example.com/pic.jpg"), null); + }); + + it("rejects a non-https URL", () => { + assert.strictEqual(buildPhotoShareUrl("http://cdn.rescuegroups.org/pic.jpg"), null); + }); + + it("rejects a malformed URL", () => { + assert.strictEqual(buildPhotoShareUrl("not a url"), null); + assert.strictEqual(buildPhotoShareUrl(""), null); + assert.strictEqual(buildPhotoShareUrl(undefined), null); + }); +}); + +describe("GET /api/photo-share", () => { + let port; + + beforeEach(async () => { + await new Promise((resolve) => server.listen(0, resolve)); + port = server.address().port; + }); + + afterEach(async () => { + await new Promise((resolve) => server.close(resolve)); + mock.restoreAll(); + }); + + const get = (path) => { + return new Promise((resolve, reject) => { + const req = http.request({ hostname: "127.0.0.1", port, path, method: "GET" }, (res) => { + const chunks = []; + res.on("data", (chunk) => chunks.push(chunk)); + res.on("end", () => resolve({ res, body: Buffer.concat(chunks) })); + }); + req.on("error", reject); + req.end(); + }); + }; + + it("streams back the upstream image with CORS headers on a valid request", async () => { + const fakeImage = Buffer.from("fake-jpeg-bytes-but-bigger-for-sharing"); + let requestedUrl; + mock.method(global, "fetch", async (url) => { + requestedUrl = url; + return { + ok: true, + headers: new Map([["content-type", "image/jpeg"]]), + arrayBuffer: async () => fakeImage.buffer.slice(fakeImage.byteOffset, fakeImage.byteOffset + fakeImage.byteLength) + }; + }); + + const url = encodeURIComponent("https://cdn.rescuegroups.org/pic.jpg"); + const { res, body } = await get(`/api/photo-share?url=${url}`); + + assert.strictEqual(res.statusCode, 200); + assert.strictEqual(res.headers["content-type"], "image/jpeg"); + assert.strictEqual(res.headers["access-control-allow-origin"], "*"); + assert.deepStrictEqual(body, fakeImage); + assert.strictEqual(requestedUrl, "https://cdn.rescuegroups.org/pic.jpg?width=640", "must request the share width, not the analysis thumbnail width"); + }); + + it("returns 400 for a disallowed host without ever calling fetch", async () => { + const fetchSpy = mock.method(global, "fetch", async () => { throw new Error("should not be called"); }); + + const url = encodeURIComponent("https://evil.example.com/pic.jpg"); + const { res, body } = await get(`/api/photo-share?url=${url}`); + + assert.strictEqual(res.statusCode, 400); + assert.strictEqual(fetchSpy.mock.callCount(), 0); + assert.strictEqual(JSON.parse(body.toString()).error, "Invalid photo URL."); + }); + + it("returns 502 when the upstream image exceeds the share size cap", async () => { + const bigImage = Buffer.alloc(800 * 1024, 1); + mock.method(global, "fetch", async () => ({ + ok: true, + headers: new Map([["content-type", "image/jpeg"]]), + arrayBuffer: async () => bigImage.buffer.slice(bigImage.byteOffset, bigImage.byteOffset + bigImage.byteLength) + })); + + const url = encodeURIComponent("https://cdn.rescuegroups.org/pic.jpg"); + const { res } = await get(`/api/photo-share?url=${url}`); + + assert.strictEqual(res.statusCode, 502); + }); + + it("405s a non-GET request", async () => { + const { res } = await new Promise((resolve, reject) => { + const req = http.request({ hostname: "127.0.0.1", port, path: "/api/photo-share?url=x", method: "POST" }, (resp) => { + resp.resume(); + resp.on("end", () => resolve({ res: resp })); + }); + req.on("error", reject); + req.end(); + }); + assert.strictEqual(res.statusCode, 405); + assert.strictEqual(res.headers.allow, "GET"); + }); +}); From 984ed27e99e28f04a2267039263184ad3ed2f123 Mon Sep 17 00:00:00 2001 From: BrandonML Date: Fri, 11 Sep 2026 17:06:08 -0400 Subject: [PATCH 15/16] Automate CI, release tagging, and deploy verification Adds three GitHub Actions workflows to close the gaps found in a pre-release audit: no CI gate before merge, no git tags for any shipped version, and no automated confirmation that a Northflank deploy actually landed before submitting to CWS/EWS. - ci.yml: npm test on push/PR to main and dev, pinned to Node 22 to match the Dockerfile. - tag-release.yml: tags main `v` (from manifest.json) on every push, idempotent. - deploy-verify.yml: on a push to main touching server/**/Dockerfile, polls the live /healthz until it reports the pushed commit's sha, then smoke-tests /api/nearby-cats, /api/photo-thumb, and /api/photo-share against production. /healthz now reports Northflank's auto-injected NF_DEPLOYMENT_SHA runtime env var (confirmed present on the live service) so deploy-verify.yml can tell "the new code is live" from "a process is answering," rather than relying on a version bump that might not happen for every server change. Also, from the same audit: - Dockerfile: copies package.json into the image so ESM import/export in server/*.js resolves via the standard package.json "type" field instead of relying on Node 22's module-syntax auto-detection, which only works because no package.json was present at all -- a base image downgrade would have silently broken this. - README: corrected a stale claim that NODE_ENV would select between a "production" and "dev/sandbox" RescueGroups endpoint -- no such distinction exists in server/rescuegroups.js, there's only one BASE_URL used everywhere. Documented the new workflows and the /healthz sha field. - extension/newtab.js: the "Share this cat" promo link now checks for Edge's user agent and can point at a separate Edge Add-ons URL instead of always linking to the Chrome Web Store (which Edge blocks one-click installs from by default). Currently still falls back to the CWS link either way -- the Edge listing isn't public yet, so there's no real URL to point at (see the TODO in the code). Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci.yml | 30 +++++++++++ .github/workflows/deploy-verify.yml | 84 +++++++++++++++++++++++++++++ .github/workflows/tag-release.yml | 38 +++++++++++++ Dockerfile | 8 +++ README.md | 14 ++++- extension/newtab.js | 16 +++++- server/index.js | 7 ++- test/newtab.test.js | 21 ++++++++ test/server.test.js | 18 +++++++ 9 files changed, 232 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/deploy-verify.yml create mode 100644 .github/workflows/tag-release.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fee2812 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # Matches the Dockerfile's node:22-alpine so CI runs the same Node + # version the server actually deploys on. + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - run: npm ci + - run: npm test + + # The live RescueGroups integration test hits the real API and needs a + # real key -- it's excluded from CI on purpose (see README "Run the + # live RescueGroups integration test..."), same as it's excluded from + # `npm test` locally. Run `npm run test:live` manually before merging + # any change to search radius, pagination, or the RescueGroups query + # contract. diff --git a/.github/workflows/deploy-verify.yml b/.github/workflows/deploy-verify.yml new file mode 100644 index 0000000..304068e --- /dev/null +++ b/.github/workflows/deploy-verify.yml @@ -0,0 +1,84 @@ +name: Verify production deploy + +# Northflank's own build trigger on the `tabby` service fires independently +# on push to main (see buildConfiguration.pathIgnoreRules there) -- this +# workflow doesn't control or trigger that build, it just confirms it +# actually landed. Runs in parallel with Northflank's build, so it has to +# wait/poll rather than assume the deploy is already done by the time this +# job starts. +on: + push: + branches: [main] + +env: + PROD_URL: https://p01--tabby--bklqdgzwx4md.code.run + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check whether this push touches a deployed path + id: changed + run: | + # Mirrors the tabby service's own buildConfiguration allow-list + # (isAllowList: true, pathIgnoreRules: ["server/**", "Dockerfile"]) + # -- if Northflank wouldn't rebuild for this push, waiting for the + # live sha to change would just time out for no reason. + if git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" | grep -qE '^(server/|Dockerfile$)'; then + echo "deploy=true" >> "$GITHUB_OUTPUT" + else + echo "deploy=false" >> "$GITHUB_OUTPUT" + echo "No server/** or Dockerfile changes in this push -- Northflank will not rebuild, skipping verification." + fi + + - name: Wait for the live deployment to report this commit + if: steps.changed.outputs.deploy == 'true' + run: | + set -e + EXPECTED="${{ github.sha }}" + echo "Waiting for $PROD_URL/healthz to report sha=$EXPECTED ..." + for i in $(seq 1 40); do + ACTUAL=$(curl -fsS "$PROD_URL/healthz" 2>/dev/null | jq -r '.sha // empty' || echo "") + if [ "$ACTUAL" = "$EXPECTED" ]; then + echo "Live deployment matches ($ACTUAL) after $((i * 15))s." + exit 0 + fi + echo " [$i/40] still on $ACTUAL, retrying in 15s..." + sleep 15 + done + echo "::error::Timed out after 10 minutes waiting for $PROD_URL to report sha=$EXPECTED (last seen: $ACTUAL). Check the tabby service's build/deploy status on Northflank before proceeding to store submission." + exit 1 + + - name: Smoke test the live API + if: steps.changed.outputs.deploy == 'true' + run: | + set -e + + echo "-- /healthz --" + curl -fsS "$PROD_URL/healthz" | jq . + + echo "-- POST /api/nearby-cats --" + NEARBY=$(curl -fsS -X POST "$PROD_URL/api/nearby-cats" \ + -H "Content-Type: application/json" \ + -d '{"location":{"postalcode":"10001"},"page":1}') + echo "$NEARBY" | jq '{cardCount: (.cards | length), radiusMiles}' + CARD_COUNT=$(echo "$NEARBY" | jq '.cards | length') + if [ "$CARD_COUNT" -lt 1 ]; then + echo "::error::/api/nearby-cats returned zero cards for a known-good ZIP -- possible RescueGroups outage or regression, not necessarily a bad deploy." + exit 1 + fi + IMAGE_URL=$(echo "$NEARBY" | jq -r '.cards[0].imageUrl') + + echo "-- GET /api/photo-thumb --" + curl -fsS -o /dev/null -w "status=%{http_code} size=%{size_download}\n" \ + "$PROD_URL/api/photo-thumb?url=$(node -e "console.log(encodeURIComponent(process.argv[1]))" "$IMAGE_URL")" + + echo "-- GET /api/photo-share --" + curl -fsS -o /dev/null -w "status=%{http_code} size=%{size_download}\n" \ + "$PROD_URL/api/photo-share?url=$(node -e "console.log(encodeURIComponent(process.argv[1]))" "$IMAGE_URL")" + + echo "All smoke checks passed -- safe to proceed with store submission." diff --git a/.github/workflows/tag-release.yml b/.github/workflows/tag-release.yml new file mode 100644 index 0000000..a8d7732 --- /dev/null +++ b/.github/workflows/tag-release.yml @@ -0,0 +1,38 @@ +name: Tag release + +# manifest.json and package.json are always bumped together by +# scripts/release.js, so manifest.json's version is the single source of +# truth for "what version just landed on main." Runs on every push to main +# and no-ops if that version is already tagged, so it's safe to fire on +# every merge rather than needing to detect "was this a release commit." +on: + push: + branches: [main] + +permissions: + contents: write + +jobs: + tag: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Create and push a version tag if one doesn't already exist + run: | + set -e + VERSION=$(node -p "require('./manifest.json').version") + TAG="v$VERSION" + + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "Tag $TAG already exists -- nothing to do." + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "$TAG" -m "Release $TAG" + git push origin "$TAG" + echo "Created and pushed $TAG." diff --git a/Dockerfile b/Dockerfile index 2c833ef..07eec78 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,13 @@ FROM node:22-alpine WORKDIR /app +# package.json declares "type": "module" -- server/index.js and +# server/rescuegroups.js rely on that for import/export syntax to parse as +# ESM. Node 22 happens to auto-detect module syntax when no package.json is +# present at all, which is why this worked before without it, but that's an +# implicit, version-specific fallback -- copying the real package.json makes +# module resolution explicit and correct regardless of the Node version this +# image is ever rebased to. +COPY package.json ./package.json COPY server ./server EXPOSE 8787 CMD ["node", "server/index.js"] diff --git a/README.md b/README.md index 448c3a8..36458c9 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Deploying the server is a separate step from packaging the extension; whatever h - `RG_API_KEY` — set in the platform's secret manager, never committed. - `ALLOW_ORIGIN` — the installed extension's exact `chrome-extension://` origin, not `*`. Each store (Chrome Web Store, Edge Add-ons, ...) assigns its own extension ID even for an identical package, so once the extension is published to more than one store this needs a comma-separated list of every store's origin, e.g. `chrome-extension://elfpnkoboidkgahmoggodpnmekfodcig,chrome-extension://fieeoalehgckgnkohkdblljmgaemaiho` (Edge extensions also use the `chrome-extension://` scheme, not `edge-extension://`). The server reflects back whichever of these matches the incoming request's `Origin` header; an origin not in the list gets refused by the browser. This is only knowable once the extension has been uploaded to each store's dashboard at least once (that's what assigns the permanent ID), so it's normal to deploy once with `ALLOW_ORIGIN` unset (permissive, with a logged warning) and circle back to lock it down afterward — and to append to the list, rather than replace it, each time the extension is published to a new store. -- `NODE_ENV=production` — currently gates stack-trace logging; a follow-up change will also use it to select the production RescueGroups API endpoint instead of the dev/sandbox one. +- `NODE_ENV=production` — gates stack-trace logging in error responses. RescueGroups has only one API endpoint (`server/rescuegroups.js`'s `BASE_URL`); local dev, tests, and production all call the same live API, there is no separate dev/sandbox endpoint to select between. The in-memory cache is correct as-is for the intended deployment target: a single persistent Node process (for example Render, Railway, Fly.io, or Northflank). It would need to be replaced with a shared cache (for example KV/Redis) only if the server is ever scaled to multiple concurrent instances, or moved to a serverless/edge platform (Vercel functions, Cloudflare Workers) where in-process state isn't reliably shared or persistent between requests — those platforms would also require restructuring `server/index.js` away from its current `node:http` `createServer` model. @@ -48,7 +48,17 @@ Once the server has a real HTTPS URL, package the extension with: npm.cmd run release 1.0.0 https://your-deployed-backend.example.com ``` -This bumps `manifest.json`/`package.json` to the given version and zips `manifest.json` + `extension/` for the Chrome Web Store — it does not touch `server/` or its hosting environment. The `` argument is required (must be `https://`) and is only baked into the **staged copy that gets zipped** — `extension/config.js` in the repo itself is never modified, so it always stays at `http://localhost:8787` for local dev and the test suite (which asserts requests go to `localhost:8787`). There's no manual edit-then-revert step needed for a release. +This bumps `manifest.json`/`package.json` to the given version and zips `manifest.json` + `extension/` for the Chrome Web Store — it does not touch `server/` or its hosting environment. The `` argument is required (must be `https://`) and is only baked into the **staged copy that gets zipped** — `extension/config.js` in the repo itself is never modified, so it always stays at `http://localhost:8787` for local dev and the test suite (which asserts requests go to `localhost:8787`). There's no manual edit-then-revert step needed for a release. The same zip is submitted to both the Chrome Web Store and Edge Add-ons — Edge is Chromium-based and needs no code changes. + +## CI/CD + +Three GitHub Actions workflows automate the release process end to end: + +- **`ci.yml`** — runs `npm test` on every push and pull request to `main`/`dev`, pinned to Node 22 to match the Dockerfile. Configure this as a **required status check** on `main`'s branch protection rule (Settings → Branches) so a PR can't merge with failing tests — the workflow alone doesn't block a merge, only branch protection does. +- **`tag-release.yml`** — on every push to `main`, tags the commit `v` (read from `manifest.json`) if that tag doesn't already exist. Idempotent, so it's safe to fire on every push rather than needing to detect "was this actually a release." +- **`deploy-verify.yml`** — on every push to `main` that touches `server/**` or `Dockerfile` (mirroring the `tabby` service's own Northflank build trigger), polls the live `/healthz` endpoint until its `sha` field (see below) matches the pushed commit, then smoke-tests `/api/nearby-cats`, `/api/photo-thumb`, and `/api/photo-share` against production. A **green run is the signal that it's safe to build and submit the release to CWS/EWS** — since Northflank deploys in minutes and store review takes hours, the server is always live and correct well before any user's browser updates to a new extension version, as long as server changes stay additive/backward-compatible with whatever extension version is still in the wild. + +`/healthz` reports `{ status: "ok", sha }`, where `sha` is Northflank's auto-injected `NF_DEPLOYMENT_SHA` runtime env var (the exact git commit of the running build) — `null` locally, where that variable is never set. This is what lets `deploy-verify.yml` confirm the *new* code is actually live, not just that some process answered the health check. To manually smoke-test the unpacked extension against a real deployed backend (as opposed to packaging a release), temporarily edit `BACKEND_URL` in `extension/config.js` yourself, reload the unpacked extension, test, then revert the edit (`git checkout -- extension/config.js`) before committing anything or running the test suite. diff --git a/extension/newtab.js b/extension/newtab.js index 6218b3c..219accc 100644 --- a/extension/newtab.js +++ b/extension/newtab.js @@ -30,7 +30,21 @@ const PORTRAIT_ANALYSIS_MIN_CONFIDENCE = 1.1; const PORTRAIT_ANALYSIS_TIMEOUT_MS = 5000; const PHOTO_SHARE_TIMEOUT_MS = 6000; const TABBY_CWS_URL = "https://chromewebstore.google.com/detail/tabby-new-tab-for-adoptab/elfpnkoboidkgahmoggodpnmekfodcig"; +// TODO: replace with the real Edge Add-ons listing URL once it's live and +// public (the store id 0RDCK9VTFG8C does not yet resolve to a working +// listing as of this writing -- still in review). Falls back to the Chrome +// Web Store link rather than a broken/placeholder URL in the meantime. +const TABBY_EDGE_URL = TABBY_CWS_URL; const TABBY_TAGLINE = "Meet an adoptable cat every time you open a new tab."; + +// Chromium-based Edge identifies itself with "Edg/" in its user agent (not +// "Edge/", which was the older, pre-Chromium EdgeHTML browser) -- checked +// ahead of the generic case since Edge's UA also contains "Chrome/". An +// Edge user sharing a cat should link to the Edge Add-ons listing, since +// Edge blocks one-click installs from the Chrome Web Store by default. +function tabbyStoreUrl() { + return navigator.userAgent.includes("Edg/") ? TABBY_EDGE_URL : TABBY_CWS_URL; +} let inFlight = null; const $ = (id) => document.getElementById(id); const ZIP_SETTINGS_LINK = { text: "zip code", action: "open-settings" }; @@ -416,7 +430,7 @@ function renderCard(card, { stale = false, exploreLabel = null, locationLabel = function buildShareText(card) { const meta = [card.breed, card.age, card.sex].filter(Boolean).join(", "); const intro = meta ? `${card.name} (${meta}) is looking for a home at ${card.rescueName}.` : `${card.name} is looking for a home at ${card.rescueName}.`; - return `${intro}\n\n${TABBY_TAGLINE} Get Tabby: ${TABBY_CWS_URL}`; + return `${intro}\n\n${TABBY_TAGLINE} Get Tabby: ${tabbyStoreUrl()}`; } const IMAGE_CONTENT_TYPE_EXTENSIONS = { "image/jpeg": "jpg", "image/png": "png", "image/webp": "webp", "image/gif": "gif" }; diff --git a/server/index.js b/server/index.js index 2f446bb..723f111 100644 --- a/server/index.js +++ b/server/index.js @@ -286,7 +286,12 @@ export const server = createServer(async (request, response) => { if (request.method === "OPTIONS") return send(response, 204, {}, origin); if (request.url === "/healthz") { if (request.method !== "GET") return send(response, 405, { error: "Method Not Allowed" }, origin, { "Allow": "GET" }); - return send(response, 200, { status: "ok" }, origin); + // NF_DEPLOYMENT_SHA is auto-injected by Northflank at runtime (the git + // commit hash of the running build) -- exposing it here lets automated + // post-deploy checks confirm the *new* code is actually live, rather + // than just that some process is answering on the port. null locally, + // where the env var is never set. + return send(response, 200, { status: "ok", sha: process.env.NF_DEPLOYMENT_SHA || null }, origin); } if (request.url.startsWith("/api/photo-thumb")) { if (request.method !== "GET") return send(response, 405, { error: "Method Not Allowed" }, origin, { "Allow": "GET" }); diff --git a/test/newtab.test.js b/test/newtab.test.js index 0fee1ab..d0a4456 100644 --- a/test/newtab.test.js +++ b/test/newtab.test.js @@ -215,6 +215,27 @@ describe('newtab.js DOM manipulation', () => { assert.equal(sharedData.files[0].type, 'image/jpeg'); }); + it('promotes the Edge Add-ons listing instead of the Chrome Web Store when running in Edge', async () => { + Object.defineProperty(window.navigator, 'userAgent', { + value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0', + configurable: true + }); + window.navigator.canShare = () => true; + let sharedData; + window.navigator.share = async (data) => { sharedData = data; }; + window.fetch = async () => ({ ok: true, blob: async () => new window.Blob(["x"], { type: "image/jpeg" }) }); + + window.renderCard(shareCardData); + document.querySelector('#card .share').dispatchEvent(new window.Event('click')); + await new Promise(r => setTimeout(r, 10)); + + assert.ok(sharedData); + // TODO: once TABBY_EDGE_URL points at the real Edge Add-ons listing + // (see the TODO comment in newtab.js), this should assert on the + // *Edge* URL instead of falling back to the CWS one. + assert.ok(sharedData.text.includes('chromewebstore.google.com'), 'currently falls back to the CWS link until the Edge listing is live'); + }); + it('shares without a photo file when canShare rejects file attachments', async () => { window.navigator.canShare = () => false; let sharedData; diff --git a/test/server.test.js b/test/server.test.js index 0e49e36..7ed174e 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -214,6 +214,24 @@ describe("server routing and behavior", () => { assert.strictEqual(body.status, "ok"); }); + it("GET /healthz reports null sha when NF_DEPLOYMENT_SHA is unset, as in local dev", async () => { + delete process.env.NF_DEPLOYMENT_SHA; + const { data } = await request({ path: '/healthz', method: 'GET' }); + const body = JSON.parse(data); + assert.strictEqual(body.sha, null); + }); + + it("GET /healthz reports NF_DEPLOYMENT_SHA when Northflank has injected it, so a post-deploy check can confirm the new code is live", async () => { + process.env.NF_DEPLOYMENT_SHA = "abc1234"; + try { + const { data } = await request({ path: '/healthz', method: 'GET' }); + const body = JSON.parse(data); + assert.strictEqual(body.sha, "abc1234"); + } finally { + delete process.env.NF_DEPLOYMENT_SHA; + } + }); + it("POST /healthz returns 405 with an Allow header", async () => { const { res, data } = await request({ path: '/healthz', method: 'POST' }); assert.strictEqual(res.statusCode, 405); From 1a66b54d969a87c3b3c5091a28f24df56d9d8406 Mon Sep 17 00:00:00 2001 From: BrandonML Date: Fri, 11 Sep 2026 17:24:45 -0400 Subject: [PATCH 16/16] Wire in the real Edge Add-ons share link, add a committed release runbook The Edge Add-ons listing (https://microsoftedge.microsoft.com/addons/detail/fieeoalehgckgnkohkdblljmgaemaiho) is live -- confirmed by fetching it -- so the Share feature's Edge-vs-Chrome link now actually differs instead of always falling back to the Chrome Web Store URL. RELEASE.md is the versioned source of truth for the dev-to-main release steps (kept in the repo so it stays in sync with the actual scripts/workflows via normal PRs, rather than drifting in a separately-hosted doc); README links to it from the CI/CD section. Co-Authored-By: Claude Sonnet 5 --- README.md | 4 ++-- RELEASE.md | 29 +++++++++++++++++++++++++++++ extension/newtab.js | 6 +----- test/newtab.test.js | 6 ++---- 4 files changed, 34 insertions(+), 11 deletions(-) create mode 100644 RELEASE.md diff --git a/README.md b/README.md index 36458c9..2a4ef11 100644 --- a/README.md +++ b/README.md @@ -52,9 +52,9 @@ This bumps `manifest.json`/`package.json` to the given version and zips `manifes ## CI/CD -Three GitHub Actions workflows automate the release process end to end: +Three GitHub Actions workflows automate the release process end to end — see [RELEASE.md](RELEASE.md) for the step-by-step runbook: -- **`ci.yml`** — runs `npm test` on every push and pull request to `main`/`dev`, pinned to Node 22 to match the Dockerfile. Configure this as a **required status check** on `main`'s branch protection rule (Settings → Branches) so a PR can't merge with failing tests — the workflow alone doesn't block a merge, only branch protection does. +- **`ci.yml`** — runs `npm test` on every push and pull request to `main`/`dev`, pinned to Node 22 to match the Dockerfile. `main` has a ruleset (Settings → Rules → Rulesets) requiring this check to pass and requiring a pull request before merging — the workflow alone doesn't block anything, only the ruleset does. - **`tag-release.yml`** — on every push to `main`, tags the commit `v` (read from `manifest.json`) if that tag doesn't already exist. Idempotent, so it's safe to fire on every push rather than needing to detect "was this actually a release." - **`deploy-verify.yml`** — on every push to `main` that touches `server/**` or `Dockerfile` (mirroring the `tabby` service's own Northflank build trigger), polls the live `/healthz` endpoint until its `sha` field (see below) matches the pushed commit, then smoke-tests `/api/nearby-cats`, `/api/photo-thumb`, and `/api/photo-share` against production. A **green run is the signal that it's safe to build and submit the release to CWS/EWS** — since Northflank deploys in minutes and store review takes hours, the server is always live and correct well before any user's browser updates to a new extension version, as long as server changes stay additive/backward-compatible with whatever extension version is still in the wild. diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..c5e6bb0 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,29 @@ +# Release runbook + +Steps to ship a change from `dev` to `main`, which auto-deploys the server to Northflank, and on to the Chrome Web Store / Edge Add-ons. + +## 1. One-time setup (only needs doing once, ever) + +- [ ] `main` has a ruleset requiring the `test` status check (from `.github/workflows/ci.yml`) to pass before merging, and requiring changes to go through a pull request. See GitHub → repo → Settings → Rules → Rulesets. + +## 2. Pre-merge + +- [ ] Open a pull request from `dev` into `main` — don't merge locally and push. Going through a PR is what lets the ruleset above actually gate the merge on CI passing (a direct push of an untested commit is rejected outright, since it can never satisfy the required check). +- [ ] Wait for the `test` check to go green. If it's red, stop — do not merge. +- [ ] Merge the PR. + +## 3. Post-merge (fully automated — just watch) + +- [ ] `.github/workflows/tag-release.yml` tags the commit `v` (read from `manifest.json`). Check the repo's Tags page. +- [ ] `.github/workflows/deploy-verify.yml` polls the live server's `/healthz` until it reports this exact commit's SHA (`NF_DEPLOYMENT_SHA`, injected by Northflank), then smoke-tests `/api/nearby-cats`, `/api/photo-thumb`, and `/api/photo-share` against production. **A green run is the signal that it's safe to proceed to store submission.** If it goes red or times out, stop and check the `tabby` service's build/deploy status directly on Northflank before doing anything else. + +## 4. Store submission (manual — both stores require a human in their dashboard) + +- [ ] Build the release zip: `npm run release https://p01--tabby--bklqdgzwx4md.code.run` +- [ ] Upload `dist/v.zip` to the Chrome Web Store dashboard, submit for review. +- [ ] Upload the same zip to the Edge Add-ons dashboard, submit for review. +- [ ] Update listing copy/screenshots if the release changes what's visibly different to a user. + +## Why store review lag isn't a problem + +Northflank deploys in minutes; CWS/EWS review takes hours, and browsers don't auto-update installed extensions instantly even after approval — so there's always a window where some users are on an older extension version talking to the new server. This is safe as long as every server change is **additive**: never remove or change an existing endpoint/field an older client version depends on in the same release that ships a client relying on the removal. A new endpoint an old client never calls is always safe to ship ahead of the client that uses it. diff --git a/extension/newtab.js b/extension/newtab.js index 219accc..4ca07e3 100644 --- a/extension/newtab.js +++ b/extension/newtab.js @@ -30,11 +30,7 @@ const PORTRAIT_ANALYSIS_MIN_CONFIDENCE = 1.1; const PORTRAIT_ANALYSIS_TIMEOUT_MS = 5000; const PHOTO_SHARE_TIMEOUT_MS = 6000; const TABBY_CWS_URL = "https://chromewebstore.google.com/detail/tabby-new-tab-for-adoptab/elfpnkoboidkgahmoggodpnmekfodcig"; -// TODO: replace with the real Edge Add-ons listing URL once it's live and -// public (the store id 0RDCK9VTFG8C does not yet resolve to a working -// listing as of this writing -- still in review). Falls back to the Chrome -// Web Store link rather than a broken/placeholder URL in the meantime. -const TABBY_EDGE_URL = TABBY_CWS_URL; +const TABBY_EDGE_URL = "https://microsoftedge.microsoft.com/addons/detail/fieeoalehgckgnkohkdblljmgaemaiho"; const TABBY_TAGLINE = "Meet an adoptable cat every time you open a new tab."; // Chromium-based Edge identifies itself with "Edg/" in its user agent (not diff --git a/test/newtab.test.js b/test/newtab.test.js index d0a4456..84605e6 100644 --- a/test/newtab.test.js +++ b/test/newtab.test.js @@ -230,10 +230,8 @@ describe('newtab.js DOM manipulation', () => { await new Promise(r => setTimeout(r, 10)); assert.ok(sharedData); - // TODO: once TABBY_EDGE_URL points at the real Edge Add-ons listing - // (see the TODO comment in newtab.js), this should assert on the - // *Edge* URL instead of falling back to the CWS one. - assert.ok(sharedData.text.includes('chromewebstore.google.com'), 'currently falls back to the CWS link until the Edge listing is live'); + assert.ok(sharedData.text.includes('microsoftedge.microsoft.com/addons'), 'Edge users should get the Edge Add-ons link, not the CWS one'); + assert.ok(!sharedData.text.includes('chromewebstore.google.com'), 'must not also include the Chrome Web Store link'); }); it('shares without a photo file when canShare rejects file attachments', async () => {