From aa0d85caf1cb3ebac1967c2b40812256b27c1f63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristoffer=20S=C3=B6dersten?= <60147923+kristoffersodersten@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:19:15 +0200 Subject: [PATCH 1/2] feat(onboarding): add real first-run export flow Route a fixed local sample through the paid extraction contract and persist successful completion. Replace static AES material with a per-install non-exportable key and cover the locked path plus encrypted round-trip.\n\nVerified: pnpm typecheck; pnpm exec tsc -p tsconfig.json; node --test tests/panel/*.test.mjs --- DEMO_SCRIPT.md | 4 + background/background.js | 22 ++++ background/background.ts | 23 ++++ panel/panel.css | 18 +++ panel/panel.js | 59 +++++++++- panel/panel.ts | 58 ++++++++- shared/first-run-example.js | 7 ++ shared/first-run-example.ts | 17 +++ tests/e2e/extension-first-run-flow.spec.mjs | 124 ++++++++++++++++++++ tests/e2e/panel-harness.html | 23 +++- tests/e2e/panel-user-flow.spec.mjs | 26 +++- tests/panel/first-run-example.test.mjs | 12 ++ tests/panel/storage-encryption.test.mjs | 28 +++++ utils/storage.js | 19 ++- utils/storage.ts | 19 ++- 15 files changed, 441 insertions(+), 18 deletions(-) create mode 100644 shared/first-run-example.js create mode 100644 shared/first-run-example.ts create mode 100644 tests/e2e/extension-first-run-flow.spec.mjs create mode 100644 tests/panel/first-run-example.test.mjs create mode 100644 tests/panel/storage-encryption.test.mjs diff --git a/DEMO_SCRIPT.md b/DEMO_SCRIPT.md index 5e0a70d..9650b5b 100644 --- a/DEMO_SCRIPT.md +++ b/DEMO_SCRIPT.md @@ -33,6 +33,10 @@ Use this exact sequence when time is short. It maps directly to product value: - Explain that the app chooses the smallest viable model for the workload, not the largest available one 3. Open the extension on a real page. + - On a fresh unlocked install, run `Try local example` once without selecting page text + - Show the real local Readable and Structured result, then the Markdown and JSON exports + - Reload once to show that the first-run prompt stays resolved + - Continue with the ordinary selected-text path - Highlight a paragraph - Open the side panel - Click `Extract JSON` diff --git a/background/background.js b/background/background.js index a8b5666..1f87b76 100644 --- a/background/background.js +++ b/background/background.js @@ -4,6 +4,7 @@ import { log, error } from '../utils/logger.js'; import { requireFeature, getLicenseTier, attachLicenseToken, refreshLicense } from './tier-service.js'; import { getEntitlementSnapshot } from './entitlement-service.js'; import { ApiRequestError } from '../api/request.js'; +import { FIRST_RUN_EXAMPLE } from '../shared/first-run-example.js'; const MEMORY_ENABLED_KEY = 'selectpilot_memory_enabled_v1'; const MEMORY_LEDGER_KEY = 'selectpilot_memory_ledger_v1'; async function canUseProjectMemory() { @@ -190,6 +191,23 @@ async function handleExtract(preset) { }); return result; } +async function handleFirstRunExtract() { + const entitlement = await getEntitlementSnapshot(); + const { allowed } = await requireFeature('structured_extraction'); + if (!entitlement?.token || !allowed) { + throw new Error('Paid license required for deterministic extraction'); + } + return extract({ + text: FIRST_RUN_EXAMPLE.text, + preset: FIRST_RUN_EXAMPLE.preset, + url: FIRST_RUN_EXAMPLE.url, + title: FIRST_RUN_EXAMPLE.title, + metadata: { + source: 'selectpilot_first_run', + sample_version: FIRST_RUN_EXAMPLE.version, + }, + }); +} async function handleTranscribe() { const { allowed } = await requireFeature('audio_transcription'); if (!allowed) @@ -268,6 +286,10 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { sendResponse(await handleExtract(msg.preset)); return; } + if (msg.type === 'panel:extract_demo') { + sendResponse(await handleFirstRunExtract()); + return; + } if (msg.type === 'panel:vision') { sendResponse(await handleVision()); return; diff --git a/background/background.ts b/background/background.ts index c2eb1cf..bf05d7f 100644 --- a/background/background.ts +++ b/background/background.ts @@ -5,6 +5,7 @@ import { requireFeature, getLicenseTier, attachLicenseToken, refreshLicense } fr import { getEntitlementSnapshot } from './entitlement-service.js'; import { ApiRequestError } from '../api/request.js'; import type { AgentContext } from '../agent/agent-types.js'; +import { FIRST_RUN_EXAMPLE } from '../shared/first-run-example.js'; type MemoryEntry = { action: 'extract' | 'summarize' | 'agent'; @@ -218,6 +219,24 @@ async function handleExtract(preset?: string): Promise { return result; } +async function handleFirstRunExtract(): Promise { + const entitlement = await getEntitlementSnapshot(); + const { allowed } = await requireFeature('structured_extraction'); + if (!entitlement?.token || !allowed) { + throw new Error('Paid license required for deterministic extraction'); + } + return extract({ + text: FIRST_RUN_EXAMPLE.text, + preset: FIRST_RUN_EXAMPLE.preset, + url: FIRST_RUN_EXAMPLE.url, + title: FIRST_RUN_EXAMPLE.title, + metadata: { + source: 'selectpilot_first_run', + sample_version: FIRST_RUN_EXAMPLE.version, + }, + }); +} + async function handleTranscribe(): Promise { const { allowed } = await requireFeature('audio_transcription'); if (!allowed) throw new Error('Feature blocked: upgrade tier for audio transcription'); @@ -296,6 +315,10 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { sendResponse(await handleExtract(msg.preset)); return; } + if (msg.type === 'panel:extract_demo') { + sendResponse(await handleFirstRunExtract()); + return; + } if (msg.type === 'panel:vision') { sendResponse(await handleVision()); return; diff --git a/panel/panel.css b/panel/panel.css index a58499e..b355e88 100644 --- a/panel/panel.css +++ b/panel/panel.css @@ -486,6 +486,24 @@ html, body { gap: 8px; } +.selection-example-action { + min-height: 44px; + margin-top: 4px; + padding: 10px 14px; + border: 1px solid var(--accent); + border-radius: 14px; + background: var(--accent); + color: white; + font: inherit; + font-weight: 600; + cursor: pointer; +} + +.selection-example-action:disabled { + opacity: 0.5; + cursor: progress; +} + .intent-shell { border: 1px solid var(--panel-border); border-radius: 18px; diff --git a/panel/panel.js b/panel/panel.js index 98cab86..c87ab9c 100644 --- a/panel/panel.js +++ b/panel/panel.js @@ -8,6 +8,7 @@ import { buildKnowledgePackage } from './knowledge-connectors.js'; import { applyRuntimeEvent, setIntent, setSelectionContext, setVisiblePanels } from './state/runtimeStore.js'; import { loadBottleneckReport, loadDeterminismReport, loadFrontierReport } from './state/reportStore.js'; import { getTopologyForComponent, validateTopologyMap } from './layout/topologyMap.js'; +import { FIRST_RUN_EXAMPLE } from '../shared/first-run-example.js'; const workflow = $('#workflow'); const exportsEl = $('#exports'); const runtimeStateEl = $('#runtime-state'); @@ -58,6 +59,7 @@ const runtimeMetaTraceEl = $('#runtime-meta-trace'); const runtimeMetaEventsEl = $('#runtime-meta-events'); const actionButtons = Array.from(document.querySelectorAll('.primary-action, .secondary-grid button, .advanced-grid button')); const ENTITLEMENT_FRESH_MS = 15 * 60 * 1000; +const FIRST_RUN_COMPLETED_KEY = 'selectpilot_first_run_completed_v1'; let isBusy = false; let runtimeSnapshot = { ok: false, @@ -92,6 +94,7 @@ let memorySnapshot = { lastUpdatedAt: null, }; let entitlementSnapshot = null; +let firstRunCompleted = false; const BENCHMARK_CACHE_KEY = 'selectpilot_runtime_benchmark_v1'; const RUNTIME_META_MAX_EVENTS = 6; let runtimeMetaEventSource = null; @@ -764,6 +767,7 @@ async function refreshEntitlementStatus() { entitlementSnapshot = null; } renderEntitlementStatus(); + renderSelectionState(); syncControlAvailability(); } async function doSyncOrderToken() { @@ -855,6 +859,9 @@ function syncControlAvailability() { } if (intentClearButtonEl) intentClearButtonEl.disabled = isBusy; + const firstRunButton = document.querySelector('#btn-first-run-example'); + if (firstRunButton) + firstRunButton.disabled = isBusy || !runtimeReady || !entitlementSnapshot?.token; } function populatePresetOptions() { if (!extractPresetEl) @@ -1047,14 +1054,23 @@ function renderSelectionState() { clearNode(selectionCardEl); if (!selectionCardEl) return; + const showFirstRunExample = runtimeSnapshot.ok + && Boolean(entitlementSnapshot?.token) + && !selectionPreview.hasSelection + && !firstRunCompleted; const header = document.createElement('div'); header.className = 'output-eyebrow'; - header.textContent = selectionPreview.hasSelection ? 'Active selection' : 'No active selection'; + header.textContent = showFirstRunExample + ? 'First result' + : (selectionPreview.hasSelection ? 'Active selection' : 'No active selection'); const title = document.createElement('h3'); - title.textContent = selectionPreview.title || 'Current page'; + title.textContent = showFirstRunExample ? 'See SelectPilot once' : (selectionPreview.title || 'Current page'); const copy = document.createElement('p'); copy.className = 'selection-copy'; - if (selectionPreview.hasSelection) { + if (showFirstRunExample) { + copy.textContent = FIRST_RUN_EXAMPLE.text; + } + else if (selectionPreview.hasSelection) { copy.textContent = shorten(selectionPreview.selection, 260); } else if (selectionPreview.pageText) { @@ -1066,8 +1082,19 @@ function renderSelectionState() { const meta = document.createElement('p'); meta.className = 'selection-copy'; const charCount = selectionPreview.hasSelection ? selectionPreview.selection.length : selectionPreview.pageText.length; - meta.textContent = `${selectionPreview.hasSelection ? 'Selection' : 'Page context'} · ${charCount} chars${selectionPreview.url ? ` · ${selectionPreview.url}` : ''}`; + meta.textContent = showFirstRunExample + ? 'Local example · Action Brief · no page content used' + : `${selectionPreview.hasSelection ? 'Selection' : 'Page context'} · ${charCount} chars${selectionPreview.url ? ` · ${selectionPreview.url}` : ''}`; selectionCardEl.append(header, title, copy, meta); + if (showFirstRunExample) { + const action = document.createElement('button'); + action.id = 'btn-first-run-example'; + action.className = 'selection-example-action'; + action.type = 'button'; + action.textContent = 'Try local example'; + action.disabled = isBusy; + selectionCardEl.append(action); + } } async function refreshSelectionPreview() { const preview = await request('panel:get_selection_preview'); @@ -1184,6 +1211,7 @@ async function refreshRuntime() { setStatus(runtimeSnapshot.error || 'Ollama health check failed'); renderRuntimeState(); } + renderSelectionState(); syncControlAvailability(); } async function doSummarize() { @@ -1232,6 +1260,23 @@ async function doExtract(presetKey) { renderExports({ markdown: res.markdown, json: res.json, basename: `selectpilot-${selectedPreset.key}` }); setStatus('Done'); } +async function doFirstRunExample() { + const selectedPreset = getExtractionPreset(FIRST_RUN_EXAMPLE.preset); + const res = await request('panel:extract_demo'); + renderOutput({ + title: res.label || selectedPreset.label, + markdown: res.markdown || '', + json: res.json || {}, + eyebrow: 'Your first structured result', + meta: 'Created locally from the example above. Ready to read or export.', + exportBase: 'selectpilot-first-result', + }); + renderExports({ markdown: res.markdown, json: res.json, basename: 'selectpilot-first-result' }); + await setJSON(FIRST_RUN_COMPLETED_KEY, true); + firstRunCompleted = true; + renderSelectionState(); + setStatus('Ready to export'); +} async function doRewrite() { const prompt = agentPromptEl?.value.trim() || 'Rewrite the selected text in clearer, tighter language.'; setStatus('Rewriting...'); @@ -1387,6 +1432,11 @@ function bindActions() { void Promise.all([refreshRuntime(), refreshSelectionPreview(), refreshMemoryStatus(), refreshEntitlementStatus()]); }); $('#btn-extract')?.addEventListener('click', wrap(() => doExtract())); + selectionCardEl?.addEventListener('click', (event) => { + const target = event.target; + if (target?.id === 'btn-first-run-example') + void wrap(() => doFirstRunExample())(); + }); $('#btn-summarize')?.addEventListener('click', wrap(() => doSummarize())); $('#btn-rewrite')?.addEventListener('click', wrap(() => doRewrite())); $('#btn-actions')?.addEventListener('click', wrap(() => doActions())); @@ -1484,6 +1534,7 @@ async function initialize() { setStatus(`Topology contract failed: ${[...topologyValidation.errors, ...topologyBindingErrors].join(', ')}`); } setVisiblePanels(['selection_surface', 'runtime_surface', 'report_surface']); + firstRunCompleted = Boolean(await getJSON(FIRST_RUN_COMPLETED_KEY)); renderRuntimeMetaOverlay(); void connectRuntimeMetaStream(); refreshIntentSuggestions(); diff --git a/panel/panel.ts b/panel/panel.ts index 26ce953..50cec24 100644 --- a/panel/panel.ts +++ b/panel/panel.ts @@ -8,6 +8,7 @@ import { buildKnowledgePackage, type KnowledgeTarget, type MemoryLedgerEntry } f import { applyRuntimeEvent, setIntent, setSelectionContext, setVisiblePanels } from './state/runtimeStore.js'; import { loadBottleneckReport, loadDeterminismReport, loadFrontierReport } from './state/reportStore.js'; import { getTopologyForComponent, validateTopologyMap } from './layout/topologyMap.js'; +import { FIRST_RUN_EXAMPLE } from '../shared/first-run-example.js'; const workflow = $('#workflow'); const exportsEl = $('#exports'); @@ -118,6 +119,7 @@ type EntitlementSnapshot = { } | null; const ENTITLEMENT_FRESH_MS = 15 * 60 * 1000; +const FIRST_RUN_COMPLETED_KEY = 'selectpilot_first_run_completed_v1'; type SelectionPreview = { selection: string; @@ -185,6 +187,7 @@ let memorySnapshot: MemoryStatusSnapshot = { lastUpdatedAt: null, }; let entitlementSnapshot: EntitlementSnapshot = null; +let firstRunCompleted = false; const BENCHMARK_CACHE_KEY = 'selectpilot_runtime_benchmark_v1'; const RUNTIME_META_MAX_EVENTS = 6; @@ -916,6 +919,7 @@ async function refreshEntitlementStatus() { entitlementSnapshot = null; } renderEntitlementStatus(); + renderSelectionState(); syncControlAvailability(); } @@ -1002,6 +1006,8 @@ function syncControlAvailability() { intentExecuteButtonEl.disabled = isBusy || !runtimeReady || !selectionReady || !hasIntent; } if (intentClearButtonEl) intentClearButtonEl.disabled = isBusy; + const firstRunButton = document.querySelector('#btn-first-run-example'); + if (firstRunButton) firstRunButton.disabled = isBusy || !runtimeReady || !entitlementSnapshot?.token; } function populatePresetOptions() { @@ -1246,16 +1252,25 @@ function renderSelectionState() { clearNode(selectionCardEl); if (!selectionCardEl) return; + const showFirstRunExample = runtimeSnapshot.ok + && Boolean(entitlementSnapshot?.token) + && !selectionPreview.hasSelection + && !firstRunCompleted; + const header = document.createElement('div'); header.className = 'output-eyebrow'; - header.textContent = selectionPreview.hasSelection ? 'Active selection' : 'No active selection'; + header.textContent = showFirstRunExample + ? 'First result' + : (selectionPreview.hasSelection ? 'Active selection' : 'No active selection'); const title = document.createElement('h3'); - title.textContent = selectionPreview.title || 'Current page'; + title.textContent = showFirstRunExample ? 'See SelectPilot once' : (selectionPreview.title || 'Current page'); const copy = document.createElement('p'); copy.className = 'selection-copy'; - if (selectionPreview.hasSelection) { + if (showFirstRunExample) { + copy.textContent = FIRST_RUN_EXAMPLE.text; + } else if (selectionPreview.hasSelection) { copy.textContent = shorten(selectionPreview.selection, 260); } else if (selectionPreview.pageText) { copy.textContent = 'Extract JSON requires highlighted text. Summarize and Ask can still fall back to page context.'; @@ -1266,9 +1281,20 @@ function renderSelectionState() { const meta = document.createElement('p'); meta.className = 'selection-copy'; const charCount = selectionPreview.hasSelection ? selectionPreview.selection.length : selectionPreview.pageText.length; - meta.textContent = `${selectionPreview.hasSelection ? 'Selection' : 'Page context'} · ${charCount} chars${selectionPreview.url ? ` · ${selectionPreview.url}` : ''}`; + meta.textContent = showFirstRunExample + ? 'Local example · Action Brief · no page content used' + : `${selectionPreview.hasSelection ? 'Selection' : 'Page context'} · ${charCount} chars${selectionPreview.url ? ` · ${selectionPreview.url}` : ''}`; selectionCardEl.append(header, title, copy, meta); + if (showFirstRunExample) { + const action = document.createElement('button'); + action.id = 'btn-first-run-example'; + action.className = 'selection-example-action'; + action.type = 'button'; + action.textContent = 'Try local example'; + action.disabled = isBusy; + selectionCardEl.append(action); + } } async function refreshSelectionPreview() { @@ -1377,6 +1403,7 @@ async function refreshRuntime() { setStatus(runtimeSnapshot.error || 'Ollama health check failed'); renderRuntimeState(); } + renderSelectionState(); syncControlAvailability(); } @@ -1428,6 +1455,24 @@ async function doExtract(presetKey?: ExtractionPresetKey) { setStatus('Done'); } +async function doFirstRunExample() { + const selectedPreset = getExtractionPreset(FIRST_RUN_EXAMPLE.preset); + const res = await request('panel:extract_demo'); + renderOutput({ + title: res.label || selectedPreset.label, + markdown: res.markdown || '', + json: res.json || {}, + eyebrow: 'Your first structured result', + meta: 'Created locally from the example above. Ready to read or export.', + exportBase: 'selectpilot-first-result', + }); + renderExports({ markdown: res.markdown, json: res.json, basename: 'selectpilot-first-result' }); + await setJSON(FIRST_RUN_COMPLETED_KEY, true); + firstRunCompleted = true; + renderSelectionState(); + setStatus('Ready to export'); +} + async function doRewrite() { const prompt = agentPromptEl?.value.trim() || 'Rewrite the selected text in clearer, tighter language.'; setStatus('Rewriting...'); @@ -1592,6 +1637,10 @@ function bindActions() { void Promise.all([refreshRuntime(), refreshSelectionPreview(), refreshMemoryStatus(), refreshEntitlementStatus()]); }); $('#btn-extract')?.addEventListener('click', wrap(() => doExtract())); + selectionCardEl?.addEventListener('click', (event) => { + const target = event.target as HTMLElement | null; + if (target?.id === 'btn-first-run-example') void wrap(() => doFirstRunExample())(); + }); $('#btn-summarize')?.addEventListener('click', wrap(() => doSummarize())); $('#btn-rewrite')?.addEventListener('click', wrap(() => doRewrite())); $('#btn-actions')?.addEventListener('click', wrap(() => doActions())); @@ -1693,6 +1742,7 @@ async function initialize() { } setVisiblePanels(['selection_surface', 'runtime_surface', 'report_surface']); + firstRunCompleted = Boolean(await getJSON(FIRST_RUN_COMPLETED_KEY)); renderRuntimeMetaOverlay(); void connectRuntimeMetaStream(); refreshIntentSuggestions(); diff --git a/shared/first-run-example.js b/shared/first-run-example.js new file mode 100644 index 0000000..0c1bfda --- /dev/null +++ b/shared/first-run-example.js @@ -0,0 +1,7 @@ +export const FIRST_RUN_EXAMPLE = Object.freeze({ + version: 'v1', + preset: 'action_brief', + title: 'Launch review notes', + url: 'selectpilot://first-run', + text: 'The launch review is Friday. Maya owns the privacy check by Thursday. Jonas will verify payment and store assets. Publishing stays blocked until both checks pass.', +}); diff --git a/shared/first-run-example.ts b/shared/first-run-example.ts new file mode 100644 index 0000000..fc2107a --- /dev/null +++ b/shared/first-run-example.ts @@ -0,0 +1,17 @@ +import type { ExtractionPresetKey } from '../panel/extraction-presets.js'; + +export type FirstRunExample = Readonly<{ + version: string; + preset: ExtractionPresetKey; + title: string; + url: string; + text: string; +}>; + +export const FIRST_RUN_EXAMPLE: FirstRunExample = Object.freeze({ + version: 'v1', + preset: 'action_brief', + title: 'Launch review notes', + url: 'selectpilot://first-run', + text: 'The launch review is Friday. Maya owns the privacy check by Thursday. Jonas will verify payment and store assets. Publishing stays blocked until both checks pass.', +}); diff --git a/tests/e2e/extension-first-run-flow.spec.mjs b/tests/e2e/extension-first-run-flow.spec.mjs new file mode 100644 index 0000000..7ae7188 --- /dev/null +++ b/tests/e2e/extension-first-run-flow.spec.mjs @@ -0,0 +1,124 @@ +import { test, expect, chromium } from '@playwright/test'; + +test('real extension first-run sends only the canonical local example', async () => { + const executablePath = process.env.SELECTPILOT_CHROME_EXECUTABLE; + test.skip(!executablePath, 'SELECTPILOT_CHROME_EXECUTABLE is required for unpacked-extension proof'); + + const context = await chromium.launchPersistentContext(test.info().outputPath('extension-user-data'), { + executablePath, + headless: process.env.SELECTPILOT_HEADED !== '1', + args: [ + `--disable-extensions-except=${process.cwd()}`, + `--load-extension=${process.cwd()}`, + '--disable-web-security', + ], + }); + + let extractRequest = null; + await context.route('http://127.0.0.1:8083/**', async (route) => { + const request = route.request(); + const path = new URL(request.url()).pathname; + const fulfill = (body) => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(body), + }); + + if (path === '/profiles') { + return fulfill({ + profiles: [{ + key: 'fast', label: 'Fast', description: 'Local test profile', + generation_model: 'test-generation', embedding_model: 'test-embedding', + target_latency: 'local', intended_for: 'Test', command: 'not-used', + }], + recommended_profile: 'fast', + reason: 'Recommended from local hardware.', + }); + } + if (path === '/health') { + return fulfill({ + ok: true, + ollama: { + reachable: true, model_available: true, active_model: 'test-generation', + privacy_mode: 'local-only', ignored_remote_models: [], + }, + }); + } + if (path === '/privacy-proof') { + return fulfill({ + ok: true, + privacy_mode: 'local-only', + generated_at: new Date().toISOString(), + outbound_observation: { external_calls_registered: false }, + }); + } + if (path === '/benchmark') { + return fulfill({ + ok: true, active_model: 'test-generation', extract_latency_ms: 20, + summarize_latency_ms: 25, recommended_profile: 'fast', + }); + } + if (path === '/runtime-meta/health') { + return fulfill({ ok: false, stream_enabled: false, active_streams: 0, event_version: '1' }); + } + if (path === '/extract') { + extractRequest = request.postDataJSON(); + return fulfill({ + preset: 'action_brief', + label: 'Action Brief', + description: 'Turn selected text into a concise action-oriented brief.', + markdown: '## Action Brief\n\n- Maya: privacy check by Thursday', + json: { + action_items: ['Maya completes the privacy check by Thursday'], + risks: ['Publishing waits for both checks'], + }, + }); + } + return fulfill({ ok: true }); + }); + + try { + let [serviceWorker] = context.serviceWorkers(); + if (!serviceWorker) serviceWorker = await context.waitForEvent('serviceworker'); + const extensionId = new URL(serviceWorker.url()).host; + const panelPage = await context.newPage(); + await panelPage.goto(`chrome-extension://${extensionId}/panel/panel.html`); + await expect(panelPage.locator('#btn-first-run-example')).toHaveCount(0); + + const lockedResponse = await panelPage.evaluate(() => globalThis.chrome.runtime.sendMessage({ type: 'panel:extract_demo' })); + expect(lockedResponse.error).toBe('Paid license required for deterministic extraction'); + expect(extractRequest).toBeNull(); + + await panelPage.evaluate(async () => { + const storage = await import(globalThis.chrome.runtime.getURL('licensing/license-storage.js')); + const now = Date.now(); + await storage.saveLicense({ + token: 'sp_e2e_paid_token', tier: 'essential', issuedAt: now, + expiresAt: now + 86_400_000, cachedAt: now, + }); + }); + await panelPage.reload(); + + await expect(panelPage.locator('#btn-first-run-example')).toBeVisible(); + await panelPage.locator('#btn-first-run-example').click(); + await expect.poll(() => extractRequest).not.toBeNull(); + expect(extractRequest).toEqual({ + text: 'The launch review is Friday. Maya owns the privacy check by Thursday. Jonas will verify payment and store assets. Publishing stays blocked until both checks pass.', + preset: 'action_brief', + url: 'selectpilot://first-run', + title: 'Launch review notes', + metadata: { source: 'selectpilot_first_run', sample_version: 'v1' }, + }); + + await expect(panelPage.locator('#result-title')).toHaveText('Action Brief'); + await expect(panelPage.locator('#exports')).toContainText('Copy Markdown'); + await panelPage.locator('#tab-structured').click(); + await expect(panelPage.locator('#workflow')).toContainText('Publishing waits for both checks'); + await expect(panelPage.locator('#btn-first-run-example')).toHaveCount(0); + + await panelPage.reload(); + await expect(panelPage.locator('#btn-first-run-example')).toHaveCount(0); + } finally { + await context.close(); + } +}); diff --git a/tests/e2e/panel-harness.html b/tests/e2e/panel-harness.html index 89e0bee..906fc77 100644 --- a/tests/e2e/panel-harness.html +++ b/tests/e2e/panel-harness.html @@ -131,7 +131,8 @@

Ready

- \ No newline at end of file + diff --git a/tests/e2e/panel-user-flow.spec.mjs b/tests/e2e/panel-user-flow.spec.mjs index e3abc08..f8eb4c5 100644 --- a/tests/e2e/panel-user-flow.spec.mjs +++ b/tests/e2e/panel-user-flow.spec.mjs @@ -31,4 +31,28 @@ test('sidepanel harness can run extract action and render result', async ({ page await page.click('#btn-sync-order'); await expect(page.locator('#status')).toContainText('Order already synced; entitlement refreshed'); -}); \ No newline at end of file +}); + +test('unlocked first-run uses the dedicated example route and resolves once', async ({ page }) => { + const harnessPath = resolve(process.cwd(), 'tests/e2e/panel-harness.html'); + await page.goto(`${pathToFileURL(harnessPath)}?firstRun=1`); + + await expect(page.locator('#btn-first-run-example')).toHaveCount(0); + await page.fill('#order-id', 'SP-PAID'); + await page.click('#btn-sync-order'); + await expect(page.locator('#btn-first-run-example')).toBeVisible(); + await expect(page.locator('#selection-card')).toContainText('no page content used'); + + await page.click('#btn-first-run-example'); + await expect(page.locator('#result-title')).toHaveText('Action Brief'); + await expect(page.locator('#workflow')).toContainText('Maya'); + await page.click('#tab-structured'); + await expect(page.locator('#workflow')).toContainText('Publishing waits for both checks'); + await expect(page.locator('#exports')).toContainText('Copy Markdown'); + await expect(page.locator('#exports')).toContainText('Copy JSON'); + await expect(page.locator('#btn-first-run-example')).toHaveCount(0); + + const demoMessages = await page.evaluate(() => globalThis.__messageLog.filter((msg) => msg.type === 'panel:extract_demo')); + expect(demoMessages).toHaveLength(1); + expect(demoMessages[0]).toEqual({ type: 'panel:extract_demo' }); +}); diff --git a/tests/panel/first-run-example.test.mjs b/tests/panel/first-run-example.test.mjs new file mode 100644 index 0000000..cb0474e --- /dev/null +++ b/tests/panel/first-run-example.test.mjs @@ -0,0 +1,12 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { FIRST_RUN_EXAMPLE } from '../../shared/first-run-example.js'; + +test('first-run example is fixed, local, and uses the existing action preset', () => { + assert.equal(Object.isFrozen(FIRST_RUN_EXAMPLE), true); + assert.equal(FIRST_RUN_EXAMPLE.url, 'selectpilot://first-run'); + assert.equal(FIRST_RUN_EXAMPLE.preset, 'action_brief'); + assert.match(FIRST_RUN_EXAMPLE.text, /privacy check/i); + assert.equal('callerText' in FIRST_RUN_EXAMPLE, false); +}); diff --git a/tests/panel/storage-encryption.test.mjs b/tests/panel/storage-encryption.test.mjs new file mode 100644 index 0000000..a524f85 --- /dev/null +++ b/tests/panel/storage-encryption.test.mjs @@ -0,0 +1,28 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +const values = new Map(); +globalThis.chrome = { + storage: { + local: { + async get(key) { + return { [key]: values.get(key) }; + }, + async set(entries) { + for (const [key, value] of Object.entries(entries)) values.set(key, value); + }, + }, + }, +}; + +const { getDecrypted, setEncrypted } = await import('../../utils/storage.js'); + +test('AES-GCM storage creates a non-exportable per-install key and round-trips data', async () => { + await setEncrypted('test_payload', 'private local value'); + + assert.equal(await getDecrypted('test_payload'), 'private local value'); + assert.equal(Array.isArray(values.get('selectpilot_encryption_key_v1')), true); + assert.equal(values.get('selectpilot_encryption_key_v1').length, 32); + assert.equal(typeof values.get('test_payload'), 'string'); + assert.equal(values.get('test_payload').includes('private local value'), false); +}); diff --git a/utils/storage.js b/utils/storage.js index 94b804d..2284e5e 100644 --- a/utils/storage.js +++ b/utils/storage.js @@ -1,11 +1,24 @@ import { error, log } from './logger.js'; const encoder = new TextEncoder(); const decoder = new TextDecoder(); -const KEY_MATERIAL = 'chromeai_local_key_v1'; -async function getCryptoKey() { - const keyData = encoder.encode(KEY_MATERIAL); +const KEY_STORAGE = 'selectpilot_encryption_key_v1'; +let cryptoKeyPromise = null; +async function loadOrCreateCryptoKey() { + const stored = (await chrome.storage.local.get(KEY_STORAGE))[KEY_STORAGE]; + let keyData; + if (Array.isArray(stored) && stored.length === 32 && stored.every((value) => Number.isInteger(value) && value >= 0 && value <= 255)) { + keyData = new Uint8Array(stored); + } + else { + keyData = crypto.getRandomValues(new Uint8Array(32)); + await chrome.storage.local.set({ [KEY_STORAGE]: Array.from(keyData) }); + } return crypto.subtle.importKey('raw', keyData, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']); } +function getCryptoKey() { + cryptoKeyPromise ||= loadOrCreateCryptoKey(); + return cryptoKeyPromise; +} export async function setEncrypted(key, value) { try { const cryptoKey = await getCryptoKey(); diff --git a/utils/storage.ts b/utils/storage.ts index 2c2e4d5..4558746 100644 --- a/utils/storage.ts +++ b/utils/storage.ts @@ -2,13 +2,26 @@ import { error, log } from './logger.js'; const encoder = new TextEncoder(); const decoder = new TextDecoder(); -const KEY_MATERIAL = 'chromeai_local_key_v1'; +const KEY_STORAGE = 'selectpilot_encryption_key_v1'; +let cryptoKeyPromise: Promise | null = null; -async function getCryptoKey(): Promise { - const keyData = encoder.encode(KEY_MATERIAL); +async function loadOrCreateCryptoKey(): Promise { + const stored = (await chrome.storage.local.get(KEY_STORAGE))[KEY_STORAGE]; + let keyData: Uint8Array; + if (Array.isArray(stored) && stored.length === 32 && stored.every((value) => Number.isInteger(value) && value >= 0 && value <= 255)) { + keyData = new Uint8Array(stored); + } else { + keyData = crypto.getRandomValues(new Uint8Array(32)); + await chrome.storage.local.set({ [KEY_STORAGE]: Array.from(keyData) }); + } return crypto.subtle.importKey('raw', keyData, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']); } +function getCryptoKey(): Promise { + cryptoKeyPromise ||= loadOrCreateCryptoKey(); + return cryptoKeyPromise; +} + export async function setEncrypted(key: string, value: string): Promise { try { const cryptoKey = await getCryptoKey(); From e826880698aab48b32e032a8ce7a3bf81d856f0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristoffer=20S=C3=B6dersten?= <60147923+kristoffersodersten@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:24:04 +0200 Subject: [PATCH 2/2] test(e2e): reuse configured Chrome executable Allow the full browser suite and unpacked-extension proof to run against the same explicitly selected Chrome binary.\n\nVerified: SELECTPILOT_CHROME_EXECUTABLE= pnpm test:e2e (7 passed); pnpm lint --- tests/e2e/playwright.config.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/e2e/playwright.config.mjs b/tests/e2e/playwright.config.mjs index e9c6f05..4f0f04f 100644 --- a/tests/e2e/playwright.config.mjs +++ b/tests/e2e/playwright.config.mjs @@ -15,6 +15,9 @@ export default defineConfig({ screenshot: 'only-on-failure', launchOptions: { args: ['--allow-file-access-from-files', '--disable-web-security'], + ...(process.env.SELECTPILOT_CHROME_EXECUTABLE + ? { executablePath: process.env.SELECTPILOT_CHROME_EXECUTABLE } + : {}), }, }, -}); \ No newline at end of file +});