diff --git a/package.json b/package.json index 814a18e..49da289 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "node scripts/build.mjs", "check": "node scripts/check-extension.mjs", - "test": "node --test tests/enhancer-setup.test.mjs tests/document-title.test.mjs tests/metadata-doi.test.mjs tests/local-file-favicon.test.mjs tests/page-tab-labels.test.mjs tests/minimap.test.mjs tests/clickable-reference-links.test.mjs tests/printed-page-jump.test.mjs tests/markdown-copy.test.mjs tests/terminal-punctuation-selection.test.mjs tests/footnote-click-copy.test.mjs tests/chatgpt-summary-popup.test.mjs tests/chatgpt-pdf-relay.test.mjs tests/selection-gaps.test.mjs" + "test": "node --test tests/enhancer-setup.test.mjs tests/document-title.test.mjs tests/metadata-doi.test.mjs tests/local-file-favicon.test.mjs tests/page-tab-labels.test.mjs tests/minimap.test.mjs tests/clickable-reference-links.test.mjs tests/printed-page-jump.test.mjs tests/markdown-copy.test.mjs tests/terminal-punctuation-selection.test.mjs tests/footnote-click-copy.test.mjs tests/chatgpt-summary-popup.test.mjs tests/chatgpt-pdf-relay.test.mjs tests/selection-gaps.test.mjs tests/chatgpt-summary-handoff.test.mjs" }, "dependencies": { "pdfjs-dist": "6.3.289", diff --git a/src/content/chatgpt-summary.js b/src/content/chatgpt-summary.js index c33f010..6e393e2 100644 --- a/src/content/chatgpt-summary.js +++ b/src/content/chatgpt-summary.js @@ -7,17 +7,24 @@ function requestIdFromLocation() { } function findComposer() { - const composer = ( - document.querySelector("#prompt-textarea") || - document.querySelector('[contenteditable="true"][data-lexical-editor="true"]') || - document.querySelector('[contenteditable="true"][role="textbox"]') || - document.querySelector("textarea") - ); - if (!composer || !composer.isConnected || !composer.getClientRects().length) return null; - if (composer instanceof HTMLTextAreaElement) { - return !composer.disabled && !composer.readOnly ? composer : null; - } - return composer.isContentEditable ? composer : null; + const candidates = [ + document.querySelector("#prompt-textarea"), + document.querySelector('[contenteditable="true"][data-lexical-editor="true"]'), + document.querySelector('[contenteditable="true"][role="textbox"]'), + document.querySelector("textarea"), + ].filter((composer) => composer && composer.isConnected && ( + composer instanceof HTMLTextAreaElement + ? !composer.disabled && !composer.readOnly + : composer.isContentEditable + )); + // The active tab has a visible editor. When the tab/window is backgrounded, + // layout rects may not be available, but the connected editor is still usable. + return candidates.find((composer) => composer.getClientRects().length) || candidates[0] || null; +} + +function composerContainsPrompt(composer, prompt) { + const value = composer instanceof HTMLTextAreaElement ? composer.value : composer.textContent; + return value === prompt || (composer.innerText || "").replace(/\r\n/g, "\n") === prompt; } function findSendButton() { @@ -66,18 +73,35 @@ function waitFor(getValue, timeoutMs) { return new Promise((resolve) => { let observer; let timeout; + let finished = false; const finish = (value) => { - observer?.disconnect(); + if (finished) return; + finished = true; + observer.disconnect(); window.clearTimeout(timeout); + document.removeEventListener("visibilitychange", check); + window.removeEventListener("focus", check); resolve(value); }; const check = () => { + if (finished) return; const value = getValue(); if (value) finish(value); }; + const expire = () => { + // A background tab can be throttled before ChatGPT hydrates. Do not + // discard the pending prompt just because the user switched away. + if (document.visibilityState === "hidden" || document.hasFocus?.() === false) { + timeout = window.setTimeout(expire, timeoutMs); + } else { + finish(null); + } + }; observer = new MutationObserver(check); observer.observe(document, { childList: true, subtree: true, attributes: true }); - timeout = window.setTimeout(() => finish(null), timeoutMs); + document.addEventListener("visibilitychange", check); + window.addEventListener("focus", check); + timeout = window.setTimeout(expire, timeoutMs); check(); }); } @@ -89,6 +113,43 @@ function clearRequestHash() { } +function currentConversationUrl() { + const url = new URL(window.location.href); + return url.origin === "https://chatgpt.com" && /^\/c\/[a-z0-9-]+\/?$/i.test(url.pathname) + ? url.origin + url.pathname.replace(/\/$/, "") : null; +} + +// A fresh ChatGPT conversation obtains its /c/ URL after send. Record it only +// once it exists; subsequent summary requests reuse the attached PDF there. +async function rememberConversation(conversationKey) { + const url = await new Promise((resolve) => { + let elapsed = 0; + const check = () => { + const found = currentConversationUrl(); + if (found || elapsed >= 10 * 60 * 1000) { + clearInterval(interval); + resolve(found); + } + }; + const interval = setInterval(() => { + elapsed += 1000; + check(); + }, 1000); + check(); + }); + if (url) { + await chrome.storage.local.set({ + [conversationKey]: { status: "complete", url, updatedAt: Date.now() }, + }); + } else { + // Do not leave an indefinitely pending entry after navigation fails. + const stored = await chrome.storage.local.get(conversationKey); + if (stored[conversationKey]?.status === "pending") { + await chrome.storage.local.remove(conversationKey); + } + } +} + function findPdfInput() { return Array.from(document.querySelectorAll('input[type="file"]')).find((input) => { const accepts = (input.accept || "").toLowerCase(); @@ -151,19 +212,17 @@ async function attachPdf(file) { data.items.add(file); input.files = data.files; input.dispatchEvent(new Event("change", { bubbles: true })); - // An existing file selection is not enough: wait for ChatGPT's attachment - // chip before allowing the summary to be submitted. - const acknowledged = await waitFor(() => { - const displayed = Array.from(document.querySelectorAll( - '[data-testid*="attachment"], [data-testid*="file"], [title], [aria-label]', - )).some((element) => ( - (element.textContent || "").includes(file.name) || - (element.getAttribute("title") || "").includes(file.name) || - (element.getAttribute("aria-label") || "").includes(file.name) - )); - return displayed && !document.querySelector('[role="progressbar"]'); - }, 90000); - if (!acknowledged) throw new Error("ChatGPT did not confirm the PDF attachment"); + // Wait only until ChatGPT registers the attachment. Its upload can remain + // in progress: ChatGPT queues the message and sends it once the PDF is ready. + // Restrict this check to attachment UI, not arbitrary labels or prompt text. + const acknowledged = await waitFor(() => Array.from(document.querySelectorAll( + '[data-testid*="attachment"], [data-testid*="file"]', + )).some((element) => ( + (element.textContent || "").includes(file.name) || + (element.getAttribute("title") || "").includes(file.name) || + (element.getAttribute("aria-label") || "").includes(file.name) + )), 90000); + if (!acknowledged) throw new Error("ChatGPT did not register the PDF attachment"); } async function submitPendingSummary() { @@ -173,40 +232,51 @@ async function submitPendingSummary() { const stored = await chrome.storage.local.get(storageKey); const payload = stored[storageKey]; if (!payload?.prompt) return; - let composer = await waitFor(findComposer, 20000); - if (!composer) { - await chrome.storage.local.remove(storageKey); - clearRequestHash(); - return; - } + let composer; let port; try { + composer = await waitFor(findComposer, 90000); + if (!composer) throw new Error("ChatGPT composer unavailable"); + // Compose immediately, before transferring the PDF or waiting for its + // upload. A backgrounded or hydrated page must not lose the prompt. + setComposerText(composer, payload.prompt); if (payload.attachmentRequired) { const pdf = await receivePdf(requestId); port = pdf.port; await attachPdf(pdf.file); } - setComposerText(composer, payload.prompt); const sendButton = await waitFor(() => { const current = findComposer(); if (!current) return null; - if (current !== composer) { + if (current !== composer || !composerContainsPrompt(current, payload.prompt)) { composer = current; setComposerText(composer, payload.prompt); return null; } const button = findSendButton(); - return button && !button.disabled && button.getClientRects().length ? button : null; - }, 30000); + return button && !button.disabled && button.isConnected !== false && + (button.getClientRects().length || document.visibilityState === "hidden") + ? button : null; + }, 90000); if (!sendButton) throw new Error("ChatGPT send button unavailable"); sendButton.click(); + if (payload.attachmentRequired && payload.conversationKey) { + void rememberConversation(payload.conversationKey).catch(() => {}); + } port?.postMessage({ type: "finish" }); } catch (error) { port?.postMessage({ type: "cancel" }); + if (payload.attachmentRequired && payload.conversationKey) { + const current = await chrome.storage.local.get(payload.conversationKey); + if (current[payload.conversationKey]?.status === "pending") { + await chrome.storage.local.remove(payload.conversationKey); + } + } // Fail visibly and leave an unsent draft; never summarize just a file path. const draft = "PDF not attached automatically (" + error.message + "). Attach it manually before sending.\n\n" + payload.prompt; - setComposerText(findComposer() || composer, draft); + const failedComposer = findComposer() || composer; + if (failedComposer) setComposerText(failedComposer, draft); } finally { port?.disconnect(); await chrome.storage.local.remove(storageKey); diff --git a/src/viewer.html b/src/viewer.html index eeb4de1..ef156d0 100644 --- a/src/viewer.html +++ b/src/viewer.html @@ -103,7 +103,7 @@
-
diff --git a/src/viewer/sharing/summarize-with-chatgpt.js b/src/viewer/sharing/summarize-with-chatgpt.js index 51bd00e..757a8da 100644 --- a/src/viewer/sharing/summarize-with-chatgpt.js +++ b/src/viewer/sharing/summarize-with-chatgpt.js @@ -3,6 +3,8 @@ import { pdfDocumentSessionReady } from "../pdf-document-session.js"; const summarizeButton = document.querySelector("#summarize-chatgpt"); const STORAGE_PREFIX = "pdf-viewer-chatgpt-summary:"; +const CONVERSATION_PREFIX = "pdf-viewer-chatgpt-conversation:"; +const PENDING_MAX_AGE_MS = 10 * 60 * 1000; const CHATGPT_URL = "https://chatgpt.com/"; const defaultTitle = summarizeButton.title; // Resolve PDF metadata while the viewer loads, before the user asks for a summary. @@ -69,7 +71,7 @@ function registerPdfTransfer(requestId, filename) { const port = chrome.runtime.connect({ name: "pdf-summary-source:" + requestId }); let stopped = false; let awaitingAck; - const expire = setTimeout(() => port.disconnect(), 120000); + const expire = setTimeout(() => port.disconnect(), PENDING_MAX_AGE_MS); port.onDisconnect.addListener(() => { stopped = true; if (awaitingAck) awaitingAck.reject(new Error("PDF transfer disconnected")); @@ -118,11 +120,46 @@ function registerPdfTransfer(requestId, filename) { return port; } -async function openChatGPTSummary() { +function savedConversationUrl(value) { + try { + const url = new URL(value); + return url.origin === "https://chatgpt.com" && /^\/c\/[a-z0-9-]+\/?$/i.test(url.pathname) + ? url.origin + url.pathname.replace(/\/$/, "") : null; + } catch { + return null; + } +} + +async function pdfContentHash() { + const session = await pdfDocumentSessionReady; + if (!session?.document) throw new Error("PDF has not finished loading"); + const bytes = await session.document.getData(); + if (!bytes?.length || bytes.length > 512 * 1024 * 1024) { + throw new Error("PDF empty or over 512 MB attachment limit"); + } + // Hash bytes, not the filename or URL: changed PDFs must not reuse an old attachment. + const digest = await crypto.subtle.digest("SHA-256", bytes); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +async function focusPendingSummary(entry) { + if (entry?.status !== "pending" || Date.now() - entry.createdAt > PENDING_MAX_AGE_MS) { + return false; + } + try { + const tab = await chrome.tabs.get(entry.tabId); + if (!tab) return false; + await chrome.windows.update(tab.windowId, { focused: true }); + await chrome.tabs.update(tab.id, { active: true }); + return true; + } catch { + return false; // The previous popup was closed. + } +} + +async function openChatGPTSummary({ freshChat = false } = {}) { const requestId = crypto.randomUUID(); const storageKey = `${STORAGE_PREFIX}${requestId}`; - const chatgptUrl = new URL(CHATGPT_URL); - chatgptUrl.hash = `pdf-viewer-summary=${encodeURIComponent(requestId)}`; const screenWidth = window.screen.availWidth || window.screen.width || 1440; const screenHeight = window.screen.availHeight || window.screen.height || 900; @@ -148,6 +185,8 @@ async function openChatGPTSummary() { } let sourcePort; + let conversationKey; + let reserved = false; try { const fileUrl = await documentReferenceUrl; if (!fileUrl) { @@ -156,29 +195,54 @@ async function openChatGPTSummary() { return; } const filename = pdfFilename(fileUrl); - await chrome.storage.local.set({ - [storageKey]: { - prompt: summaryPrompt(fileUrl, filename), - createdAt: Date.now(), - attachmentRequired: true, - }, - }); - sourcePort = registerPdfTransfer(requestId, filename); + let existingConversation = null; + if (!freshChat) { + conversationKey = CONVERSATION_PREFIX + await pdfContentHash(); + const stored = await chrome.storage.local.get(conversationKey); + existingConversation = stored[conversationKey]; + if (await focusPendingSummary(existingConversation)) { + await chrome.tabs.remove(summaryTab.id); + return; // Focus the active upload rather than initiating another one. + } + } + const previousUrl = savedConversationUrl(existingConversation?.url); + const reuse = !freshChat && existingConversation?.status === "complete" && previousUrl; + const chatgptUrl = new URL(reuse || CHATGPT_URL); + chatgptUrl.hash = `pdf-viewer-summary=${encodeURIComponent(requestId)}`; + const payload = { + prompt: summaryPrompt(fileUrl, filename), + createdAt: Date.now(), + attachmentRequired: !reuse, + conversationKey: conversationKey || null, + }; + const changes = { [storageKey]: payload }; + if (conversationKey && !reuse) { + changes[conversationKey] = { status: "pending", tabId: summaryTab.id, createdAt: Date.now() }; + reserved = true; + } + await chrome.storage.local.set(changes); + if (!reuse) sourcePort = registerPdfTransfer(requestId, filename); await chrome.tabs.update(summaryTab.id, { url: chatgptUrl.href }); } catch (error) { sourcePort?.disconnect(); await chrome.storage.local.remove(storageKey); + if (reserved) { + const stored = await chrome.storage.local.get(conversationKey); + if (stored[conversationKey]?.tabId === summaryTab.id) { + await chrome.storage.local.remove(conversationKey); + } + } await chrome.tabs.remove(summaryTab.id).catch(() => {}); throw error; } } -summarizeButton.addEventListener("click", async () => { +summarizeButton.addEventListener("click", async (event) => { dismissSummaryTooltip(); summarizeButton.disabled = true; try { - await openChatGPTSummary(); + await openChatGPTSummary({ freshChat: Boolean(event?.shiftKey) }); } catch { showTemporaryTitle("Could not open ChatGPT"); } finally { diff --git a/tests/chatgpt-summary-handoff.test.mjs b/tests/chatgpt-summary-handoff.test.mjs new file mode 100644 index 0000000..18805ff --- /dev/null +++ b/tests/chatgpt-summary-handoff.test.mjs @@ -0,0 +1,188 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import vm from "node:vm"; + +const code = await readFile(new URL("../src/content/chatgpt-summary.js", import.meta.url), "utf8"); +const prompt = "Summarize the attached PDF.\n\nSource URL: https://example.com/sample.pdf"; +const requestId = "12345678-1234-1234-1234-123456789abc"; +const storageKey = "pdf-viewer-chatgpt-summary:" + requestId; + +function setup({ background = false, hydrateDuringUpload = false, attachmentRequired = true } = {}) { + const observers = new Set(); + const listeners = new Map(); + const calls = []; + const uploads = []; + const state = { progress: true, chip: false, chipFilename: "", submitted: 0, sentPrompt: "" }; + const notify = () => { + for (const observer of [...observers]) observer.callback(); + }; + class MockObserver { + constructor(callback) { this.callback = callback; } + observe() { observers.add(this); } + disconnect() { observers.delete(this); } + } + class MockTextarea { + constructor() { this.value = ""; this.isConnected = true; this.disabled = false; this.readOnly = false; } + focus() {} + getClientRects() { return background ? [] : [{}]; } + dispatchEvent() { notify(); } + } + let composer = new MockTextarea(); + class MockTransfer { + constructor() { + const files = []; + this.items = { add(file) { files.push(file); } }; + this.files = files; + } + } + class MockFile { + constructor(chunks, name) { this.chunks = chunks; this.name = name; } + } + const input = { + accept: ".pdf", disabled: false, files: [], + dispatchEvent(event) { + assert.equal(event.type, "change"); + uploads.push(this.files[0]); + state.chip = true; + state.chipFilename = this.files[0].name; + if (hydrateDuringUpload) { + composer.isConnected = false; + composer = new MockTextarea(); + } + notify(); + }, + }; + const button = { + disabled: false, isConnected: true, + getAttribute(name) { return name === "aria-label" ? "Send message" : null; }, + getClientRects() { return background ? [] : [{}]; }, + click() { + state.submitted++; + state.sentPrompt = composer.value; + location.href = "https://chatgpt.com/c/" + requestId; + location.pathname = "/c/" + requestId; + location.hash = ""; + notify(); + }, + }; + const location = { + href: "https://chatgpt.com/#pdf-viewer-summary=" + requestId, + pathname: "/", search: "", hash: "#pdf-viewer-summary=" + requestId, + }; + const window = { + location, history: { state: null, replaceState() { location.hash = ""; } }, + getSelection() { return null; }, + addEventListener(name, fn) { listeners.set("window-" + name, fn); }, + removeEventListener(name) { listeners.delete("window-" + name); }, + setTimeout, clearTimeout, + }; + const document = { + visibilityState: background ? "hidden" : "visible", + hasFocus: () => !background, + querySelector(selector) { + if (selector === "#prompt-textarea") return composer; + return null; + }, + querySelectorAll(selector) { + if (selector === 'input[type="file"]') return [input]; + if (selector === "button") return [button]; + if (selector.includes("data-testid")) { + return state.chip ? [{ + textContent: state.chipFilename, + getAttribute() { return null; }, + }] : []; + } + return []; + }, + addEventListener(name, fn) { listeners.set("document-" + name, fn); }, + removeEventListener(name) { listeners.delete("document-" + name); }, + }; + const chrome = { + storage: { local: { + async get(key) { return { [key]: { prompt, attachmentRequired, conversationKey: null } }; }, + async remove(key) { calls.push(["remove", key]); }, + async set(value) { calls.push(["store", value]); }, + } }, + runtime: { + connect(options) { + const handlers = new Map(); + const port = { + name: options.name, + onMessage: { addListener(fn) { handlers.set("message", fn); } }, + onDisconnect: { addListener(fn) { handlers.set("disconnect", fn); } }, + postMessage(message) { calls.push(["port", message]); }, + disconnect() { calls.push(["disconnect"]); }, + deliver(message) { handlers.get("message")?.(message); }, + }; + calls.push(["connect", port.name]); + ports.push(port); + return port; + }, + }, + }; + const ports = []; + class MockEvent { constructor(type) { this.type = type; } } + vm.runInNewContext(code, { + window, document, chrome, URL, URLSearchParams, Event: MockEvent, + InputEvent: MockEvent, HTMLTextAreaElement: MockTextarea, + DataTransfer: MockTransfer, File: MockFile, MutationObserver: MockObserver, + Uint8Array, atob, setInterval, clearInterval, + }); + const flush = async () => { + for (let i = 0; i < 8; i++) await new Promise((resolve) => setImmediate(resolve)); + }; + return { + state, calls, uploads, ports, flush, getPrompt: () => composer.value, listeners, + sendPdf() { + const port = ports[0]; + assert.ok(port, "target port connected"); + port.deliver({ type: "begin", filename: "sample.pdf", byteLength: 4 }); + port.deliver({ type: "chunk", data: "JVBERg==" }); + port.deliver({ type: "end" }); + }, + }; +} + +test("inserts prompt before PDF upload, then clicks send while progress is still active", async () => { + const app = setup(); + await app.flush(); + assert.equal(app.getPrompt(), prompt); + assert.equal(app.uploads.length, 0); + assert.equal(app.state.submitted, 0); + app.sendPdf(); + await app.flush(); + assert.equal(app.uploads.length, 1); + assert.equal(app.state.progress, true); + assert.equal(app.state.submitted, 1); + assert.equal(app.state.sentPrompt, prompt); + assert.ok(app.calls.some(([action, msg]) => action === "port" && msg.type === "finish")); +}); + +test("background ChatGPT tab can attach and send without layout rects or focus", async () => { + const app = setup({ background: true }); + await app.flush(); + assert.equal(app.getPrompt(), prompt); + app.sendPdf(); + await app.flush(); + assert.equal(app.state.submitted, 1); + assert.equal(app.state.sentPrompt, prompt); +}); + +test("recovers the summary prompt after ChatGPT replaces the editor during upload", async () => { + const app = setup({ hydrateDuringUpload: true }); + await app.flush(); + assert.equal(app.getPrompt(), prompt); + app.sendPdf(); + await app.flush(); + assert.equal(app.state.submitted, 1); + assert.equal(app.state.sentPrompt, prompt); +}); + +test("reused summary skips the PDF relay and submits the prompt", async () => { + const app = setup({ attachmentRequired: false }); + await app.flush(); + assert.equal(app.ports.length, 0); + assert.equal(app.state.submitted, 1); + assert.equal(app.state.sentPrompt, prompt); +}); diff --git a/tests/chatgpt-summary-popup.test.mjs b/tests/chatgpt-summary-popup.test.mjs index 49731e1..90dab5a 100644 --- a/tests/chatgpt-summary-popup.test.mjs +++ b/tests/chatgpt-summary-popup.test.mjs @@ -10,6 +10,7 @@ function setup({ width = 1080, height = 1920, popupFails = false } = {}) { let resolveUrl; const listeners = new Map(); const calls = []; + const saved = {}; const button = { title: "Summarize with ChatGPT", attributes: {}, @@ -19,7 +20,11 @@ function setup({ width = 1080, height = 1920, popupFails = false } = {}) { addEventListener(event, listener) { listeners.set(event, listener); }, }; const context = { - URL, crypto: { randomUUID: () => "request" }, + URL, Uint8Array, crypto: { + randomUUID: () => "request", + subtle: { async digest() { return new Uint8Array([18, 52, 86]).buffer; } }, + }, + pdfDocumentSessionReady: Promise.resolve({ document: { async getData() { return new Uint8Array([37, 80, 68, 70]); } } }), setTimeout: () => 0, clearTimeout() {}, document: { querySelector: () => button }, window: { screen: { availWidth: width, availHeight: height } }, @@ -29,24 +34,26 @@ function setup({ width = 1080, height = 1920, popupFails = false } = {}) { calls.push(["connect", options]); return { onMessage: { addListener() {} }, onDisconnect: { addListener() {} }, disconnect() {} }; } }, - windows: { async create(options) { + windows: { async update(id, options) { calls.push(["focus", id, options]); }, async create(options) { calls.push(["popup", options]); if (popupFails) throw new Error("Popup unavailable"); return { tabs: [{ id: 7 }] }; } }, tabs: { + async get(id) { return { id, windowId: 9 }; }, async create(options) { calls.push(["tab", options]); return { id: 7 }; }, async update(id, options) { calls.push(["navigate", options]); }, async remove() { calls.push(["close"]); }, }, storage: { local: { - async set(payload) { calls.push(["store", payload]); }, - async remove() { calls.push(["remove"]); }, + async get(key) { return { [key]: saved[key] }; }, + async set(payload) { Object.assign(saved, payload); calls.push(["store", payload]); }, + async remove(key) { delete saved[key]; calls.push(["remove"]); }, } }, }, }; vm.runInNewContext(source, context); - return { click: () => listeners.get("click")(), dispatch: (event) => listeners.get(event)(), resolveUrl, calls, button }; + return { click: (event) => listeners.get("click")(event), dispatch: (event) => listeners.get(event)(), resolveUrl, calls, saved, button }; } test("opens the portrait popup before PDF metadata resolves, then stores the prompt before navigation", async () => { @@ -120,3 +127,56 @@ test("local file URLs are not substituted for PDF attachments", async () => { assert.ok(!data.prompt.includes("file:///Users/")); assert.equal(fixture.calls.find(([action]) => action === "connect")[1].name, "pdf-summary-source:request"); }); + +test("reuses a previously uploaded PDF by reopening its existing conversation", async () => { + const fixture = setup(); + fixture.saved["pdf-viewer-chatgpt-conversation:123456"] = { + status: "complete", + url: "https://chatgpt.com/c/12345678-1234-1234-1234-123456789abc", + }; + const click = fixture.click(); + fixture.resolveUrl("https://example.com/original.pdf"); + await click; + const data = fixture.calls.find(([action]) => action === "store")[1]["pdf-viewer-chatgpt-summary:request"]; + assert.equal(data.attachmentRequired, false); + assert.equal(fixture.calls.some(([action]) => action === "connect"), false); + assert.match(fixture.calls.find(([action]) => action === "navigate")[1].url, + /^https:\/\/chatgpt.com\/c\/12345678-1234-1234-1234-123456789abc#/); +}); + +test("refocuses an active upload rather than reattaching the PDF", async () => { + const fixture = setup(); + fixture.saved["pdf-viewer-chatgpt-conversation:123456"] = { + status: "pending", tabId: 19, createdAt: Date.now(), + }; + const click = fixture.click(); + fixture.resolveUrl("https://example.com/original.pdf"); + await click; + assert.deepEqual(fixture.calls.map(([action]) => action), ["popup", "focus", "navigate", "close"]); +}); + +test("Shift-click deliberately starts a fresh chat even when the PDF has a saved summary", async () => { + const fixture = setup(); + fixture.saved["pdf-viewer-chatgpt-conversation:123456"] = { + status: "complete", url: "https://chatgpt.com/c/12345678-1234-1234-1234-123456789abc", + }; + const click = fixture.click({ shiftKey: true }); + fixture.resolveUrl("https://example.com/original.pdf"); + await click; + assert.deepEqual(fixture.calls.map(([action]) => action), ["popup", "store", "connect", "navigate"]); + const data = fixture.calls[1][1]["pdf-viewer-chatgpt-summary:request"]; + assert.equal(data.attachmentRequired, true); + assert.equal(data.conversationKey, null); + assert.match(fixture.calls[3][1].url, /^https:\/\/chatgpt.com\/#/); +}); + +test("keeps an in-progress PDF transfer reachable after two minutes in a background tab", async () => { + const fixture = setup(); + fixture.saved["pdf-viewer-chatgpt-conversation:123456"] = { + status: "pending", tabId: 19, createdAt: Date.now() - 3 * 60 * 1000, + }; + const click = fixture.click(); + fixture.resolveUrl("https://example.com/original.pdf"); + await click; + assert.deepEqual(fixture.calls.map(([action]) => action), ["popup", "focus", "navigate", "close"]); +});