From 8379d2bad453da7d51000815ad0b0fd8540da98f Mon Sep 17 00:00:00 2001 From: Akash Goswami Date: Sun, 4 Oct 2026 13:42:52 +0200 Subject: [PATCH 1/2] Harden storage, anchoring, imports, and a11y; gate deploys on tests Correctness & data safety: - Persist deletions immediately (before the undo toast expires) so a reload in the undo window can no longer resurrect a deleted comment; Undo restores idempotently and only into the page it belongs to. - Surface storage failures: store.set reports success, an explicit banner/toast warns when comments cannot be persisted, and unsaved comments stay in memory and remain exportable. - Single schema gate for storage/import: reject unknown geometry kinds and comment types, id-less records, malformed anchors/replies, and oversized batches; dedupe within-batch and existing-project IDs. - SPA isolation: a non-explicit page key tracks location.pathname and recomputes only on refresh(), so routes keep separate comments. Anchoring: - Refuse to guess: highlight quotes that are lost or ambiguous (even accounting for prefix/suffix context) surface an explicit unanchored pill instead of highlighting an arbitrary copy. - Shapes/pins whose anchor element is gone show a distinct "anchor missing" badge instead of silently redrawing over . - Coalesced ResizeObserver/scroll relayout re-renders only when an anchor actually moved. Accessibility: - Markers are focusable role=button with aria-labels and open on Enter/Space; add :focus-visible outlines. Tooling: - Add Firefox/WebKit Playwright matrix workflow and gate Pages deploy on the chromium suite; switch demo server to `serve` on port 4200. - Ignore .env and opencode.json. --- .github/workflows/deploy-pages.yml | 17 + .github/workflows/tests.yml | 43 ++ .gitignore | 4 + README.md | 5 +- annotate.js | 342 ++++++++- package-lock.json | 1045 +++++++++++++++++++++++++++- package.json | 3 +- tests/annotate.spec.js | 800 +++++++++++++++++++-- 8 files changed, 2145 insertions(+), 114 deletions(-) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index 5bd605f..5ff22d6 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -17,7 +17,24 @@ concurrency: cancel-in-progress: true jobs: + # Hard gate: the site only deploys when the chromium suite is green. + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - name: Install dependencies + run: npm ci + - name: Install Playwright browsers + run: npx playwright install --with-deps + - name: Run tests + run: npx playwright test --project=chromium + deploy: + needs: test environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..a91f1d5 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,43 @@ +name: Tests + +on: + push: + branches: + - main + - improvement + pull_request: + workflow_dispatch: + +concurrency: + group: tests-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Playwright (${{ matrix.project }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # Desktop browsers are the compatibility gate (all three must pass). + # Mobile/tablet are best-effort and allowed to fail until they are + # stabilized. + project: [chromium, firefox, webkit] + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - name: Install dependencies + run: npm ci + - name: Install Playwright browsers + run: npx playwright install --with-deps + - name: Run tests + run: npx playwright test --project=${{ matrix.project }} + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-report-${{ matrix.project }} + path: playwright-report/ + retention-days: 14 diff --git a/.gitignore b/.gitignore index be1c39f..3dca38a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ node_modules/ .DS_Store *.log +.env +.env.* +!.env.example +opencode.json .cache/ dist/ playwright-report/ diff --git a/README.md b/README.md index 48cc49c..6c5446e 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,8 @@ That single line is the whole installation. import it with one click and see every note in place. - **Polished UI.** A floating toolbar, a Figma-style comments panel, light/dark themes that auto-adapt to your page, and full keyboard shortcuts. -- **Tiny & dependency-free.** ~40 KB of vanilla JavaScript, zero dependencies. +- **Tiny & dependency-free.** ~110 KB of vanilla JavaScript (unminified, zero + dependencies) — minify it for production to cut that to a few tens of KB. --- @@ -348,7 +349,7 @@ Annotate.version; // "1.2.0" ```bash git clone git@github.com:reviewjs/annotate.git cd annotate -npm start # serves the demo at http://localhost:3000 +npm start # serves the demo at http://localhost:4200 ``` Open [`index.html`](./index.html) and start annotating. Framework examples live diff --git a/annotate.js b/annotate.js index 981e0e9..2a72616 100644 --- a/annotate.js +++ b/annotate.js @@ -45,9 +45,10 @@ document.querySelector('script[src*="annotate"]'); var scriptData = (SCRIPT && SCRIPT.dataset) || {}; // data-* attributes var globalConfig = window.AnnotateConfig || {}; // window.AnnotateConfig + var explicitPage = scriptData.page || globalConfig.page || ""; var CFG = { project: scriptData.project || globalConfig.project || "", - page: scriptData.page || globalConfig.page || location.pathname, + page: explicitPage || location.pathname, accent: scriptData.accent || globalConfig.accent || "", theme: scriptData.theme || globalConfig.theme || "auto", position: scriptData.position || globalConfig.position || "bottom-right", @@ -56,18 +57,45 @@ note: scriptData.note || globalConfig.note || "", share: String(scriptData.shareEmail || globalConfig.shareEmail || "").trim(), }; - var PAGE = (CFG.project ? CFG.project + ":" : "") + CFG.page; - - // localStorage can be denied (private mode, sandboxed iframes) — never crash + // An explicit data-page / AnnotateConfig.page is fixed; otherwise the page key + // tracks the current pathname so history-based SPA routing stays isolated. + var pageExplicit = !!explicitPage; + function currentPageKey() { + if (!pageExplicit) CFG.page = location.pathname; + return (CFG.project ? CFG.project + ":" : "") + CFG.page; + } + var PAGE = currentPageKey(); + + // localStorage can be denied (private mode, sandboxed iframes) or fill up — + // never crash, and never pretend a failed write persisted. dbWrite reports + // success so callers can keep unsaved work in memory and surface the issue. var store = { get: function (k) { try { return localStorage.getItem(k); } catch (e) { return null; } }, + // Returns whether the write actually reached storage. Callers must not + // assume persistence on failure — that is what surfaces the unsaved banner. set: function (k, v) { - try { localStorage.setItem(k, v); } catch (e) { + try { localStorage.setItem(k, v); return true; } catch (e) { if (e && (e.name === "QuotaExceededError" || e.name === "NS_ERROR_DOM_QUOTA_REACHED")) setTimeout(function () { toast("Storage full — export your comments before adding more.", { kind: "error", duration: 8000 }); }, 0); + return false; } }, }; + var UNSAVED_MSG = "Changes may not be saved — this browser is refusing storage. Your comments are kept on screen and can still be downloaded as JSON."; + var quotaWarned = false; + // Persistent (not toast) warning for the failure modes that toasts can't + // express: storage denied outright, or writes silently failing. Visibility + // is derived from the write that just happened (`ok`), not a shared flag, so + // an unrelated failed preference write can't leave the banner stuck on. + function showUnsavedBanner(ok) { + if (!ok) { + if (!quotaWarned) { quotaWarned = true; toast("Storage is unavailable — your comments are kept on screen only.", { kind: "error", duration: 8000 }); } + if (!document.getElementById("__an_unsaved")) + document.body.appendChild(el("div", { id: "__an_unsaved", role: "alert", text: UNSAVED_MSG })); + } else if (document.getElementById("__an_unsaved")) { + document.getElementById("__an_unsaved").remove(); + } + } var COLORS = [ { name: "Amber", hex: "#f59e0b" }, { name: "Rose", hex: "#f43f5e" }, @@ -160,7 +188,11 @@ if (!Array.isArray(d.comments)) d.comments = []; return d; } - function dbWrite(d) { store.set(STORE_KEY, JSON.stringify(d)); } + function dbWrite(d) { + var ok = store.set(STORE_KEY, JSON.stringify(d)); + showUnsavedBanner(ok); + return ok; + } function uid() { if (window.crypto && crypto.getRandomValues) { var arr = new Uint32Array(3); @@ -189,7 +221,9 @@ createdAt: now, updatedAt: now, }; - d.comments.push(c); dbWrite(d); + d.comments.push(c); + dbWrite(d); // surfaces the banner if the write was refused; the caller + // keeps the comment in state so it stays visible & exportable return c; } function patchComment(id, changes) { @@ -208,7 +242,13 @@ c.replies = c.replies.filter(function (r) { return r.id !== changes.deleteReply; }); } c.updatedAt = new Date().toISOString(); - dbWrite(d); + if (!dbWrite(d)) { + // Mirror the change in memory so the UI stays consistent with what the + // user just did, even though it did not reach storage. + var live = state.comments.filter(function (x) { return x.id === id; })[0]; + if (live) mergeComment(c); + renderAll(); renderPanel(); + } return c; } function removeComment(id) { @@ -330,6 +370,9 @@ .an-pin span { transform: rotate(-45deg); } .an-pin:hover { transform: rotate(45deg) scale(1.14); } .an-pin.an-active { outline: 3px solid rgba(0,0,0,.18); } + .an-pin:focus-visible { outline: 3px solid #4f46e5; outline-offset: 2px; } + #__an_overlay [tabindex]:focus-visible, .an-block-tab:focus-visible { + outline: 3px solid #4f46e5; outline-offset: 2px; } .an-avatar { width:22px; height:22px; border-radius:50%; flex:none; display:inline-flex; align-items:center; justify-content:center; @@ -646,6 +689,22 @@ border-radius:6px; flex:none; } .an-toast .an-taction:hover { background:rgba(165,180,252,.14); } + /* ---- unanchored highlight pill ------------------------------------------ */ + .an-unanchored-pill { position:absolute; left:16px; z-index:2147483110; + display:flex; align-items:center; gap:7px; max-width:280px; + background:#78350f; color:#fde68a; font:500 11.5px/1.35 var(--an-font); + padding:7px 11px; border-radius:10px; box-shadow: var(--an-shadow-md); + border:1px solid rgba(255,255,255,.14); cursor:pointer; } + .an-unanchored-pill svg { width:14px; height:14px; flex:none; } + + /* ---- persistent "storage unavailable" banner --------------------------- */ + #__an_unsaved { position:fixed; top:14px; left:50%; transform:translateX(-50%); + z-index:2147483510; display:flex; align-items:center; gap:9px; + background:#7f1d1d; color:#ffe4e6; font:500 12.5px var(--an-font); + padding:10px 14px; border-radius:12px; box-shadow: var(--an-shadow-md); + border:1px solid rgba(255,255,255,.14); max-width:min(460px, 92vw); + animation: an-toast-in .25s cubic-bezier(.34,1.3,.64,1); } + /* ---- section (+) and margin bubbles ------------------------------------ */ #__an_plus { position:fixed; z-index:2147483120; width:30px; height:30px; border-radius:50%; background: var(--an-surface); @@ -953,15 +1012,100 @@ if (pinLayer) pinLayer.innerHTML = ""; } + // Anchor boxes at last render, so layout observers can tell whether a full + // re-render is actually needed (cheap to check, avoids re-render churn). + var anchorCache = {}; + function rememberAnchor(c, box) { + anchorCache[c.id] = { x: box.x, y: box.y, w: box.w, h: box.h }; + } + function anchorsMoved() { + for (var i = 0; i < state.comments.length; i++) { + var c = state.comments[i]; + if (c.resolved && !showResolvedVisuals()) continue; + if (c.type === "highlight" || !c.geom || !c.geom.selector) continue; // marks reflow with the DOM + var ae = resolveAnchorEl(c.geom.selector); + if (!ae) continue; + var prev = anchorCache[c.id]; + var b = docBox(ae); + if (!prev || Math.abs(prev.x - b.x) > 0.5 || Math.abs(prev.y - b.y) > 0.5 || + Math.abs(prev.w - b.w) > 0.5 || Math.abs(prev.h - b.h) > 0.5) return true; + } + return false; + } + function showResolvedVisuals() { return state.filter !== "open"; } + // Make a DOM or SVG marker keyboard-operable: focusable, announced by name, + // and activated with Enter / Space (mirrors the mouse click). Returns nothing. + function makeMarkerInteractive(node, c, idx, verb) { + node.setAttribute("role", "button"); + node.setAttribute("tabindex", "0"); + node.setAttribute("aria-label", (verb || "Annotation") + " comment #" + idx + + ": " + (c.text || "no text").slice(0, 120)); + node.addEventListener("keydown", function (ev) { + if (ev.key === "Enter" || ev.key === " " || ev.key === "Spacebar") { + ev.preventDefault(); ev.stopPropagation(); focusComment(c.id, true); + } + }); + } + + // Count full occurrences of a needle in the page text. + function countMatches(needle) { + var nodes = getTextNodes(), full = ""; + for (var i = 0; i < nodes.length; i++) full += nodes[i].nodeValue; + var count = 0, at = 0; + while (needle && (at = full.indexOf(needle, at)) >= 0) { count++; at += needle.length; } + return count; + } + + // Decide whether a saved quote can be highlighted honestly: + // - the saved prefix+exact+suffix context occurs exactly once, or + // - the context is gone but the exact text occurs exactly once + // (the rangeFromQuote fallback is then unambiguous), + // otherwise the anchor is ambiguous (2+ candidates) or lost (0). + function resolveHighlightAnchor(q) { + if (countMatches((q.prefix || "") + q.exact + (q.suffix || "")) === 1) return { status: "ok" }; + var exactCount = countMatches(q.exact); + if (exactCount === 1) return { status: "ok" }; + return { status: exactCount === 0 ? "lost" : "ambiguous" }; + } + + // Explicit "can't find your text" marker instead of a silent guess: shown + // when a highlight's quote has disappeared (0 matches) or been duplicated + // (2+ matches) since it was written. `slot` is the pre-computed stack index + // among unanchored comments (see renderAll) so positioning stays O(1). + function unanchoredPill(c, slot) { + var idx = state.comments.indexOf(c) + 1; + var pill = el("div", { class: "an-unanchored-pill", title: "The quoted text for comment #" + idx + + " can’t be found (or appears multiple times), so it isn’t highlighted. Open the comment for details." }, + [el("span", { html: ICONS.alert }), el("span", { text: "Moved or duplicated — #" + idx })]); + pill.style.top = (16 + slot * 34 + window.scrollY) + "px"; + pill.style.left = "16px"; + pill.style.pointerEvents = "auto"; + pill.addEventListener("click", function (ev) { ev.stopPropagation(); focusComment(c.id, true); }); + pinLayer.appendChild(pill); + } + function renderAll() { ensureOverlay(); clearVisuals(); + anchorCache = {}; sizeOverlay(); + // Running stack slots so the floating "unanchored" / "anchor missing" + // markers don't each scan the whole comment list to find their offset. + var unanchoredSlot = 0, missingSlot = 0; state.comments.forEach(function (c) { if (c.resolved && !showResolvedVisuals()) return; if (c.type === "highlight" && c.anchor) { + var status = resolveHighlightAnchor(c.anchor).status; + if (status !== "ok") { + // "lost" = the quoted text vanished; "ambiguous" = it can no longer + // identify one intended target. Surface it — never guess a copy. + c.__unanchored = status; + unanchoredPill(c, unanchoredSlot++); + return; + } + c.__unanchored = false; var r = rangeFromQuote(c.anchor); if (r) { var marks = paintRange(r, c.color, c.id); @@ -973,9 +1117,11 @@ }); } } else if (c.geom && (c.type === "shape" || c.type === "pen")) { - renderGeom(c); + renderGeom(c, missingSlot); + if (c.__anchorMissing) missingSlot++; } else if (c.type === "pin" && c.geom) { - renderPin(c); + renderPin(c, missingSlot); + if (c.__anchorMissing) missingSlot++; } else if (c.type === "block" && c.geom) { renderBlock(c); } @@ -987,6 +1133,7 @@ var anchorEl = c.geom.selector ? resolveAnchorEl(c.geom.selector) : null; if (!anchorEl) return; var box = docBox(anchorEl); + rememberAnchor(c, box); var idx = state.comments.indexOf(c) + 1; var tab = el("div", { class: "an-block-tab" + (c.id === state.activeId ? " an-active" : "") }, [ el("span", { html: ICONS.bubble }), el("span", { text: String(idx) }), @@ -999,16 +1146,24 @@ tab.style.top = (box.y + 2) + "px"; tab.title = c.text || ""; tab.addEventListener("click", function (ev) { ev.stopPropagation(); focusComment(c.id, true); }); + makeMarkerInteractive(tab, c, idx, "Section"); pinLayer.appendChild(tab); } function viewportMismatch(g) { return g && g.vw && Math.abs(g.vw - window.innerWidth) > 200; } - function renderGeom(c) { - var anchorEl = c.geom.selector ? resolveAnchorEl(c.geom.selector) : document.body; - if (!anchorEl) anchorEl = document.body; + function renderGeom(c, missingSlot) { + // A shape only makes sense relative to its anchor element. If that + // element no longer exists we must NOT silently redraw the shape over + // — that pretends the annotation still lives where it was meant + // to. Draw only when the anchor can be resolved; otherwise surface a + // floating "anchor missing" badge the user can open to reposition or delete. + var anchorEl = c.geom.selector ? resolveAnchorEl(c.geom.selector) : null; + if (!anchorEl) { c.__anchorMissing = true; renderMissingAnchor(c, missingSlot); return; } + c.__anchorMissing = false; var box = docBox(anchorEl); + rememberAnchor(c, box); var g = c.geom; var node; if (g.kind === "rect") { @@ -1059,13 +1214,41 @@ badge.style.cursor = "pointer"; badge.style.pointerEvents = "all"; badge.addEventListener("click", function (ev) { ev.stopPropagation(); focusComment(c.id, true); }); + makeMarkerInteractive(badge, c, idx, "Shape"); overlay.appendChild(badge); } - function renderPin(c) { - var anchorEl = c.geom.selector ? resolveAnchorEl(c.geom.selector) : document.body; + // Floating badge shown when a shape/pin anchor element no longer exists. + // Positioned at a stable page offset (never over , which would imply + // the annotation still has a valid position). + function renderMissingAnchor(c, missingSlot) { + var idx = state.comments.indexOf(c) + 1; + var badgeR = 13; + var bx = 40 + missingSlot * 36; + var by = 40 + window.scrollY; + var badge = svgEl("g", {}); + var circ = svgEl("circle", { cx: bx, cy: by, r: badgeR, fill: "#f59e0b", stroke: "#fff", "stroke-width": 2, class: "an-badge-circle" }); + var title = svgEl("title"); + title.textContent = "The element this comment points to no longer exists — it is not drawn in place."; + circ.setAttribute("title", "Anchor element missing"); + var txt = svgEl("text", { x: bx, y: by + 4, "text-anchor": "middle", fill: "#fff", + "font-size": "11", "font-weight": "700", "font-family": "Inter, sans-serif" }); + txt.textContent = idx; + badge.appendChild(title); badge.appendChild(circ); badge.appendChild(txt); + badge.style.cursor = "pointer"; + badge.style.pointerEvents = "all"; + badge.addEventListener("click", function (ev) { ev.stopPropagation(); focusComment(c.id, true); }); + makeMarkerInteractive(badge, c, idx, "Annotation (anchor missing)"); + overlay.appendChild(badge); + } + + function renderPin(c, missingSlot) { + var anchorEl = c.geom.selector ? resolveAnchorEl(c.geom.selector) : null; + if (!anchorEl && c.geom.selector !== "body") { c.__anchorMissing = true; renderMissingAnchor(c, missingSlot); return; } if (!anchorEl) anchorEl = document.body; + c.__anchorMissing = false; var box = docBox(anchorEl); + rememberAnchor(c, box); var idx = state.comments.indexOf(c) + 1; var pin = el("div", { class: "an-pin" + (c.id === state.activeId ? " an-active" : ""), title: c.text || "" }, [el("span", { text: String(idx) })]); @@ -1074,6 +1257,7 @@ pin.style.top = (box.y + c.geom.y * box.h) + "px"; pin.style.pointerEvents = "auto"; pin.addEventListener("click", function (ev) { ev.stopPropagation(); focusComment(c.id, true); }); + makeMarkerInteractive(pin, c, idx, "Pin"); pinLayer.appendChild(pin); } @@ -1509,10 +1693,28 @@ }); if (document.fonts && document.fonts.ready) document.fonts.ready.then(function () { setTimeout(renderAll, 60); }); window.addEventListener("load", function () { setTimeout(renderAll, 120); }); - // Resize overlay when page content grows (lazy images, dynamic content) + // Keep overlays aligned after layout changes (lazy images, dynamic + // content, nested scroll containers, SPA route swaps). Coalesced to one + // pass per frame; a full re-render happens only when an annotation anchor + // actually moved, so idle layout churn never restarts markers mid-animation. if (typeof ResizeObserver !== "undefined") { - var _ro = new ResizeObserver(function () { if (overlay) sizeOverlay(); }); + var _roRaf = 0; + var _scRaf = 0; + function relayout() { + if (!overlay || drawing || pendingDraft) return; + sizeOverlay(); + if (anchorsMoved()) renderAll(); + } + var _ro = new ResizeObserver(function () { + if (_roRaf) return; + _roRaf = requestAnimationFrame(function () { _roRaf = 0; relayout(); }); + }); _ro.observe(document.body); + // capture:true so scrolls inside nested scrollable containers count too + document.addEventListener("scroll", function () { + if (_scRaf) return; + _scRaf = requestAnimationFrame(function () { _scRaf = 0; relayout(); }); + }, { passive: true, capture: true }); } } @@ -1954,41 +2156,75 @@ inp.click(); } + var MAX_EXPORT_COMMENTS = 5000; + function isFiniteNum(v) { return typeof v === "number" && isFinite(v); } function isValidGeom(g) { - if (!g || typeof g.kind !== "string") return false; + if (!g || typeof g !== "object" || typeof g.kind !== "string") return false; if (g.kind === "pin" || g.kind === "rect" || g.kind === "circle") - return typeof g.x === "number" && isFinite(g.x) && typeof g.y === "number" && isFinite(g.y); + return isFiniteNum(g.x) && isFiniteNum(g.y) && (g.w === undefined || isFiniteNum(g.w)) && (g.h === undefined || isFiniteNum(g.h)); if (g.kind === "pen") return Array.isArray(g.points) && g.points.length >= 2 && g.points.length <= 10000 && - g.points.every(function (p) { return Array.isArray(p) && p.length === 2 && isFinite(p[0]) && isFinite(p[1]); }); + g.points.every(function (p) { return Array.isArray(p) && p.length === 2 && isFiniteNum(p[0]) && isFiniteNum(p[1]); }); if (g.kind === "block") return typeof g.selector === "string" && g.selector.length < 4096; + return false; // unknown geometry kinds are rejected, not guessed at + } + var VALID_TYPES = { highlight: 1, shape: 1, pin: 1, pen: 1, note: 1, block: 1 }; + function isValidAnchor(a) { + if (typeof a.exact !== "string") return false; + if (a.exact.length > 10000) return false; + return (typeof a.prefix === "string") && (typeof a.suffix === "string"); + } + function isValidReply(r) { + return r && typeof r.id === "string" && r.id.length < 128 && + typeof r.author === "string" && typeof r.text === "string" && r.text.length <= 5000 && + typeof r.createdAt === "string"; + } + function isValidType(t) { return typeof t === "string" && VALID_TYPES[t] === 1; } + // Single schema gate for anything read back from storage or imported from a + // file. Returns true for well-formed records only. + function isValidComment(c) { + if (!c || typeof c !== "object") return false; + if (typeof c.id !== "string" || !c.id || c.id.length > 128) return false; + if (!isValidType(c.type)) return false; + if (typeof c.author !== "string" || typeof c.text !== "string" || c.text.length > 5000) return false; + if (typeof c.color !== "string") return false; + if (c.anchor !== null && c.anchor !== undefined && !isValidAnchor(c.anchor)) return false; + if (c.geom !== null && c.geom !== undefined && !isValidGeom(c.geom)) return false; + if (!Array.isArray(c.replies) || c.replies.length > 500 || + !c.replies.every(isValidReply)) return false; return true; } function importComments(data) { var incoming = data && Array.isArray(data.comments) ? data.comments : null; if (!incoming) { toast("No comments found in that file", { kind: "error" }); return; } + if (incoming.length > MAX_EXPORT_COMMENTS) { + toast("That file has too many comments to import", { kind: "error" }); return; + } // Warn if the export came from a different page if (data.page && data.page !== PAGE) toast("These comments were from a different page — positions may not match.", { kind: "info", duration: 6000 }); var existing = {}; state.comments.forEach(function (c) { existing[c.id] = true; }); - var prepared = []; + var prepared = [], skipped = 0, seenIds = {}; incoming.forEach(function (c) { - if (!c || (!c.text && !c.anchor && !c.geom)) return; - if (c.geom && !isValidGeom(c.geom)) return; // reject malformed geometry - if (c.anchor && c.anchor.exact && String(c.anchor.exact).length > 10000) return; + if (!isValidComment(c)) { skipped++; return; } // malformed record — drop + if (seenIds[c.id]) { skipped++; return; } // duplicate ID within the batch + if (existing[c.id]) { skipped++; return; } // already imported to this project + seenIds[c.id] = true; var copy = JSON.parse(JSON.stringify(c)); copy.page = PAGE; - if (!copy.id || existing[copy.id]) copy.id = uid(); - if (!Array.isArray(copy.replies)) copy.replies = []; prepared.push(copy); }); - if (!prepared.length) { toast("Nothing new to import", { kind: "info" }); return; } + if (!prepared.length) { + toast(skipped ? "Nothing new to import (" + skipped + " invalid or already present)" : "Nothing new to import", { kind: skipped ? "error" : "info" }); + return; + } var d = dbRead(); d.comments = d.comments.concat(prepared); dbWrite(d); load(); - toast("Imported " + prepared.length + " comment" + (prepared.length === 1 ? "" : "s"), { kind: "success" }); + var ok = prepared.length + (skipped ? " (" + skipped + " skipped)" : ""); + toast("Imported " + ok + " comment" + (prepared.length === 1 ? "" : "s"), { kind: "success" }); } function copyLink(id) { @@ -1999,30 +2235,45 @@ else prompt("Copy link:", link); } - // delete with undo — remove locally now, persist when the toast expires + // delete with undo — persist the deletion immediately (so a reload in the + // undo window can't resurrect it) and let Undo restore the record. var pendingDeletes = {}; function deleteComment(c) { state.comments = state.comments.filter(function (x) { return x.id !== c.id; }); if (state.activeId === c.id) state.activeId = null; pendingDeletes[c.id] = c; + removeComment(c.id); // persist the deletion now renderAll(); renderPanel(); toast("Comment deleted", { kind: "info", action: "Undo", duration: 5000, onAction: function () { delete pendingDeletes[c.id]; - // Re-insert in chronological order so concurrent deletes don't break positions - var inserted = false; - for (var i = 0; i < state.comments.length; i++) { - if (state.comments[i].createdAt > c.createdAt) { - state.comments.splice(i, 0, c); inserted = true; break; + var restored = Object.assign({}, c, { replies: (c.replies || []).slice() }); + // Only re-show the comment if we're still on the page it belongs to; + // after an SPA route change it must be restored to storage without + // leaking into the current route's list. + if (restored.page === PAGE) { + // Re-insert in chronological order so concurrent deletes don't break positions + var inserted = false; + for (var i = 0; i < state.comments.length; i++) { + if (state.comments[i].createdAt > c.createdAt) { + state.comments.splice(i, 0, restored); inserted = true; break; + } } + if (!inserted) state.comments.push(restored); + } + // Idempotent restore: another path (cross-tab sync, a re-import) may + // have already put this id back in storage during the undo window — + // never write a second copy. + var d = dbRead(); + if (!d.comments.some(function (x) { return x.id === restored.id; })) { + d.comments.push(restored); + dbWrite(d); } - if (!inserted) state.comments.push(c); renderAll(); renderPanel(); }, onExpire: function () { delete pendingDeletes[c.id]; - removeComment(c.id); }, }); } @@ -2225,6 +2476,15 @@ // ========================================================================== var firstLoad = true; function load() { + // SPA navigation: when the host pushes a new route and calls refresh(), + // recompute a non-explicit page key so each route keeps its own comments. + // An in-flight draft from the previous page is discarded. + var newKey = currentPageKey(); + if (newKey !== PAGE) { + if (pendingDraft || drawing) { drawing = null; cancelDraft(); } + PAGE = newKey; + state.activeId = null; + } if (pendingDraft || drawing) return; state.comments = pageComments().filter(function (c) { return !pendingDeletes[c.id]; }); renderAll(); @@ -2279,6 +2539,16 @@ toast: toast, export: function () { exportComments(); }, import: function () { pickImportFile(); }, + // Test / automation hooks (not part of the documented API) + _annotateImportForTest: function (data) { importComments(data); }, + _annotateOpenImportForTest: function () { pickImportFile(); }, + _annotateCreateForTest: function (draft) { + var c = createComment(draft); + state.comments.push(c); + renderAll(); renderPanel(); + return c; + }, + _annotateExportPayloadForTest: function () { return buildExportPayload(); }, clear: function () { var d = dbRead(); d.comments = d.comments.filter(function (c) { return c.page !== PAGE; }); diff --git a/package-lock.json b/package-lock.json index c54b38a..37d4a38 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "1.2.0", "license": "MIT", "devDependencies": { - "@playwright/test": "^1.61.0" + "@playwright/test": "^1.61.0", + "serve": "^14.2.6" } }, "node_modules/@playwright/test": { @@ -28,6 +29,462 @@ "node": ">=18" } }, + "node_modules/@zeit/schemas": { + "version": "2.36.0", + "resolved": "https://registry.npmjs.org/@zeit/schemas/-/schemas-2.36.0.tgz", + "integrity": "sha512-7kjMwcChYEzMKjeex9ZFXkt1AyNov9R5HZtjBKVsmVpw7pa7ZtlCGvCBC2vnnXctaYN+aRI61HjIqeetZW5ROg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-align": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.1.0" + } + }, + "node_modules/ansi-align/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-align/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-align/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-align/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-regex": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/arch": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz", + "integrity": "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/boxen": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-7.0.0.tgz", + "integrity": "sha512-j//dBVuyacJbvW+tvZ9HuH03fZ46QcaKvvhZickZqtB271DxJ7SNRSNxrV/dZX0085m7hISRZWbzWlJvx/rHSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-align": "^3.0.1", + "camelcase": "^7.0.0", + "chalk": "^5.0.1", + "cli-boxes": "^3.0.0", + "string-width": "^5.1.2", + "type-fest": "^2.13.0", + "widest-line": "^4.0.1", + "wrap-ansi": "^8.0.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/camelcase": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-7.0.1.tgz", + "integrity": "sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chalk": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.0.1.tgz", + "integrity": "sha512-Fo07WOYGqMfCWHOzSXOt2CxDbC6skS/jO9ynEcmpANMoPrD+W1r1K6Vx7iNm+AQmETU1Xr2t+n8nzkV9t6xh3w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk-template": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-0.4.0.tgz", + "integrity": "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/chalk-template?sponsor=1" + } + }, + "node_modules/chalk-template/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/chalk-template/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clipboardy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-3.0.0.tgz", + "integrity": "sha512-Su+uU5sr1jkUy1sGRpLKjKrvEOVXgSgiSInwa/qeID6aJ07yh+5NWc3h2QfjHjBnfX4LhtFcuAWKUsJ3r+fjbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "arch": "^2.2.0", + "execa": "^5.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz", + "integrity": "sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -43,6 +500,278 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-port-reachable": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-port-reachable/-/is-port-reachable-4.0.0.tgz", + "integrity": "sha512-9UoipoxYmSk6Xy7QFgRv2HDyaysmgSG75TFQs6S+3pDM7ZhKTF/bskZV+0UlABHzKjNVhPjYCLfeZUEg1wXxig==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz", + "integrity": "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "~1.33.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz", + "integrity": "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-is-inside": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", + "integrity": "sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz", + "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==", + "dev": true, + "license": "MIT" + }, "node_modules/playwright": { "version": "1.61.0", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz", @@ -74,6 +803,320 @@ "engines": { "node": ">=18" } + }, + "node_modules/range-parser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz", + "integrity": "sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/registry-auth-token": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-3.3.2.tgz", + "integrity": "sha512-JL39c60XlzCVgNrO+qq68FoNb56w/m7JYvGR2jT5iR1xBrUA3Mfx5Twk5rqTThPmQKMWydGmq8oFtDlxfrmxnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "rc": "^1.1.6", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/registry-url": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-3.1.0.tgz", + "integrity": "sha512-ZbgR5aZEdf4UKZVBPYIgaglBmSF2Hi94s2PcIHhRGFjKYu+chjJdYfHn4rt3hB6eCKLJ8giVIIfgMa1ehDfZKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "rc": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/serve": { + "version": "14.2.6", + "resolved": "https://registry.npmjs.org/serve/-/serve-14.2.6.tgz", + "integrity": "sha512-QEjUSA+sD4Rotm1znR8s50YqA3kYpRGPmtd5GlFxbaL9n/FdUNbqMhxClqdditSk0LlZyA/dhud6XNRTOC9x2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zeit/schemas": "2.36.0", + "ajv": "8.18.0", + "arg": "5.0.2", + "boxen": "7.0.0", + "chalk": "5.0.1", + "chalk-template": "0.4.0", + "clipboardy": "3.0.0", + "compression": "1.8.1", + "is-port-reachable": "4.0.0", + "serve-handler": "6.1.7", + "update-check": "1.5.4" + }, + "bin": { + "serve": "build/main.js" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/serve-handler": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.7.tgz", + "integrity": "sha512-CinAq1xWb0vR3twAv9evEU8cNWkXCb9kd5ePAHUKJBkOsUpR1wt/CvGdeca7vqumL1U5cSaeVQ6zZMxiJ3yWsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.0.0", + "content-disposition": "0.5.2", + "mime-types": "2.1.18", + "minimatch": "3.1.5", + "path-is-inside": "1.0.2", + "path-to-regexp": "3.3.0", + "range-parser": "1.2.0" + } + }, + "node_modules/serve-handler/node_modules/bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/update-check": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/update-check/-/update-check-1.5.4.tgz", + "integrity": "sha512-5YHsflzHP4t1G+8WGPlvKbJEbAJGCgw+Em+dGR1KmBUbr1J36SJBqlHLjR7oob7sco5hWHGQVcr9B2poIVDDTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "registry-auth-token": "3.3.2", + "registry-url": "3.1.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/widest-line": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz", + "integrity": "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^5.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } } } } diff --git a/package.json b/package.json index 467284f..63dbd70 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ }, "author": "Akash Goswami", "devDependencies": { - "@playwright/test": "^1.61.0" + "@playwright/test": "^1.61.0", + "serve": "^14.2.6" } } diff --git a/tests/annotate.spec.js b/tests/annotate.spec.js index 4805625..aed6a34 100644 --- a/tests/annotate.spec.js +++ b/tests/annotate.spec.js @@ -415,6 +415,171 @@ test.describe('Comment actions', () => { await expect(page.locator('.an-card')).toHaveCount(1); }); + test('deletion persists to storage immediately (before the undo toast expires)', async ({ page }) => { + // Regression: deletion used to be deferred until the undo toast expired, + // so reloading within that window resurrected the "deleted" comment. + // Seed exactly one comment via storage (deterministic, avoids click-to-pin flake). + await page.evaluate(() => { + const key = Object.keys(localStorage).find(k => k.startsWith('annotate:')); + const stored = { comments: [{ + id: 'doomed-1', type: 'note', author: 'Test', text: 'Doomed comment', + color: '#f59e0b', page: 'annotate-demo:/', + resolved: false, replies: [], + createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + }]}; + localStorage.setItem(key, JSON.stringify(stored)); + window.Annotate.refresh(); + }); + await expect(page.locator('.an-card')).toHaveCount(1); + + await page.locator('.an-card').hover(); + await page.locator('.an-card [title="Delete"]').click(); + await expect(page.locator('.an-card')).toHaveCount(0); + + // Storage must no longer contain the comment immediately — a reload in the + // undo window must not resurrect it. + const remaining = await page.evaluate(() => window.Annotate.comments().length); + expect(remaining).toBe(0); + const storedCount = await page.evaluate(() => { + const key = Object.keys(localStorage).find(k => k.startsWith('annotate:')); + return JSON.parse(localStorage.getItem(key) || '{"comments":[]}').comments.length; + }); + expect(storedCount).toBe(0); + + // Undo restores the record in both storage and the panel. + const toast = page.locator('.an-toast', { hasText: 'deleted' }); + await expect(toast).toBeVisible(); + await toast.locator('.an-taction', { hasText: 'Undo' }).click(); + await expect(page.locator('.an-card')).toHaveCount(1); + const restoredStored = await page.evaluate(() => { + const key = Object.keys(localStorage).find(k => k.startsWith('annotate:')); + return JSON.parse(localStorage.getItem(key) || '{"comments":[]}').comments.length; + }); + expect(restoredStored).toBe(1); + }); + + test('undo does not duplicate a comment if its id was re-added to storage during the window', async ({ page }) => { + // Delete the comment, then (as a cross-tab sync or re-import would) write + // the same id back into storage before Undo is pressed. Undo must restore + // the record in the panel but must not append a second copy to storage. + await page.evaluate(() => { + const key = Object.keys(localStorage).find(k => k.startsWith('annotate:')); + localStorage.setItem(key, JSON.stringify({ comments: [{ + id: 'reappear-1', type: 'note', author: 'Test', text: 'Come back', + color: '#f59e0b', page: 'annotate-demo:/', + resolved: false, replies: [], + createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + }] })); + window.Annotate.refresh(); + }); + await expect(page.locator('.an-card')).toHaveCount(1); + await page.locator('.an-card').hover(); + await page.locator('.an-card [title="Delete"]').click(); + await expect(page.locator('.an-card')).toHaveCount(0); + + // Simulate the id reappearing in storage during the undo window. + await page.evaluate(() => { + const key = Object.keys(localStorage).find(k => k.startsWith('annotate:')); + const stored = JSON.parse(localStorage.getItem(key)); + stored.comments.push({ + id: 'reappear-1', type: 'note', author: 'Test', text: 'Come back', + color: '#f59e0b', page: 'annotate-demo:/', + resolved: false, replies: [], + createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + }); + localStorage.setItem(key, JSON.stringify(stored)); + }); + + const toast = page.locator('.an-toast', { hasText: 'deleted' }); + await toast.locator('.an-taction', { hasText: 'Undo' }).click(); + const stored = await page.evaluate(() => { + const key = Object.keys(localStorage).find(k => k.startsWith('annotate:')); + return JSON.parse(localStorage.getItem(key)).comments.filter(c => c.id === 'reappear-1').length; + }); + expect(stored).toBe(1); + }); + + test('undo after navigating to another route does not inject the old route\'s comment', async ({ page }) => { + // Delete on route A, navigate to route B, then hit Undo: the comment is + // restored to storage but must not appear in route B's live list. + await page.evaluate(() => { + const key = 'annotate:annotate-demo'; + localStorage.setItem(key, JSON.stringify({ comments: [{ + id: 'routeA-1', type: 'note', author: 'T', text: 'from route A', + color: '#f59e0b', page: 'annotate-demo:/', + resolved: false, replies: [], + createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + }] })); + window.Annotate.refresh(); + }); + await page.locator('.an-card').hover(); + await page.locator('.an-card [title="Delete"]').click(); + await expect(page.locator('.an-card')).toHaveCount(0); + const toast = page.locator('.an-toast', { hasText: 'deleted' }); + await expect(toast).toBeVisible(); + + // Navigate to a different route and refresh — route A is no longer shown. + await page.evaluate(() => { history.pushState({}, '', '/route-b'); window.Annotate.refresh(); }); + expect(await page.evaluate(() => window.Annotate.comments().length)).toBe(0); + + // Undo is still pending; clicking it must restore to storage only. + await toast.locator('.an-taction', { hasText: 'Undo' }).click(); + const state = await page.evaluate(() => ({ + live: window.Annotate.comments().length, + liveIds: window.Annotate.comments().map((c) => c.id), + stored: JSON.parse(localStorage.getItem('annotate:annotate-demo')).comments + .map((c) => c.id), + })); + expect(state.live).toBe(0); + expect(state.liveIds).not.toContain('routeA-1'); + expect(state.stored).toContain('routeA-1'); + }); + + test('undo never duplicates a record that is already in storage', async ({ page }) => { + // Simulate the record still present in storage (e.g. the delete write + // failed, or another tab restored it) before Undo runs. + await page.evaluate(() => { + const key = 'annotate:annotate-demo'; + localStorage.setItem(key, JSON.stringify({ comments: [] })); + window.Annotate.refresh(); + window.Annotate._annotateCreateForTest({ type: 'note', text: 'dup check', color: '#f59e0b' }); + }); + expect(await page.evaluate(() => window.Annotate.comments().length)).toBe(1); + // Force the storage record to survive: delete only from the live state via + // the UI, then restore the storage copy before undo (as a failed write + // would have left it). + const id = await page.evaluate(() => window.Annotate.comments()[0].id); + await page.locator('.an-card').hover(); + await page.locator('.an-card [title="Delete"]').click(); + await expect(page.locator('.an-card')).toHaveCount(0); + await page.evaluate((id) => { + const key = 'annotate:annotate-demo'; + const fresh = JSON.parse(localStorage.getItem(key)); + if (!fresh.comments.some((c) => c.id === id)) { + // Re-create an equivalent record with the same id to model the + // "delete write failed, so the record is still in storage" case. + fresh.comments.push({ + id, type: 'note', author: 'Test', text: 'dup check', color: '#f59e0b', + page: 'annotate-demo:/', resolved: false, replies: [], + createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + }); + localStorage.setItem(key, JSON.stringify(fresh)); + } + }, id); + const toast = page.locator('.an-toast', { hasText: 'deleted' }); + await toast.locator('.an-taction', { hasText: 'Undo' }).click(); + const counts = await page.evaluate((id) => { + const key = 'annotate:annotate-demo'; + const stored = JSON.parse(localStorage.getItem(key)).comments; + return { + storedDup: stored.filter((c) => c.id === id).length, + liveDup: window.Annotate.comments().filter((c) => c.id === id).length, + }; + }, id); + expect(counts.storedDup).toBe(1); + expect(counts.liveDup).toBeLessThanOrEqual(1); + }); + test('edit comment text', async ({ page }) => { const card = page.locator('.an-card').first(); await card.hover(); @@ -551,68 +716,260 @@ test.describe('Export / Import', () => { expect(typeof json.exportedViewport.dpr).toBe('number'); }); - test('importing from a different page shows a mismatch toast', async ({ page }) => { + test('importing from a different page shows a mismatch toast and still imports', async ({ page }) => { await page.evaluate(() => { window.Annotate.clear(); - const key = Object.keys(localStorage).find(k => k.startsWith('annotate:')); - const data = { - annotate: '1.0.1', - kind: 'annotate-export', - page: '/some-other-page', - url: 'http://example.com/some-other-page', + }); + const before = await page.evaluate(() => window.Annotate.comments().length); + await page.evaluate(() => { + window.Annotate._annotateImportForTest({ + annotate: '1.2.0', kind: 'annotate-export', page: '/some-other-page', comments: [{ id: 'mismatch-1', type: 'pin', author: 'Tester', text: 'From elsewhere', color: '#f59e0b', geom: { kind: 'pin', selector: 'body', x: 0.5, y: 0.5 }, - resolved: false, replies: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + resolved: false, replies: [], + createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }], - }; - // Trigger importComments via internal path - const storeKey = key || ('annotate:' + (location.host || 'default')); - const stored = JSON.parse(localStorage.getItem(storeKey) || '{"comments":[]}'); - const c = JSON.parse(JSON.stringify(data.comments[0])); - c.page = window.__ANNOTATE_PAGE__ || '/'; - stored.comments.push(c); - localStorage.setItem(storeKey, JSON.stringify(stored)); - // Invoke the importComments path through the public import event - // by directly calling the exposed internal (indirectly via a crafted object) - }); - // Instead test it end-to-end: the toast appears when page key differs - // We call import via the internal function by simulating a file (evaluate) - await page.evaluate(() => { - // Simulate what importComments does when page key differs - const data = { page: '/different-page', comments: [{ - id: 'mis2', type: 'pin', author: 'X', text: 'hi', - color: '#f59e0b', geom: { kind: 'pin', selector: 'body', x: 0.5, y: 0.5 }, - resolved: false, replies: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), - }]}; - // Access internal via global hook if exposed, else skip - if (window._annotateImportForTest) window._annotateImportForTest(data); + }); }); - // The mismatch toast is info-level; it may appear if the hook is wired. - // For now verify geom is validated - malformed geom is rejected + await expect(page.locator('.an-toast', { hasText: 'different page' })).toBeVisible(); + expect(await page.evaluate(() => window.Annotate.comments().length)).toBe(before + 1); }); test('import rejects comment with malformed geom', async ({ page }) => { - const badCount = await page.evaluate(() => { + await page.evaluate(() => { const key = Object.keys(localStorage).find(k => k.startsWith('annotate:')); - const storeKey = key || ('annotate:' + (location.host || 'default')); - const stored = JSON.parse(localStorage.getItem(storeKey) || '{"comments":[]}'); - const before = stored.comments.length; - // Malformed geom: non-finite coordinates + const stored = JSON.parse(localStorage.getItem(key) || '{"comments":[]}'); + const pageKey = stored.comments[0] ? stored.comments[0].page : '/'; + // Malformed geom: JSON has no Infinity/NaN, so they serialize as null stored.comments.push({ id: 'bad-1', type: 'pin', author: 'X', text: 'bad geom', - color: '#f59e0b', geom: { kind: 'pin', selector: 'body', x: Infinity, y: NaN }, - page: stored.comments[0] ? stored.comments[0].page : '/', + color: '#f59e0b', geom: { kind: 'pin', selector: 'body', x: null, y: null }, + page: pageKey, resolved: false, replies: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); - localStorage.setItem(storeKey, JSON.stringify(stored)); + localStorage.setItem(key, JSON.stringify(stored)); + }); + // A real import of that same malformed record must be rejected by the + // schema validator — never written into storage. + const imported = await page.evaluate(() => { + const key = Object.keys(localStorage).find(k => k.startsWith('annotate:')); + const before = JSON.parse(localStorage.getItem(key)).comments.length; + window.Annotate._annotateImportForTest({ + annotate: '1.2.0', kind: 'annotate-export', page: '/', + comments: [{ + id: 'bad-2', type: 'pin', author: 'X', text: 'bad geom', + color: '#f59e0b', geom: { kind: 'pin', selector: 'body', x: null, y: null }, + resolved: false, replies: [], + createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + }], + }); + const after = JSON.parse(localStorage.getItem(key)).comments.length; + return { before, after }; + }); + expect(imported.after).toBe(imported.before); + }); + + test('import rejects unknown geometry kinds and unknown comment types', async ({ page }) => { + const before = await page.evaluate(() => window.Annotate.comments().length); + const toastVisible = await page.evaluate(() => { + window.Annotate._annotateImportForTest({ + annotate: '1.2.0', kind: 'annotate-export', page: '/', + comments: [ + { id: 'k-1', type: 'pin', author: 'X', text: 'bad kind', color: '#f59e0b', + geom: { kind: 'hexagon', selector: 'body', x: 0.5, y: 0.5 }, + resolved: false, replies: [], + createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }, + { id: 't-1', type: 'teleport', author: 'X', text: 'bad type', color: '#f59e0b', + resolved: false, replies: [], + createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }, + ], + }); + return !!document.querySelector('.an-toast.an-error'); + }); + expect(toastVisible).toBe(true); + expect(await page.evaluate(() => window.Annotate.comments().length)).toBe(before); + }); + + test('import skips records with duplicate IDs in the same batch', async ({ page }) => { + await page.evaluate(() => { + const key = Object.keys(localStorage).find(k => k.startsWith('annotate:')); + const stored = JSON.parse(localStorage.getItem(key) || '{"comments":[]}'); + localStorage.setItem(key, JSON.stringify({ comments: [] })); window.Annotate.refresh(); - return window.Annotate.comments().filter(c => c.id === 'bad-1').length; }); - // Malformed comment persists in storage but isValidGeom correctly - // identifies Infinity/NaN during import — test via the validation function indirectly - expect(typeof badCount).toBe('number'); // validation tested at unit level + const count = await page.evaluate(() => { + const mk = (id) => ({ + id, type: 'pin', author: 'X', text: 'dup', color: '#f59e0b', + geom: { kind: 'pin', selector: 'body', x: 0.2, y: 0.2 }, + resolved: false, replies: [], + createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + }); + window.Annotate._annotateImportForTest({ + annotate: '1.2.0', kind: 'annotate-export', page: '/', comments: [mk('dup-id'), mk('dup-id')], + }); + return window.Annotate.comments().length; + }); + expect(count).toBe(1); + }); + + test('import skips records whose ID already exists in this project', async ({ page }) => { + // Distinct from the intra-batch duplicate case: a single incoming record + // whose id is already present in this project must be skipped, not merged + // or duplicated. + const count = await page.evaluate(() => { + const key = Object.keys(localStorage).find(k => k.startsWith('annotate:')); + const mk = (id, text) => ({ + id, type: 'pin', author: 'X', text, color: '#f59e0b', + geom: { kind: 'pin', selector: 'body', x: 0.2, y: 0.2 }, + resolved: false, replies: [], + createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + }); + // An existing comment already lives in this project… + const existing = mk('already-here', 'existing'); + existing.page = 'annotate-demo:/'; + localStorage.setItem(key, JSON.stringify({ comments: [existing] })); + window.Annotate.refresh(); + // …and the import carries the very same id. + window.Annotate._annotateImportForTest({ + annotate: '1.2.0', kind: 'annotate-export', page: '/', + comments: [mk('already-here', 'incoming duplicate'), mk('fresh-id', 'new one')], + }); + return { + count: window.Annotate.comments().length, + texts: window.Annotate.comments().map(c => c.text), + }; + }); + // The existing id is skipped; only the genuinely new record is added. + expect(count.count).toBe(2); + expect(count.texts).toContain('existing'); + expect(count.texts).toContain('new one'); + expect(count.texts).not.toContain('incoming duplicate'); + }); + + test('import validates reply structure', async ({ page }) => { + const before = await page.evaluate(() => window.Annotate.comments().length); + const imported = await page.evaluate(() => { + const mk = (replies) => ({ + id: 'rep-test', type: 'note', author: 'X', text: 'replies check', color: '#f59e0b', + resolved: false, replies, + createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + }); + window.Annotate._annotateImportForTest({ + annotate: '1.2.0', kind: 'annotate-export', page: '/', + comments: [ + mk([{ id: 'r1', author: 'R', text: 'ok', createdAt: new Date().toISOString() }]), + mk(['not-a-reply']), + mk([{ author: 'missing id', text: 'x', createdAt: new Date().toISOString() }]), + ], + }); + return window.Annotate.comments().length; + }); + // Only the record with well-formed replies passes validation. + expect(imported).toBe(before + 1); + }); + + test('real file import via the file chooser imports one comment', async ({ page }) => { + const before = await page.evaluate(() => window.Annotate.comments().length); + const [fileChooser] = await Promise.all([ + page.waitForEvent('filechooser'), + page.evaluate(() => window.Annotate._annotateOpenImportForTest()), + ]); + await fileChooser.setFiles({ + name: 'review.json', + mimeType: 'application/json', + buffer: Buffer.from(JSON.stringify({ + annotate: '1.2.0', kind: 'annotate-export', page: '/', + comments: [{ + id: 'file-1', type: 'pin', author: 'X', text: 'From a real file', + color: '#f59e0b', geom: { kind: 'pin', selector: 'body', x: 0.5, y: 0.5 }, + resolved: false, replies: [], + createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + }], + })), + }); + await expect(page.locator('.an-toast', { hasText: 'Imported 1 comment' })).toBeVisible(); + expect(await page.evaluate(() => window.Annotate.comments().length)).toBe(before + 1); + }); + + test('invalid JSON file shows an error toast and imports nothing', async ({ page }) => { + const before = await page.evaluate(() => window.Annotate.comments().length); + const [fileChooser] = await Promise.all([ + page.waitForEvent('filechooser'), + page.evaluate(() => window.Annotate._annotateOpenImportForTest()), + ]); + await fileChooser.setFiles({ + name: 'broken.json', + mimeType: 'application/json', + buffer: Buffer.from('{ this is not json'), + }); + await expect(page.locator('.an-toast.an-error', { hasText: 'isn’t valid JSON' })).toBeVisible(); + expect(await page.evaluate(() => window.Annotate.comments().length)).toBe(before); + }); +}); + +test.describe('Storage failures', () => { + test('when localStorage writes fail, comments are kept in memory, exportable, and the failure is surfaced', async ({ page }) => { + const res = await page.evaluate(() => { + const orig = Storage.prototype.setItem; + Storage.prototype.setItem = function () { + throw new DOMException('denied', 'SecurityError'); + }; + try { + const before = window.Annotate.comments().length; + window.Annotate._annotateCreateForTest({ type: 'note', text: 'kept in memory', color: '#f59e0b' }); + const after = window.Annotate.comments(); + // The comment must be in memory… + const inMemory = after.some(c => c.text === 'kept in memory'); + // …but absent from localStorage (write failed)… + const key = Object.keys(localStorage).find(k => k.startsWith('annotate:')); + const inStorage = (JSON.parse(localStorage.getItem(key) || '{"comments":[]}').comments || []) + .some(c => c.text === 'kept in memory'); + // …and it must be present in the export payload. + const exp = window.Annotate._annotateExportPayloadForTest(); + const inExport = !!(exp && exp.comments.some(c => c.text === 'kept in memory')); + const warned = !!document.getElementById('__an_unsaved'); + return { inMemory, inStorage, inExport, warned, grew: after.length === before + 1 }; + } finally { + Storage.prototype.setItem = orig; + } + }); + expect(res.inMemory).toBe(true); + expect(res.inStorage).toBe(false); + expect(res.inExport).toBe(true); + expect(res.warned).toBe(true); + expect(res.grew).toBe(true); + }); + + test('a failed preference write does not raise the unsaved-comments banner', async ({ page }) => { + // The banner is about comment persistence. A failure writing an unrelated + // preference (e.g. an-color) must not make it look like comments are at risk. + const res = await page.evaluate(() => { + // Ensure no banner is present to start. + const existing = document.getElementById('__an_unsaved'); + if (existing) existing.remove(); + const orig = Storage.prototype.setItem; + // Only the preference key fails; comment writes keep working. + Storage.prototype.setItem = function (k, v) { + if (k === 'an-color') throw new DOMException('denied', 'SecurityError'); + return orig.call(this, k, v); + }; + try { + // A successful comment write must leave the banner off… + window.Annotate._annotateCreateForTest({ type: 'note', text: 'fine', color: '#f59e0b' }); + const afterCommentWrite = !!document.getElementById('__an_unsaved'); + // …and a failing preference write must not raise it either. + const sw = document.querySelector('#__an_colorpop .an-sw'); + if (sw) sw.click(); + const afterPrefWrite = !!document.getElementById('__an_unsaved'); + return { afterCommentWrite, afterPrefWrite }; + } finally { + Storage.prototype.setItem = orig; + } + }); + expect(res.afterCommentWrite).toBe(false); + expect(res.afterPrefWrite).toBe(false); }); }); @@ -987,48 +1344,343 @@ test.describe('Landing page startup', () => { }); }); +// ============================================================ +// SPA PAGE ISOLATION +// ============================================================ +test.describe('SPA page isolation', () => { + test('refresh() after in-page navigation switches the page key and its comments', async ({ page }) => { + // Seed one comment on the landing page, then simulate SPA navigation to a + // new route. The layer must recompute the (non-explicit) page key so the + // new route does NOT show the old route's comments, and new comments + // created after the switch are tagged with the new key. + await page.evaluate(() => { + const key = 'annotate:annotate-demo'; // data-project on the landing page + const stored = { comments: [{ + id: 'spa-old', type: 'note', author: 'SPA', text: 'on first route', + color: '#f59e0b', page: 'annotate-demo:/', + resolved: false, replies: [], + createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + }]}; + localStorage.setItem(key, JSON.stringify(stored)); + window.Annotate.refresh(); + }); + expect(await page.evaluate(() => window.Annotate.comments().length)).toBe(1); + expect(await page.evaluate(() => window.Annotate.config.page)).toBe('/'); + + // Navigate in-page (what a history-based SPA router does) and refresh. + await page.evaluate(() => { + history.pushState({}, '', '/new-route'); + window.Annotate.refresh(); + }); + + expect(await page.evaluate(() => window.Annotate.config.page)).toBe('/new-route'); + // The old route's comment must not leak into the new route. + expect(await page.evaluate(() => window.Annotate.comments().length)).toBe(0); + + // A comment created on the new route is tagged with the new page key. + await page.evaluate(() => { + window.Annotate._annotateCreateForTest({ type: 'note', text: 'on new route', color: '#f59e0b' }); + }); + const saved = await page.evaluate(() => { + const key = Object.keys(localStorage).find(k => k.startsWith('annotate:')); + return JSON.parse(localStorage.getItem(key)).comments + .filter(c => c.text === 'on new route').map(c => c.page); + }); + expect(saved).toEqual(['annotate-demo:/new-route']); + }); + + test('page key is stable until the host calls refresh()', async ({ page }) => { + // Navigation alone must not silently retag comments; only an explicit + // refresh() (which SPA routers call) may recompute the page key. + await page.evaluate(() => { history.pushState({}, '', '/elsewhere'); }); + expect(await page.evaluate(() => window.Annotate.config.page)).toBe('/'); + // Comments created before an explicit refresh still land on the old key. + await page.evaluate(() => { + window.Annotate._annotateCreateForTest({ type: 'note', text: 'pre-refresh', color: '#f59e0b' }); + }); + const pages = await page.evaluate(() => { + const key = Object.keys(localStorage).find(k => k.startsWith('annotate:')); + return JSON.parse(localStorage.getItem(key)).comments + .filter(c => c.text === 'pre-refresh').map(c => c.page); + }); + expect(pages).toEqual(['annotate-demo:/']); + }); +}); + +// ============================================================ +// OVERLAY RE-ANCHORING +// ============================================================ +test.describe('Overlay re-anchoring', () => { + test('pin repositions when its anchor block grows', async ({ page }) => { + // A pin anchored to the hero paragraph: when the paragraph grows (its + // layout shifts), the pin must track the anchor's new box on the next + // render pass — not stay frozen at its original position. + await page.evaluate(() => { + const key = 'annotate:annotate-demo'; + const stored = { comments: [{ + id: 'reanchor-1', type: 'pin', author: 'Test', text: 'Tracking pin', + color: '#f59e0b', + geom: { kind: 'pin', selector: 'body', x: 0.5, y: 0.5, vw: window.innerWidth, vh: window.innerHeight }, + page: 'annotate-demo:/', + resolved: false, replies: [], + createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + }]}; + localStorage.setItem(key, JSON.stringify(stored)); + window.Annotate.refresh(); + }); + await expect(page.locator('.an-pin')).toHaveCount(1); + // Let startup renders (window-load / fonts.ready) settle so the only + // layout change that follows is the spacer we inject. + await page.waitForTimeout(800); + const before = await page.evaluate(() => { + const p = document.querySelector('.an-pin'); + return { left: p.style.left, top: p.style.top }; + }); + // Grow a large block above the pin's anchor area so document coordinates + // shift — no window resize occurs, only the ResizeObserver on sees it. + await page.evaluate(() => { + const spacer = document.createElement('div'); + spacer.id = '__dbg_spacer'; + spacer.style.height = '1200px'; + document.body.insertBefore(spacer, document.body.firstChild); + }); + await page.waitForTimeout(500); // observer + rAF coalescing + const after = await page.evaluate(() => { + const p = document.querySelector('.an-pin'); + return p ? { left: p.style.left, top: p.style.top } : null; + }); + expect(after).toBeTruthy(); + // A pin at 50% of a document that just grew 1200px tall must have moved down. + expect(parseFloat(after.top)).toBeGreaterThan(parseFloat(before.top)); + await page.evaluate(() => { document.getElementById('__dbg_spacer')?.remove(); }); + }); +}); + +// ============================================================ +// ANCHOR INTEGRITY +// ============================================================ +test.describe('Anchor integrity', () => { + test('ambiguous text match shows an unanchored warning instead of a silent guess', async ({ page }) => { + const phrase = 'review surface'; // occurs once in .hero-sub + const occurrences = await page.evaluate((phrase) => { + // Clone the hero paragraph so the phrase occurs in two places — the + // stored quote below can no longer identify a single intended target. + const src = document.querySelector('.hero-sub'); + const clone = src.cloneNode(true); + clone.id = '__dup_hero_sub'; + src.insertAdjacentElement('afterend', clone); + const stored = { comments: [{ + id: 'ambig-1', type: 'highlight', author: 'T', text: 'which one?', + color: '#f59e0b', + anchor: { exact: phrase, prefix: '', suffix: '' }, + resolved: false, replies: [], + page: 'annotate-demo:/', + createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + }]}; + localStorage.setItem('annotate:annotate-demo', JSON.stringify(stored)); + window.Annotate.refresh(); + return document.body.textContent.split(phrase).length - 1; + }, phrase); + expect(occurrences).toBeGreaterThanOrEqual(2); + // The library must not silently highlight an arbitrary copy: it surfaces + // an explicit unanchored/ambiguous state (no mark is painted). + await expect(page.locator('mark[data-an="ambig-1"]')).toHaveCount(0); + await expect(page.locator('.an-unanchored-pill')).toHaveCount(1); + }); + + test('shape anchored to a missing element is flagged as unanchored', async ({ page }) => { + await page.evaluate(() => { + const stored = { comments: [{ + id: 'ghost-1', type: 'shape', author: 'T', text: 'where am I', + color: '#f59e0b', + geom: { kind: 'rect', selector: '#ghost-999', x: 0.1, y: 0.1, w: 0.3, h: 0.2, + vw: window.innerWidth, vh: window.innerHeight }, + page: 'annotate-demo:/', + resolved: false, replies: [], + createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + }]}; + localStorage.setItem('annotate:annotate-demo', JSON.stringify(stored)); + window.Annotate.refresh(); + }); + // The badge takes the explicit "element missing" state, not a silent body fallback. + await expect(page.locator('.an-badge-circle[title*="missing"]')).toHaveCount(1); + }); + + test('multiple missing anchors get distinct badge positions', async ({ page }) => { + await page.evaluate(() => { + const mk = (id, sel) => ({ + id, type: 'shape', author: 'T', text: 'ghost ' + id, color: '#f59e0b', + geom: { kind: 'rect', selector: sel, x: 0.1, y: 0.1, w: 0.3, h: 0.2 }, + page: 'annotate-demo:/', resolved: false, replies: [], + createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + }); + const stored = { comments: [mk('g1', '#ghost-a'), mk('g2', '#ghost-b')] }; + localStorage.setItem('annotate:annotate-demo', JSON.stringify(stored)); + window.Annotate.refresh(); + }); + const circles = page.locator('.an-badge-circle[title*="missing"]'); + await expect(circles).toHaveCount(2); + const cx = await circles.evaluateAll((els) => els.map((e) => e.getAttribute('cx'))); + expect(new Set(cx).size).toBe(2); + }); + + test('repeated text with unique surrounding context still highlights', async ({ page }) => { + // "bravo" occurs twice, but the saved prefix+suffix context matches only + // the first occurrence — the highlight must NOT be treated as ambiguous. + await page.evaluate(() => { + const host = document.createElement('div'); + host.id = '__ctx_host'; + host.innerHTML = '

ALPHA bravo charlie

delta bravo echo

'; + document.body.appendChild(host); + const stored = { comments: [{ + id: 'ctx-1', type: 'highlight', author: 'T', text: 'contextual', + color: '#f59e0b', anchor: { exact: 'bravo', prefix: 'ALPHA ', suffix: ' charlie' }, + page: 'annotate-demo:/', resolved: false, replies: [], + createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + }]}; + localStorage.setItem('annotate:annotate-demo', JSON.stringify(stored)); + window.Annotate.refresh(); + }); + // Exactly one mark is painted (the contextualized occurrence), no pill. + await expect(page.locator('mark[data-an="ctx-1"]')).toHaveCount(1); + await expect(page.locator('.an-unanchored-pill')).toHaveCount(0); + }); + + test('repeated text with no distinguishing context is flagged ambiguous', async ({ page }) => { + // Same word twice with empty saved context — genuinely ambiguous. + await page.evaluate(() => { + const host = document.createElement('div'); + host.id = '__amb_host'; + host.innerHTML = '

foo bravo bar

baz bravo qux

'; + document.body.appendChild(host); + const stored = { comments: [{ + id: 'amb-2', type: 'highlight', author: 'T', text: 'which bravo', + color: '#f59e0b', anchor: { exact: 'bravo', prefix: '', suffix: '' }, + page: 'annotate-demo:/', resolved: false, replies: [], + createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + }]}; + localStorage.setItem('annotate:annotate-demo', JSON.stringify(stored)); + window.Annotate.refresh(); + }); + await expect(page.locator('mark[data-an="amb-2"]')).toHaveCount(0); + await expect(page.locator('.an-unanchored-pill')).toHaveCount(1); + }); +}); + +// ============================================================ +// MARKER KEYBOARD ACCESSIBILITY +// ============================================================ +test.describe('Marker keyboard accessibility', () => { + async function seedPin(page) { + await page.evaluate(() => { + const stored = { comments: [{ + id: 'kbd-pin', type: 'pin', author: 'T', text: 'Keyboard pin', + color: '#f59e0b', geom: { kind: 'pin', selector: 'body', x: 0.5, y: 0.5 }, + page: 'annotate-demo:/', resolved: false, replies: [], + createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + }]}; + localStorage.setItem('annotate:annotate-demo', JSON.stringify(stored)); + window.Annotate.refresh(); + }); + await page.waitForTimeout(300); + return page.locator('.an-pin'); + } + + test('pins are focusable and open their comment with Enter', async ({ page }) => { + const pin = await seedPin(page); + await expect(pin).toHaveCount(1); + await pin.focus(); + await expect(pin).toBeFocused(); + await page.keyboard.press('Enter'); + await expect(page.locator('#__an_panel')).toHaveClass(/an-open/); + await expect(page.locator('.an-card.an-active')).toHaveCount(1); + }); + + test('pins are focusable and open their comment with Space', async ({ page }) => { + const pin = await seedPin(page); + await pin.focus(); + await page.keyboard.press('Space'); + await expect(page.locator('#__an_panel')).toHaveClass(/an-open/); + await expect(page.locator('.an-card.an-active')).toHaveCount(1); + }); + + test('annotation markers expose accessible names', async ({ page }) => { + const pin = await seedPin(page); + await expect(pin).toHaveAttribute('aria-label', expect.stringContaining('comment #1')); + }); + + test('shape badges are focusable and open their comment with Enter', async ({ page }) => { + await page.evaluate(() => { + const stored = { comments: [{ + id: 'kbd-rect', type: 'shape', author: 'T', text: 'Keyboard shape', + color: '#f59e0b', + geom: { kind: 'rect', selector: 'header.hero', x: 0.1, y: 0.1, w: 0.3, h: 0.2 }, + page: 'annotate-demo:/', resolved: false, replies: [], + createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + }]}; + localStorage.setItem('annotate:annotate-demo', JSON.stringify(stored)); + window.Annotate.refresh(); + }); + await page.waitForTimeout(300); + const badge = page.locator('#__an_overlay [tabindex="0"]').first(); + await expect(badge).toHaveCount(1); + await badge.focus(); + await page.keyboard.press('Enter'); + await expect(page.locator('#__an_panel')).toHaveClass(/an-open/); + await expect(page.locator('.an-card.an-active')).toHaveCount(1); + }); +}); + // ============================================================ // ARCHITECTURAL IMPROVEMENTS // ============================================================ test.describe('Architectural improvements', () => { - test('comment IDs are distinct when import assigns new IDs to id-less entries', async ({ page }) => { - // importComments() calls uid() for every comment whose id is null. - // Verify all 20 assigned IDs are unique. - const storeKey = 'annotate:annotate-demo'; - const allUnique = await page.evaluate((key) => { + test('import preserves distinct IDs for a batch of well-formed comments', async ({ page }) => { + // End-to-end: feed 20 distinct, valid records through the real import path + // (via the file-agnostic hook) and confirm all 20 land with distinct IDs. + const res = await page.evaluate(() => { + const key = Object.keys(localStorage).find(k => k.startsWith('annotate:')); + const stored = JSON.parse(localStorage.getItem(key) || '{"comments":[]}'); + localStorage.setItem(key, JSON.stringify({ comments: [] })); + window.Annotate.refresh(); const batch = []; for (let i = 0; i < 20; i++) { batch.push({ - // id explicitly null so importComments regenerates each one - id: null, type: 'pin', author: 'Test', text: 'C' + i, - color: '#f59e0b', page: 'annotate-demo:/', + id: 'batch-' + i, type: 'pin', author: 'Test', text: 'C' + i, + color: '#f59e0b', geom: { kind: 'pin', selector: 'body', x: 0.1 * (i % 10), y: 0.5 }, resolved: false, replies: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); } - // importComments reads existing IDs, so clear state first - const stored = { comments: [] }; - localStorage.setItem(key, JSON.stringify(stored)); - // Call importComments via the internal path by crafting a data object - // that matches what pickImportFile feeds to it: - const data = { annotate: '1.0.1', kind: 'annotate-export', page: 'annotate-demo:/', comments: batch }; - // We can't call importComments directly, so simulate via the reader callback - // by writing to storage with null ids and then using Annotate.refresh() - // which loads them. IDs remain null in storage. - // Instead: verify uid() itself is collision-free by generating 50 with the same logic - function uid() { - if (window.crypto && window.crypto.getRandomValues) { - var arr = new Uint32Array(3); - window.crypto.getRandomValues(arr); - return 'c' + arr[0].toString(36) + arr[1].toString(36) + arr[2].toString(36); - } - return 'c' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10); - } - const ids = Array.from({ length: 50 }, uid); - return new Set(ids).size === 50; - }, storeKey); - expect(allUnique).toBe(true); + window.Annotate._annotateImportForTest({ annotate: '1.2.0', kind: 'annotate-export', page: '/', comments: batch }); + const after = window.Annotate.comments(); + return { count: after.length, ids: after.map(c => c.id), allDistinct: new Set(after.map(c => c.id)).size === after.length }; + }); + expect(res.count).toBe(20); + expect(res.allDistinct).toBe(true); + expect(res.ids).toContain('batch-0'); + expect(res.ids).toContain('batch-19'); + }); + + test('import rejects id-less records instead of silently reassigning', async ({ page }) => { + // A record with no string id cannot be deduplicated or deep-linked, so it + // is rejected rather than mutated — import surfaces an error toast. + const before = await page.evaluate(() => window.Annotate.comments().length); + const res = await page.evaluate(() => { + window.Annotate._annotateImportForTest({ + annotate: '1.2.0', kind: 'annotate-export', page: '/', + comments: [{ + type: 'pin', author: 'X', text: 'no id', color: '#f59e0b', + geom: { kind: 'pin', selector: 'body', x: 0.3, y: 0.3 }, + resolved: false, replies: [], + createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + }], + }); + return { count: window.Annotate.comments().length, errored: !!document.querySelector('.an-toast.an-error') }; + }); + expect(res.count).toBe(before); + expect(res.errored).toBe(true); }); test('pin geom stores viewport dimensions at draw time', async ({ page }) => { From 9519e584dc92dfe6ead64b76dd961f8a325a243f Mon Sep 17 00:00:00 2001 From: Akash Goswami Date: Sun, 4 Oct 2026 15:08:11 +0200 Subject: [PATCH 2/2] Fix review findings: preserve unsaved work, project-wide dedup, input caps Addresses the codex advisor's review of the hardening commit plus a follow-up review of the fixes themselves. Storage: - Keep an in-memory `unsaved` store and overlay it in pageComments() so work that failed to persist (storage denied/quota) survives edits, refresh(), storage sync and imports instead of being silently discarded. - patchComment() falls back to the unsaved copy and re-stages it; removeComment() clears it; clear() drops the current page's unsaved records. - Import dedup builds the existing-ID set from the whole project (all pages), not just the current route, so cross-page edits/deletes can't collide. Validation & import: - Enforce comment/reply/thread caps at input time (5000/5000/500) and keep the import/storage gate in lock-step so native exports always round-trip. - Validate stored records on load (skip, don't crash on, malformed entries); enforce type<->geometry kind consistency and finite rect/circle dimensions. - isValidAnchor() guards null/missing anchors and uses a generous exact cap so legitimate highlights are never hidden. - Failed import writes keep the imported records in memory and surface the banner. SPA & anchoring: - Split load() into syncPageKey() + loadData() so storage events/import/clear no longer switch PAGE identity mid-route; only boot/refresh() re-sync the route. - countOccurrences() counts overlapping matches individually (ana in banana = 2). - anchorsMoved() treats present->missing as a move; observe anchor elements and reconcile observers to avoid leaking detached subtrees. A11y & polish: - Highlight marks and the unanchored pill are keyboard-operable (role=button, tabindex=0) and open their comment with Enter/Space, moving focus into the card. - Undo toast is guarded against double-activation; submitReply preserves the draft and explains when the thread cap refuses a reply. Tests: add `_annotatePatchForTest` / `_annotateCountOccurrencesForTest` hooks and 11 regression tests covering the above. --- annotate.js | 217 ++++++++++++++++++++++++++++++++++------- tests/annotate.spec.js | 200 +++++++++++++++++++++++++++++++++++++ 2 files changed, 383 insertions(+), 34 deletions(-) diff --git a/annotate.js b/annotate.js index 2a72616..06deb7e 100644 --- a/annotate.js +++ b/annotate.js @@ -201,8 +201,34 @@ } return "c" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 10); } + // Comment / reply size caps — enforced BOTH when accepting input and when + // validating imports, so the app can never export something it would later + // reject. Kept as named constants so the two stay in lock-step. + var MAX_COMMENT_TEXT = 5000; + var MAX_REPLY_TEXT = 5000; + var MAX_REPLIES = 500; + + // In-memory store of comments whose last write was refused by localStorage + // (private mode, quota…). They stay visible, editable and exportable even + // though they are not persisted; pageComments() overlays them back in. + var unsaved = {}; + function pageComments() { - return dbRead().comments.filter(function (c) { return c.page === PAGE; }); + // Validate stored records before touching properties: a corrupt/legacy + // entry (e.g. [null]) must be skipped rather than crash rendering. + var listed = dbRead().comments.filter(function (c) { + return isValidComment(c) && c.page === PAGE; + }); + // Reoverlay unsaved records for this page onto the persisted list (upsert + // by id) so work that never reached storage is not lost on reload. + Object.keys(unsaved).forEach(function (id) { + var u = unsaved[id]; + if (!u || u.page !== PAGE) return; + var idx = -1; + for (var i = 0; i < listed.length; i++) if (listed[i].id === id) { idx = i; break; } + if (idx >= 0) listed[idx] = u; else listed.push(u); + }); + return listed; } function createComment(draft) { var d = dbRead(), now = new Date().toISOString(); @@ -212,7 +238,7 @@ url: location.href, type: draft.type || "note", author: state.author || "Anonymous", - text: String(draft.text || "").slice(0, 5000), + text: String(draft.text || "").slice(0, MAX_COMMENT_TEXT), color: draft.color || state.color, anchor: draft.anchor || null, geom: draft.geom || null, @@ -222,36 +248,54 @@ updatedAt: now, }; d.comments.push(c); - dbWrite(d); // surfaces the banner if the write was refused; the caller + var ok = dbWrite(d); // surfaces the banner if the write was refused; the caller // keeps the comment in state so it stays visible & exportable + if (!ok) unsaved[c.id] = c; // keep it so edits/reloads don't lose it return c; } function patchComment(id, changes) { var d = dbRead(); var c = d.comments.filter(function (x) { return x.id === id; })[0]; - if (!c) return null; - if (typeof changes.text === "string") c.text = changes.text.slice(0, 5000); + var fromUnsaved = false; + if (!c) { + // A comment created while storage was unavailable never reached the + // persisted list; fall back to our in-memory copy so it stays editable. + if (unsaved[id]) { c = unsaved[id]; fromUnsaved = true; } else return null; + } + if (typeof changes.text === "string") c.text = changes.text.slice(0, MAX_COMMENT_TEXT); if (typeof changes.resolved === "boolean") c.resolved = changes.resolved; if (typeof changes.color === "string") c.color = changes.color; - if (changes.reply) c.replies.push(changes.reply); + if (changes.reply) { + changes.reply.text = String(changes.reply.text || "").slice(0, MAX_REPLY_TEXT); + // Enforce the thread cap at input too — a comment can never grow beyond + // the number imports will accept, so native exports always round-trip. + if (c.replies.length < MAX_REPLIES) c.replies.push(changes.reply); + } if (changes.editReply) { var ri = c.replies.findIndex(function (r) { return r.id === changes.editReply.id; }); - if (ri >= 0) { c.replies[ri] = Object.assign({}, c.replies[ri], { text: changes.editReply.text }); } + if (ri >= 0) c.replies[ri] = Object.assign({}, c.replies[ri], + { text: String(changes.editReply.text || "").slice(0, MAX_REPLY_TEXT) }); } if (changes.deleteReply) { c.replies = c.replies.filter(function (r) { return r.id !== changes.deleteReply; }); } c.updatedAt = new Date().toISOString(); + if (fromUnsaved) d.comments.push(c); // stage so the write can persist it if (!dbWrite(d)) { - // Mirror the change in memory so the UI stays consistent with what the - // user just did, even though it did not reach storage. + // Keep the (updated) in-memory copy authoritative and mirror it into the + // live list so the UI reflects what the user just did — even though the + // change did not reach storage. + unsaved[id] = c; var live = state.comments.filter(function (x) { return x.id === id; })[0]; if (live) mergeComment(c); renderAll(); renderPanel(); + } else { + delete unsaved[id]; } return c; } function removeComment(id) { + delete unsaved[id]; // it must not linger in the recoverable memory set var d = dbRead(); d.comments = d.comments.filter(function (c) { return c.id !== id; }); dbWrite(d); @@ -849,7 +893,9 @@ if (opts.action) { var btn = el("button", { class: "an-taction", text: opts.action }); btn.addEventListener("click", function () { - if (expired) return; + // Guard on `acted` too: a rapid second click within the removal window + // (220ms) must not re-run onAction (e.g. restoring a comment twice). + if (acted || expired) return; acted = true; clearTimeout(timer); t.classList.add("an-out"); @@ -1024,7 +1070,14 @@ if (c.resolved && !showResolvedVisuals()) continue; if (c.type === "highlight" || !c.geom || !c.geom.selector) continue; // marks reflow with the DOM var ae = resolveAnchorEl(c.geom.selector); - if (!ae) continue; + var wasAnchored = !!anchorCache[c.id]; + if (!ae) { + // present -> missing is a move: the last render drew in place, so a + // re-render must now replace it with the missing-anchor badge instead + // of leaving the old marker visible. + if (wasAnchored) return true; + continue; + } var prev = anchorCache[c.id]; var b = docBox(ae); if (!prev || Math.abs(prev.x - b.x) > 0.5 || Math.abs(prev.y - b.y) > 0.5 || @@ -1033,6 +1086,35 @@ return false; } + // Observe each resolved anchor element directly (in addition to ), so + // an anchor that moves without changing 's box still re-renders. + var _anchorRO = null; + var observedAnchors = {}; // comment id -> element currently observed + function observeAnchors() { + if (!_anchorRO) return; + var current = {}; // comment id -> live anchor element now + for (var i = 0; i < state.comments.length; i++) { + var c = state.comments[i]; + if (!c.geom || !c.geom.selector || c.type === "highlight") continue; + var ae = resolveAnchorEl(c.geom.selector); + if (ae) current[c.id] = ae; + } + // Unobserve any anchor that is no longer the live one for its comment, so + // SPA-removed subtrees aren't retained through strong ResizeObserver refs. + for (var id in observedAnchors) { + if (observedAnchors[id] !== current[id]) { + try { _anchorRO.unobserve(observedAnchors[id]); } catch (e) {} + delete observedAnchors[id]; + } + } + for (var nid in current) { + if (!observedAnchors[nid]) { + observedAnchors[nid] = current[nid]; + try { _anchorRO.observe(current[nid]); } catch (e2) {} + } + } + } + function showResolvedVisuals() { return state.filter !== "open"; } // Make a DOM or SVG marker keyboard-operable: focusable, announced by name, @@ -1049,13 +1131,19 @@ }); } - // Count full occurrences of a needle in the page text. + // Count full occurrences of a needle in the page text. Overlapping matches + // each count (advancing by one, not by the needle length, so e.g. "ana" + // inside "banana" is counted twice rather than once). + function countOccurrences(full, needle) { + if (!needle) return 0; + var count = 0, at = 0; + while ((at = full.indexOf(needle, at)) >= 0) { count++; if (count > 1) return count; at += 1; } + return count; + } function countMatches(needle) { var nodes = getTextNodes(), full = ""; for (var i = 0; i < nodes.length; i++) full += nodes[i].nodeValue; - var count = 0, at = 0; - while (needle && (at = full.indexOf(needle, at)) >= 0) { count++; at += needle.length; } - return count; + return countOccurrences(full, needle); } // Decide whether a saved quote can be highlighted honestly: @@ -1083,6 +1171,7 @@ pill.style.left = "16px"; pill.style.pointerEvents = "auto"; pill.addEventListener("click", function (ev) { ev.stopPropagation(); focusComment(c.id, true); }); + makeMarkerInteractive(pill, c, idx, "Unanchored note"); pinLayer.appendChild(pill); } @@ -1113,6 +1202,7 @@ m.addEventListener("click", function (ev) { ev.stopPropagation(); focusComment(c.id, true); }); + makeMarkerInteractive(m, c, state.comments.indexOf(c) + 1, "Highlight"); if (c.id === state.activeId) m.classList.add("an-active"); }); } @@ -1126,6 +1216,7 @@ renderBlock(c); } }); + observeAnchors(); updateCount(); } @@ -1689,7 +1780,7 @@ window.addEventListener("resize", function () { clearTimeout(rt); rt = setTimeout(renderAll, 150); }); // Keep tabs in sync: reload annotations when another tab writes to storage window.addEventListener("storage", function (e) { - if (e.key === STORE_KEY) load(); + if (e.key === STORE_KEY) loadData(); }); if (document.fonts && document.fonts.ready) document.fonts.ready.then(function () { setTimeout(renderAll, 60); }); window.addEventListener("load", function () { setTimeout(renderAll, 120); }); @@ -1709,6 +1800,7 @@ if (_roRaf) return; _roRaf = requestAnimationFrame(function () { _roRaf = 0; relayout(); }); }); + _anchorRO = _ro; _ro.observe(document.body); // capture:true so scrolls inside nested scrollable containers count too document.addEventListener("scroll", function () { @@ -2029,7 +2121,16 @@ if (!rin.value.trim()) return; var reply = { id: uid(), author: state.author || "Anonymous", text: rin.value.trim(), createdAt: new Date().toISOString() }; var updated = patchComment(c.id, { reply: reply }); - if (updated) { rin.value = ""; mergeComment(updated); renderPanel(); } + if (!updated) return; + // patchComment refuses the reply once the thread hits MAX_REPLIES but + // still returns the (unchanged) comment. Detect that so we don't discard + // the user's draft or pretend the reply was saved. + var added = updated.replies.some(function (r) { return r.id === reply.id; }); + if (!added) { + toast("This thread has reached its reply limit.", { kind: "error" }); + return; + } + rin.value = ""; mergeComment(updated); renderPanel(); } rsend.addEventListener("click", function (e) { e.stopPropagation(); submitReply(); }); rin.addEventListener("keydown", function (e) { @@ -2160,8 +2261,12 @@ function isFiniteNum(v) { return typeof v === "number" && isFinite(v); } function isValidGeom(g) { if (!g || typeof g !== "object" || typeof g.kind !== "string") return false; - if (g.kind === "pin" || g.kind === "rect" || g.kind === "circle") - return isFiniteNum(g.x) && isFiniteNum(g.y) && (g.w === undefined || isFiniteNum(g.w)) && (g.h === undefined || isFiniteNum(g.h)); + if (g.kind === "pin") + return isFiniteNum(g.x) && isFiniteNum(g.y); + // Rect/circle are positioned and sized relative to their anchor element; + // both dimensions must be finite numbers or rendering would produce NaN. + if (g.kind === "rect" || g.kind === "circle") + return isFiniteNum(g.x) && isFiniteNum(g.y) && isFiniteNum(g.w) && isFiniteNum(g.h); if (g.kind === "pen") return Array.isArray(g.points) && g.points.length >= 2 && g.points.length <= 10000 && g.points.every(function (p) { return Array.isArray(p) && p.length === 2 && isFiniteNum(p[0]) && isFiniteNum(p[1]); }); @@ -2169,28 +2274,44 @@ return false; // unknown geometry kinds are rejected, not guessed at } var VALID_TYPES = { highlight: 1, shape: 1, pin: 1, pen: 1, note: 1, block: 1 }; + // Which geometry kinds each comment type may carry. A "shape" is a rect or + // circle; a "pin" carries a pin geometry — never allow a mismatched pairing. + var GEOM_KINDS = { shape: { rect: 1, circle: 1 }, pin: { pin: 1 }, pen: { pen: 1 }, block: { block: 1 } }; + function geomMatchesType(type, g) { + if (type === "highlight" || type === "note") return g === null || g === undefined; + var kinds = GEOM_KINDS[type]; + return !!(g && kinds && g.kind && kinds[g.kind] === 1); + } function isValidAnchor(a) { + if (!a || typeof a !== "object") return false; if (typeof a.exact !== "string") return false; - if (a.exact.length > 10000) return false; + // Generous guard against absurd/bogus quotes — far above any realistic + // native highlight, so a legitimately created highlight is never hidden + // when validating stored records on load. + if (a.exact.length > 1000000) return false; return (typeof a.prefix === "string") && (typeof a.suffix === "string"); } function isValidReply(r) { return r && typeof r.id === "string" && r.id.length < 128 && - typeof r.author === "string" && typeof r.text === "string" && r.text.length <= 5000 && + typeof r.author === "string" && typeof r.text === "string" && r.text.length <= MAX_REPLY_TEXT && typeof r.createdAt === "string"; } function isValidType(t) { return typeof t === "string" && VALID_TYPES[t] === 1; } // Single schema gate for anything read back from storage or imported from a - // file. Returns true for well-formed records only. + // file. Returns true for well-formed records only. Limits here are kept in + // lock-step with what input accepts (MAX_COMMENT_TEXT / MAX_REPLIES), so the + // app can never export a record this gate would reject. function isValidComment(c) { if (!c || typeof c !== "object") return false; if (typeof c.id !== "string" || !c.id || c.id.length > 128) return false; if (!isValidType(c.type)) return false; - if (typeof c.author !== "string" || typeof c.text !== "string" || c.text.length > 5000) return false; + if (typeof c.author !== "string" || typeof c.text !== "string" || c.text.length > MAX_COMMENT_TEXT) return false; if (typeof c.color !== "string") return false; if (c.anchor !== null && c.anchor !== undefined && !isValidAnchor(c.anchor)) return false; + if (c.type === "highlight" && !isValidAnchor(c.anchor)) return false; + if (!geomMatchesType(c.type, c.geom)) return false; if (c.geom !== null && c.geom !== undefined && !isValidGeom(c.geom)) return false; - if (!Array.isArray(c.replies) || c.replies.length > 500 || + if (!Array.isArray(c.replies) || c.replies.length > MAX_REPLIES || !c.replies.every(isValidReply)) return false; return true; } @@ -2203,13 +2324,17 @@ // Warn if the export came from a different page if (data.page && data.page !== PAGE) toast("These comments were from a different page — positions may not match.", { kind: "info", duration: 6000 }); - var existing = {}; - state.comments.forEach(function (c) { existing[c.id] = true; }); + // Existing IDs span the WHOLE project (all pages), not just the current + // route — so importing one page's export onto another page cannot create + // colliding ids that allow cross-page edits/deletes. + var exists = Object.create(null); + dbRead().comments.forEach(function (x) { if (x && typeof x.id === "string") exists[x.id] = true; }); + Object.keys(unsaved).forEach(function (id) { exists[id] = true; }); var prepared = [], skipped = 0, seenIds = {}; incoming.forEach(function (c) { if (!isValidComment(c)) { skipped++; return; } // malformed record — drop if (seenIds[c.id]) { skipped++; return; } // duplicate ID within the batch - if (existing[c.id]) { skipped++; return; } // already imported to this project + if (exists[c.id]) { skipped++; return; } // already imported to this project (any page) seenIds[c.id] = true; var copy = JSON.parse(JSON.stringify(c)); copy.page = PAGE; @@ -2221,8 +2346,12 @@ } var d = dbRead(); d.comments = d.comments.concat(prepared); - dbWrite(d); - load(); + if (!dbWrite(d)) { + // The write was refused: keep the imported records in memory so they + // stay visible/exportable, and surface the unsaved banner. + prepared.forEach(function (c) { unsaved[c.id] = c; }); + } + loadData(); var ok = prepared.length + (skipped ? " (" + skipped + " skipped)" : ""); toast("Imported " + ok + " comment" + (prepared.length === 1 ? "" : "s"), { kind: "success" }); } @@ -2247,6 +2376,9 @@ toast("Comment deleted", { kind: "info", action: "Undo", duration: 5000, onAction: function () { + // Idempotent: only the first activation of Undo restores. A second + // click (or a stale activation) must no-op instead of re-inserting. + if (!pendingDeletes[c.id]) return; delete pendingDeletes[c.id]; var restored = Object.assign({}, c, { replies: (c.replies || []).slice() }); // Only re-show the comment if we're still on the page it belongs to; @@ -2469,22 +2601,30 @@ } if (targetY != null) window.scrollTo({ top: Math.max(0, targetY), behavior: "smooth" }); } + if (scrollToContent) { + // Keyboard activation (Enter/Space on a marker) should land focus inside + // the opened comment, not leave it dangling on the page. + var fc = listEl.querySelector('[data-id="' + id + '"]'); + if (fc) { fc.setAttribute("tabindex", "-1"); fc.focus({ preventScroll: true }); } + } } // ========================================================================== // BOOT // ========================================================================== var firstLoad = true; - function load() { - // SPA navigation: when the host pushes a new route and calls refresh(), - // recompute a non-explicit page key so each route keeps its own comments. - // An in-flight draft from the previous page is discarded. + // Route identity changes ONLY here — during boot or an explicit public + // refresh(). Internal reloads (storage sync, import, clear) must not switch + // PAGE mid-route, or they'd discard the current route's drafts/comments. + function syncPageKey() { var newKey = currentPageKey(); if (newKey !== PAGE) { if (pendingDraft || drawing) { drawing = null; cancelDraft(); } PAGE = newKey; state.activeId = null; } + } + function loadData() { if (pendingDraft || drawing) return; state.comments = pageComments().filter(function (c) { return !pendingDeletes[c.id]; }); renderAll(); @@ -2499,6 +2639,10 @@ } } } + function load() { + syncPageKey(); + loadData(); + } function boot() { buildUI(); @@ -2542,6 +2686,8 @@ // Test / automation hooks (not part of the documented API) _annotateImportForTest: function (data) { importComments(data); }, _annotateOpenImportForTest: function () { pickImportFile(); }, + _annotatePatchForTest: function (id, changes) { return patchComment(id, changes); }, + _annotateCountOccurrencesForTest: function (full, needle) { return countOccurrences(full, needle); }, _annotateCreateForTest: function (draft) { var c = createComment(draft); state.comments.push(c); @@ -2552,8 +2698,11 @@ clear: function () { var d = dbRead(); d.comments = d.comments.filter(function (c) { return c.page !== PAGE; }); + Object.keys(unsaved).forEach(function (id) { + if (unsaved[id] && unsaved[id].page === PAGE) delete unsaved[id]; + }); dbWrite(d); - load(); + loadData(); }, }; diff --git a/tests/annotate.spec.js b/tests/annotate.spec.js index aed6a34..79a743c 100644 --- a/tests/annotate.spec.js +++ b/tests/annotate.spec.js @@ -1796,3 +1796,203 @@ test.describe('Architectural improvements', () => { expect(count2).toBeGreaterThan(count1); }); }); + +// ============================================================ +// REVIEW-FIX REGRESSIONS (from the codex advisor review of +// commit 8379d2b). Each test pins one identified defect. +// ============================================================ +test.describe('Review fixes', () => { + test('an unsaved comment survives refresh() while storage is denied', async ({ page }) => { + const res = await page.evaluate(() => { + const orig = Storage.prototype.setItem; + Storage.prototype.setItem = function () { throw new DOMException('denied', 'SecurityError'); }; + try { + window.Annotate._annotateCreateForTest({ type: 'note', text: 'unsaved-fresh', color: '#f59e0b' }); + const before = window.Annotate.comments().some(c => c.text === 'unsaved-fresh'); + window.Annotate.refresh(); + const after = window.Annotate.comments().some(c => c.text === 'unsaved-fresh'); + return { before, after }; + } finally { Storage.prototype.setItem = orig; } + }); + expect(res.before).toBe(true); + // refresh() must not discard work that never reached storage. + expect(res.after).toBe(true); + }); + + test('editing an unsaved comment still applies the change in memory', async ({ page }) => { + const res = await page.evaluate(() => { + const orig = Storage.prototype.setItem; + Storage.prototype.setItem = function () { throw new DOMException('denied', 'SecurityError'); }; + try { + const c = window.Annotate._annotateCreateForTest({ type: 'note', text: 'orig', color: '#f59e0b' }); + const updated = window.Annotate._annotatePatchForTest(c.id, { text: 'edited' }); + const inState = window.Annotate.comments().some(x => x.id === c.id && x.text === 'edited'); + return { patchResult: updated ? updated.text : null, inState }; + } finally { Storage.prototype.setItem = orig; } + }); + // patchComment must not return null just because the record is not in storage. + expect(res.patchResult).toBe('edited'); + expect(res.inState).toBe(true); + }); + + test('import skips an id that already exists on a different page of the project', async ({ page }) => { + const res = await page.evaluate(() => { + const key = 'annotate:annotate-demo'; + const mk = (id, pg, text) => ({ + id, type: 'pin', page: pg, author: 'X', text, color: '#f59e0b', + geom: { kind: 'pin', selector: 'body', x: 0.2, y: 0.2 }, + resolved: false, replies: [], + createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + }); + localStorage.setItem(key, JSON.stringify({ comments: [mk('collide', 'annotate-demo:/other-route', 'on other page')] })); + window.Annotate.refresh(); + window.Annotate._annotateImportForTest({ + annotate: '1.2.0', kind: 'annotate-export', page: '/', + comments: [mk('collide', '/', 'incoming colliding id')], + }); + return window.Annotate.comments().map(c => c.text); + }); + // The same ID already lives on another page of this project — do not import. + expect(res).not.toContain('incoming colliding id'); + }); + + test('replies are truncated and thread caps enforced at input time', async ({ page }) => { + const res = await page.evaluate(() => { + const c = window.Annotate._annotateCreateForTest({ type: 'note', text: 'x', color: '#f59e0b' }); + const longText = 'a'.repeat(6000); + const updated = window.Annotate._annotatePatchForTest(c.id, { + reply: { id: 'r1', author: 'X', text: longText, createdAt: new Date().toISOString() }, + }); + const replyTextLen = updated.replies[0].text.length; + // Drive a thread up to the cap and confirm the next reply is refused. + let c2 = window.Annotate._annotateCreateForTest({ type: 'note', text: 'capthread', color: '#f59e0b' }); + for (let i = 0; i < 500; i++) { + c2 = window.Annotate._annotatePatchForTest(c2.id, { + reply: { id: 'r' + i, author: 'X', text: 'ok', createdAt: new Date().toISOString() }, + }); + } + const capLen = c2.replies.length; + const extra = window.Annotate._annotatePatchForTest(c2.id, { + reply: { id: 'r-extra', author: 'X', text: 'should-not-add', createdAt: new Date().toISOString() }, + }); + const afterExtra = extra.replies.length; + return { replyTextLen, capLen, afterExtra }; + }); + expect(res.replyTextLen).toBe(5000); + expect(res.capLen).toBe(500); + expect(res.afterExtra).toBe(500); // 501st reply refused + }); + + test('malformed stored records are skipped on load instead of crashing', async ({ page }) => { + const res = await page.evaluate(() => { + const key = 'annotate:annotate-demo'; + const existing = JSON.parse(localStorage.getItem(key) || '{"comments":[]}'); + const pageKey = existing.comments[0] ? existing.comments[0].page : 'annotate-demo:/'; + const good = { id: 'good-1', type: 'pin', page: pageKey, author: 'X', text: 'fine', color: '#f59e0b', + geom: { kind: 'pin', selector: 'body', x: 0.5, y: 0.5 }, resolved: false, replies: [], + createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; + localStorage.setItem(key, JSON.stringify({ comments: [ + null, + good, + { id: 'bad-geom', type: 'pin', page: pageKey, author: 'X', text: 'x', color: '#f59e0b', + geom: { kind: 'pin', selector: 'body', x: null, y: null }, resolved: false, replies: [], + createdAt: '', updatedAt: '' }, + { id: 'bad-type-geom', type: 'shape', page: pageKey, author: 'X', text: 'y', color: '#f59e0b', + geom: { kind: 'pin', selector: 'body', x: 0.5, y: 0.5 }, resolved: false, replies: [], + createdAt: '', updatedAt: '' }, + ] })); + let threw = null; + try { window.Annotate.refresh(); } catch (e) { threw = e && e.name; } + const texts = window.Annotate.comments().map(c => c.text); + return { threw, texts }; + }); + expect(res.threw).toBe(null); + expect(res.texts).toContain('fine'); + expect(res.texts).not.toContain('x'); + expect(res.texts).not.toContain('y'); + }); + + test('a storage sync event does not switch page identity before refresh()', async ({ page }) => { + const res = await page.evaluate(() => { + const c = window.Annotate._annotateCreateForTest({ type: 'note', text: 'routepin', color: '#f59e0b' }); + history.pushState({}, '', '/other-route'); + const key = Object.keys(localStorage).find(k => k.startsWith('annotate:')); + window.dispatchEvent(new StorageEvent('storage', { key, newValue: localStorage.getItem(key), storageArea: localStorage })); + return window.Annotate.comments().map(x => x.text); + }); + // The comment belongs to the original route; a sync load must not switch PAGE. + expect(res).toContain('routepin'); + }); + + test('overlapping occurrences are counted individually', async ({ page }) => { + // "ana" occurs twice inside "banana" (index 1 and index 3). Advancing the + // scan by the needle length used to miss the second, overlapping hit. + const n = await page.evaluate(() => window.Annotate._annotateCountOccurrencesForTest('banana', 'ana')); + expect(n).toBe(2); + }); + + test('activating undo twice does not duplicate the comment', async ({ page }) => { + const res = await page.evaluate(() => { + const c = window.Annotate._annotateCreateForTest({ type: 'note', text: 'doomed', color: '#f59e0b' }); + const card = document.querySelector('[data-id="' + c.id + '"]'); + card.querySelector('.an-mini.an-danger').click(); + const undoBtn = document.querySelector('.an-taction'); + undoBtn.click(); + undoBtn.click(); // double activation in the same tick + const inMemory = window.Annotate.comments().filter(x => x.id === c.id).length; + const key = Object.keys(localStorage).find(k => k.startsWith('annotate:')); + const inStorage = JSON.parse(localStorage.getItem(key) || '{"comments":[]}').comments.filter(x => x.id === c.id).length; + return { inMemory, inStorage }; + }); + expect(res.inMemory).toBe(1); + expect(res.inStorage).toBeLessThanOrEqual(1); + }); + + test('highlight marks are keyboard-operable (role=button, tabindex=0)', async ({ page }) => { + const res = await page.evaluate(() => { + const p = document.createElement('p'); + p.textContent = 'a uniquely quotable phrase for keyboard testing'; + document.body.appendChild(p); + const c = window.Annotate._annotateCreateForTest({ + type: 'highlight', color: '#f59e0b', text: 'kbd', + anchor: { prefix: '', exact: 'uniquely quotable phrase for keyboard testing', suffix: '' }, + }); + const mark = document.querySelector('mark.an-mark[data-an="' + c.id + '"]'); + return { role: mark && mark.getAttribute('role'), tabindex: mark && mark.getAttribute('tabindex') }; + }); + expect(res.role).toBe('button'); + expect(res.tabindex).toBe('0'); + }); + + test('stored highlight with a null anchor is skipped without crashing', async ({ page }) => { + const res = await page.evaluate(() => { + const key = 'annotate:annotate-demo'; + const good = { id: 'h-good', page: 'annotate-demo:/', type: 'note', author: 'X', text: 'ok', color: '#f59e0b', resolved: false, replies: [], createdAt: '', updatedAt: '' }; + localStorage.setItem(key, JSON.stringify({ comments: [ + { id: 'h-null', page: 'annotate-demo:/', type: 'highlight', author: 'X', text: 'x', color: '#f59e0b', anchor: null, resolved: false, replies: [], createdAt: '', updatedAt: '' }, + good, + ] })); + let threw = null; + try { window.Annotate.refresh(); } catch (e) { threw = e && e.name; } + return { threw, texts: window.Annotate.comments().map(c => c.text) }; + }); + expect(res.threw).toBe(null); + expect(res.texts).toEqual(['ok']); + }); + + test('clear() also removes comments created while storage was denied', async ({ page }) => { + const res = await page.evaluate(() => { + const orig = Storage.prototype.setItem; + Storage.prototype.setItem = function () { throw new DOMException('denied', 'SecurityError'); }; + try { + window.Annotate._annotateCreateForTest({ type: 'note', text: 'unsaved-clear', color: '#f59e0b' }); + const had = window.Annotate.comments().some(c => c.text === 'unsaved-clear'); + window.Annotate.clear(); + const after = window.Annotate.comments().some(c => c.text === 'unsaved-clear'); + return { had, after }; + } finally { Storage.prototype.setItem = orig; } + }); + expect(res.had).toBe(true); + expect(res.after).toBe(false); + }); +});