From e5dc780b606fc7444ea400c77e9b27e01a848736 Mon Sep 17 00:00:00 2001 From: StoneHub Date: Wed, 9 Sep 2026 22:19:31 -0400 Subject: [PATCH 1/2] Gate browser releases on automated exact-ZIP acceptance --- .github/workflows/ci.yml | 14 +++ .github/workflows/release.yml | 16 ++- README.md | 2 +- docs/browser-release-acceptance.md | 40 ++++++ docs/manual-release-checklist.md | 8 +- docs/software-catalog-release-plan.md | 2 +- docs/store-release-1.8.0.md | 4 +- package-lock.json | 32 ++++- package.json | 6 +- test/browser/release-acceptance.mjs | 171 ++++++++++++++++++++++++++ 10 files changed, 284 insertions(+), 11 deletions(-) create mode 100644 docs/browser-release-acceptance.md create mode 100644 test/browser/release-acceptance.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46b4b73..1f6e1d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,20 @@ jobs: - name: Package extension run: npm run package + - name: Install acceptance browser + run: npx playwright install --with-deps chromium + + - name: Accept exact release ZIP in browser + run: npm run test:browser + + - name: Upload browser acceptance evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: browser-acceptance-${{ github.sha }} + path: output/browser-acceptance/ + if-no-files-found: error + - name: Verify package artifact run: npm run verify:package diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c715773..734e1c2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,6 +54,20 @@ jobs: - name: Package extension run: npm run package + - name: Install acceptance browser + run: npx playwright install --with-deps chromium + + - name: Accept exact release ZIP in browser + run: npm run test:browser + + - name: Upload browser acceptance evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: browser-acceptance-${{ github.sha }} + path: output/browser-acceptance/ + if-no-files-found: error + - name: Verify package artifact run: npm run verify:package -- "$ZIP_PATH" @@ -61,6 +75,6 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - gh release create "$GITHUB_REF_NAME" "$ZIP_PATH" "$ZIP_PATH.sha256" \ + gh release create "$GITHUB_REF_NAME" "$ZIP_PATH" "$ZIP_PATH.sha256" output/browser-acceptance/acceptance.json \ --title "Dev Feedback Capture $GITHUB_REF_NAME" \ --generate-notes --draft diff --git a/README.md b/README.md index f4d3a3c..49fecf5 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ Electron developers can explicitly install `@flyingchangescode/dev-feedback-elec Run `npm ci`, `npm test`, `npm run check`, `npm run audit:dependencies`, `npm run package`, and `npm run verify:package`. The browser ZIP excludes tests, MCP code, and Node dependencies. -Before publishing, follow [docs/manual-release-checklist.md](docs/manual-release-checklist.md). Tagged GitHub releases are created as drafts; Store submission and Google approval are separate steps. +Before publishing, run the [automated exact-ZIP browser gate](docs/browser-release-acceptance.md). Tagged GitHub releases are created as drafts; Store submission and Google approval are separate steps. Core files: `popup.*`, `content.js`, `collector.js`, `element.*`, `background.js`, `history.*`, `shared.js`, and `ai-bundle.js`. diff --git a/docs/browser-release-acceptance.md b/docs/browser-release-acceptance.md new file mode 100644 index 0000000..974c20a --- /dev/null +++ b/docs/browser-release-acceptance.md @@ -0,0 +1,40 @@ +# Automated browser release acceptance + +As of September 9, 2026, the owner authorizes repeatable automated acceptance to replace the human-only browser release gate. GitHub publication requires a passing gate against the exact ZIP being published. Chrome Web Store submission, listing screenshots, and Google approval remain separate. + +Run from a clean checkout with Node 22 or later and the standard `unzip` utility: + +```sh +npm ci +npx playwright install --with-deps chromium +npm test +npm run check +npm run audit:dependencies +npm run package +npm run test:browser +npm run verify:package +git diff --check +``` + +`npm run test:browser -- /absolute/path/to/package.zip` accepts an explicit ZIP. It extracts into a fresh temporary directory, loads those files with the original manifest, and verifies the ZIP digest and every extracted file before and after execution. No host permissions, content scripts, or test hooks are added to the extension. The locked Playwright dependency selects the acceptance browser. CI and release workflows run the same script after packaging; the release workflow attaches the resulting digest-bound `acceptance.json` to the draft release alongside the tested ZIP and checksum. + +The harness uses Chrome for Testing's extension debugging API to invoke the actual toolbar action on the source tab. This grants real `activeTab` access and opens the native popup. Trusted browser mouse events operate popup controls; trusted keyboard and pointer events select page elements. Native popup targets require a CDP session because they are not ordinary Playwright tabs. + +Coverage includes: + +- Toolbar activation, keyboard selection, Escape, pointer selection, private editor context, and Save & pick next. +- Refusal to replace an open draft, keep-editing/discard dialogs, a real 500-record capacity rejection, and a successful single-record retry. +- On-page History without new tabs, note/check editing with preserved capture identity, and persistence after closing the source tab. +- Synthetic legacy Region/PDF and Visual/Add records, decoded original/proposed/redacted images, selected deletion, and preservation of hidden records. +- All five selected exports: preview contents, downloaded JSON/HTML/ZIP bytes, rendered HTML, and actual clipboard readback. +- Native restricted-page popup History with capture disabled. + +Evidence is written to `output/browser-acceptance/`: a JSON report tied to the ZIP SHA-256, browser trace, synthetic screenshots, and export artifacts. Temporary profiles, server, and browsers are closed at completion. CI retains evidence even when the test fails. Do not treat partial output or an old passing report as acceptance of a different digest. + +## Narrow limits + +This gate exercises isolated Chrome for Testing, not every installed Chrome/Edge version. CDP toolbar activation uses the browser's real action path but does not exercise the operating system's global shortcut dispatcher; manifest/command registration and trusted in-page keyboard selection provide separate evidence. A user-customized or OS-conflicting shortcut may still need adjustment. + +Legacy/capacity data is seeded through Chrome's storage debugging API. Clipboard read permission is granted only to the temporary browser context so tests can read back exports; the shipping manifest remains `storage`, `activeTab`, and `scripting`. Clipboard text is restored after readback. Screenshot and DOM assertions supplement exported-byte and persisted-record assertions; they do not certify every screen size or assistive technology. + +The browser ZIP does not include the separate Electron package, local MCP companion, or unmerged SwiftUI prototype. MCP tests validate the separate companion contract; end-to-end implementation in a consumer's project and that consumer's Downloads configuration are not prerequisites for the browser ZIP's GitHub publication. diff --git a/docs/manual-release-checklist.md b/docs/manual-release-checklist.md index 915977d..eda4c2f 100644 --- a/docs/manual-release-checklist.md +++ b/docs/manual-release-checklist.md @@ -1,6 +1,6 @@ -# Manual Release Checklist +# Release Checklist -Automated checks are necessary but do not replace the exact-package unpacked-extension gate. Headless QA may use an isolated synthetic-page profile while the owner uses their Mac; record any test-only permission differences. See `docs/store-release-1.8.0.md` for the current candidate’s evidence and remaining limits. +The owner authorized automated exact-ZIP browser acceptance on September 9, 2026, replacing the human-only browser gate for GitHub releases. Follow [browser-release-acceptance.md](browser-release-acceptance.md). The historical records below do not override that authorization. Store submission, listing updates, and Google approval remain separate. Store status on August 3, 2026: v1.7.0 is public in the Chrome Web Store, v1.7.1 was cancelled, and v1.7.2 is pending review for automatic publication. The distributed CRX contains the Browser Code icon, while the Store listing still renders the retired purple-flag artwork. Store approval is not proof that the deferred checks below passed. Keep them open and do not call these releases runtime-verified until the relevant evidence is recorded. @@ -28,9 +28,9 @@ For that historical package: - In the durable owner account, update the Store overview, screenshots, and optional video, then upload the exact verified v1.7.1 ZIP. Re-read the upload status before submitting for review. - After publication, confirm the Store listing and a clean Google result both show the Browser Code icon, revised title, short description, public version, and current screenshots. If the retired purple-flag asset remains despite the verified ZIP icon, record the listing asset URL and escalate through Chrome Web Store support rather than claiming the refresh worked. -## Active Element release check +## Element acceptance coverage and separate integration/Store checks -The product was narrowed after hands-on review. New Region/PDF capture is removed; do not use the earlier broad workflow as an acceptance checklist for this release. +The product was narrowed after hands-on review. New Region/PDF capture is removed. The automated gate covers browser behavior below; configured MCP implementation and Store steps remain separate and are not human-only blockers to GitHub publication. - Verify the exact release ZIP and minimal manifest permissions. - When replacing an unpacked build in an existing test profile, enable Developer mode and use Chrome’s extension Reload control. Restarting Chrome alone can leave the old service-worker behavior active; a new manifest or files on disk is insufficient proof. diff --git a/docs/software-catalog-release-plan.md b/docs/software-catalog-release-plan.md index 3feb0ee..6d79375 100644 --- a/docs/software-catalog-release-plan.md +++ b/docs/software-catalog-release-plan.md @@ -15,7 +15,7 @@ The product story is: a Chromium extension for collecting structured feedback fr - `.github/workflows/release.yml` publishes a zip asset when a matching `v*` tag is pushed. - GitHub Release `v1.2.0` is published with `dev-feedback-capture-v1.2.0.zip`. -`product.json.downloadUrl` remains a fallback to a known published asset. Do not update it until the active browser capture core passes the manual browser gate and the matching asset is actually published. The latest-release API remains the preferred source for consumers that can resolve the newest matching asset automatically. +`product.json.downloadUrl` remains a fallback to a known published asset. Do not update it until the active browser capture core passes the automated exact-ZIP browser gate and the matching asset is actually published. The latest-release API remains the preferred source for consumers that can resolve the newest matching asset automatically. ## Catalog Metadata diff --git a/docs/store-release-1.8.0.md b/docs/store-release-1.8.0.md index 6158108..0f701c7 100644 --- a/docs/store-release-1.8.0.md +++ b/docs/store-release-1.8.0.md @@ -38,6 +38,8 @@ Single purpose: Collect structured feedback about selected webpage elements and ## Validation and package +September 9 update: GitHub releases now use the [automated exact-ZIP browser gate](browser-release-acceptance.md). Its digest-bound report supersedes the human-only requirement and earlier test-only permission differences below. Store listing, screenshots, and submission remain separate. + Validated locally on September 5, 2026: - `npm test`: 49 tests across extension/privacy, Electron, and MCP, plus release assertions; passed. @@ -50,4 +52,4 @@ Validated locally on September 5, 2026: - PDF requests returned the popup fallback. History rendered at 360px inside the popup document without creating another tab. Native popup fallback acceptance remains separate from that automated document check. - Imported a real browser-exported JSON through an MCP SDK stdio client, listed the selected record, and built its implementation brief. The downloaded file was copied into the approved Downloads test inbox for this check; automatic inbox delivery and implementation/verification status are still separate acceptance steps. -Remaining before submission: finish the outstanding acceptance checks in the manual checklist, capture current Store screenshots, land the reviewed source and CI, update the Store listing, and record upload/review readback. The previous main artifact is superseded and must not be submitted. +Remaining before Store submission: pass the automated gate for the selected package, capture current Store screenshots, update the Store listing, and record upload/review readback. The previous main artifact is superseded and must not be submitted. diff --git a/package-lock.json b/package-lock.json index cc766f5..b1e7d22 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,8 @@ "zod": "4.4.3" }, "devDependencies": { - "esbuild": "0.25.12" + "esbuild": "0.25.12", + "playwright": "1.63.0" } }, "node_modules/@esbuild/aix-ppc64": { @@ -1321,6 +1322,35 @@ "node": ">=16.20.0" } }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", diff --git a/package.json b/package.json index 5ce11e4..32e0711 100644 --- a/package.json +++ b/package.json @@ -13,13 +13,15 @@ "package": "node scripts/package-extension.cjs", "verify:package": "node scripts/verify-package.cjs", "mcp": "node mcp/cli.mjs", - "audit:dependencies": "npm audit --omit=dev --audit-level=moderate" + "audit:dependencies": "npm audit --omit=dev --audit-level=moderate", + "test:browser": "node test/browser/release-acceptance.mjs" }, "dependencies": { "@modelcontextprotocol/sdk": "1.29.0", "zod": "4.4.3" }, "devDependencies": { - "esbuild": "0.25.12" + "esbuild": "0.25.12", + "playwright": "1.63.0" } } diff --git a/test/browser/release-acceptance.mjs b/test/browser/release-acceptance.mjs new file mode 100644 index 0000000..50cbae1 --- /dev/null +++ b/test/browser/release-acceptance.mjs @@ -0,0 +1,171 @@ +// Exercise files extracted from the release ZIP, never a patched extension copy. +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { createServer } from 'node:http'; +import { readFileSync, writeFileSync, mkdtempSync, mkdirSync, readdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { resolve, join } from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { chromium } from 'playwright'; +const root=resolve(import.meta.dirname,'../..'); +const version=JSON.parse(readFileSync(join(root,'package.json'))).version; +const zip=resolve(process.argv[2]||join(root,`dist/dev-feedback-capture-v${version}.zip`)); +const out=resolve(process.env.ACCEPTANCE_OUTPUT||join(root,'output/browser-acceptance')); +mkdirSync(out,{recursive:true}); +rmSync(join(out,'acceptance.json'),{force:true}); +const temp=mkdtempSync(join(tmpdir(),'dfc-acceptance-')); +const extension=join(temp,'extension');mkdirSync(extension); +const hash=path=>createHash('sha256').update(readFileSync(path)).digest('hex'); +const zipHash=hash(zip); +execFileSync('unzip',['-q',zip,'-d',extension]); +const files=()=>Object.fromEntries(readdirSync(extension).sort().map(name=>[name,hash(join(extension,name))])); +const originalFiles=files(); +const manifest=JSON.parse(readFileSync(join(extension,'manifest.json'))); +assert.deepEqual([...manifest.permissions].sort(),['activeTab','scripting','storage']); +assert.equal(manifest.host_permissions,undefined);assert.equal(manifest.content_scripts,undefined); +assert.equal(manifest.version,version); +const report={status:'running',sourceCommit:execFileSync('git',['rev-parse','HEAD'],{cwd:root,encoding:'utf8'}).trim(),zip:zip.split('/').at(-1),sha256:zipHash,version,files:originalFiles,checks:[],limitations:[ + 'Isolated Chrome for Testing via CDP; does not certify every Chrome/Edge version or Chrome Web Store approval.', + 'Toolbar action uses the browser Extensions.triggerAction API. Keyboard selection/cancel uses trusted browser input; OS-global shortcut dispatch is covered by manifest/command-handler checks, not the host OS hotkey dispatcher.', + 'Synthetic legacy and capacity fixtures are seeded through the browser storage debugging API. No real user history or extension code is changed.' +]}; +const delay=ms=>new Promise(r=>setTimeout(r,ms)); +async function until(fn,label){const end=Date.now()+15000;let last;while(Date.now(){if(req.url==='/sample.pdf'){res.setHeader('Content-Type','application/pdf');res.end(readFileSync(join(root,'test/fixtures/sample.pdf')));return;}res.setHeader('Content-Type','text/html');res.end(html);}); +await new Promise(r=>server.listen(0,'127.0.0.1',r)); +const url=`http://127.0.0.1:${server.address().port}/fixture`; +const storageKey=`dev-feedback-${new URL(url).origin}`; +let ctx; +try{ + ctx=await chromium.launchPersistentContext(join(temp,'profile'),{channel:'chromium',headless:true,ignoreDefaultArgs:['--disable-extensions'],args:['--enable-unsafe-extension-debugging'],viewport:{width:1280,height:900},acceptDownloads:true}); + ctx.setDefaultTimeout(15000); + report.browser=ctx.browser().version(); + await ctx.tracing.start({screenshots:true,snapshots:true,sources:true}); + const cdp=await ctx.browser().newBrowserCDPSession(); + const {id}=await cdp.send('Extensions.loadUnpacked',{path:extension}); + const extURL=`chrome-extension://${id}`; + let page=ctx.pages()[0];await page.goto(url);let pageCDP=await ctx.newCDPSession(page); + const worker=await until(()=>ctx.serviceWorkers().find(w=>w.url().startsWith(extURL)),'extension worker'); + const commands=await worker.evaluate(()=>chrome.commands.getAll());assert.ok(commands.some(c=>c.name==='toggle-feedback-mode'));report.registeredCommands=commands; + const state=()=>worker.evaluate(async()=>{const [tab]=await chrome.tabs.query({active:true,currentWindow:true});return chrome.tabs.sendMessage(tab.id,{action:'get-state'});}); + const getStorage=async(area='local')=>(await pageCDP.send('Extensions.getStorageItems',{id,storageArea:area})).data; + const seed=values=>pageCDP.send('Extensions.setStorageItems',{id,storageArea:'local',values}); + // Native extension action popups are CDP page targets, not Playwright tab Pages. + async function popup(){ + await page.bringToFront(); + const tabs=(await cdp.send('Target.getTargets',{filter:[{type:'tab'}]})).targetInfos; + const tab=tabs.find(t=>t.url===page.url());assert.ok(tab); + await cdp.send('Extensions.triggerAction',{id,targetId:tab.targetId}); + const target=await until(async()=>(await cdp.send('Target.getTargets')).targetInfos.find(t=>t.url===extURL+'/popup.html'),'native popup'); + const {sessionId}=await cdp.send('Target.attachToTarget',{targetId:target.targetId,flatten:false});let seq=0; + function send(method,params={}){const commandId=++seq;return new Promise((resolve,reject)=>{ + const timer=setTimeout(()=>{cdp.off('Target.receivedMessageFromTarget',cb);reject(new Error(`Popup command timeout: ${method}`));},10000); + const cb=e=>{if(e.sessionId!==sessionId)return;const m=JSON.parse(e.message);if(m.id!==commandId)return;clearTimeout(timer);cdp.off('Target.receivedMessageFromTarget',cb);m.error?reject(new Error(m.error.message)):resolve(m.result);}; + cdp.on('Target.receivedMessageFromTarget',cb);cdp.send('Target.sendMessageToTarget',{sessionId,message:JSON.stringify({id:commandId,method,params})}).catch(reject); + });} + const evaluate=async expression=>{const r=await send('Runtime.evaluate',{expression,returnByValue:true,awaitPromise:true});if(r.exceptionDetails)throw new Error(r.exceptionDetails.text);return r.result.value;}; + async function click(selector){const box=await evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});if(!e||e.disabled)throw Error('Missing/enabled control');const r=e.getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2};})()`);await send('Input.dispatchMouseEvent',{type:'mousePressed',button:'left',clickCount:1,...box});await send('Input.dispatchMouseEvent',{type:'mouseReleased',button:'left',clickCount:1,...box});} + return {evaluate,click,send}; + } + async function start(){const p=await popup();await until(()=>p.evaluate("!document.querySelector('#primary-action-btn').disabled"),'enabled pick');await p.click('#primary-action-btn');await until(async()=>(await state()).feedbackMode,'picker active');} + const frame=name=>until(()=>page.frames().find(f=>f.url().startsWith(extURL+'/'+name+'.html')),'frame '+name); + const check=(name)=>{report.checks.push({name,status:'passed'});console.log('PASS '+name);}; + await start();await page.keyboard.press('Escape');await until(async()=>!(await state()).feedbackMode,'Escape cancels picker');check('toolbar activation and trusted keyboard cancel'); + await start();await page.locator('#save-button').focus();await page.keyboard.press('Alt+Enter'); + let editor=await frame('element');await editor.locator('#note').fill('SELECTED element spacing'); + await editor.locator('summary').filter({hasText:'Acceptance checks'}).click();await editor.locator('#acceptance').fill('Button remains keyboard accessible'); + assert.equal(await page.evaluate(()=>window.siteClicks),0); + const targetText=await editor.locator('#target').textContent();assert.match(targetText,/#save-button/);assert.doesNotMatch(targetText,/SECRET_INPUT_SENTINEL|PARENT_SECRET_SENTINEL/); + assert.doesNotMatch(await page.locator('body').innerText(),/SELECTED element spacing/); + await page.screenshot({path:join(out,'element-editor.png')}); + const sessionBefore=Object.keys(await getStorage('session')); + const p=await popup();await until(()=>p.evaluate("document.querySelector('#primary-action-btn').textContent==='Return to open panel'"),'draft return action'); + await p.click('#history-btn'); + await until(()=>p.evaluate("document.querySelector('#warning').textContent.includes('Save or cancel')"),'draft replacement refused'); + assert.equal(await editor.locator('#note').inputValue(),'SELECTED element spacing');assert.deepEqual(Object.keys(await getStorage('session')),sessionBefore); + await p.click('#primary-action-btn'); + await editor.locator('#note').press('Escape');await editor.getByRole('button',{name:'Keep editing',exact:true}).click(); + assert.equal(await editor.locator('#note').inputValue(),'SELECTED element spacing');check('keyboard pick, private context, and draft replacement protection'); + const base={type:'element',selector:'#save-button',pageUrl:url,timestamp:'2026-09-09T00:00:00Z'}; + await seed({[storageKey]:Array.from({length:500},(_,i)=>({...base,id:`capacity-${i}`,note:`Synthetic capacity ${i}`}))}); + await editor.locator('#save').click();await until(async()=>(await editor.locator('#status').textContent()).includes('500 captures'),'capacity rejection'); + assert.equal(await editor.locator('#note').inputValue(),'SELECTED element spacing');assert.equal((await getStorage())[storageKey].length,500); + await seed({[storageKey]:[]});await editor.locator('#save-next').click();await until(async()=>(await state()).feedbackMode,'save and pick next'); + const saved=(await getStorage())[storageKey];assert.equal(saved.length,1);assert.equal(saved[0].note,'SELECTED element spacing');assert.deepEqual(saved[0].acceptance,['Button remains keyboard accessible']);check('real capacity failure preserves draft; retry saves once and resumes picking'); + await page.locator('#second').click();editor=await frame('element');await editor.locator('#note').fill('Discard me');await editor.locator('#cancel').click();await editor.getByRole('button',{name:'Discard',exact:true}).click();await until(async()=>!(await state()).editorOpen,'discard closes editor');assert.equal((await getStorage())[storageKey].length,1);check('pointer picking and explicit discard'); + const PNG='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='; + const legacy=[ + {...base,id:'legacy-region',type:'region',note:'SELECTED legacy redacted region',pageUrl:'https://legacy.test/PRIVATE_PATH?secret=PRIVATE_QUERY',screenshot:{dataUrl:PNG,annotatedDataUrl:PNG},annotations:[{type:'blur',rect:{x:0,y:0,width:1,height:1},target:{text:'PRIVATE_TARGET'}}]}, + {...base,id:'legacy-pdf',type:'region',sourceKind:'pdf',pageUrl:'file:///PRIVATE_DIRECTORY/brief.pdf',note:'SELECTED legacy PDF',screenshot:{dataUrl:PNG},annotations:[]}, + {...base,id:'legacy-visual',note:'SELECTED legacy Visual',evidence:{before:{dataUrl:PNG},proposed:{dataUrl:PNG}},changeRequest:{kind:'requested-mutation',summary:'SELECTED legacy Visual',requestedMutations:[{action:'restyle',target:{selectors:['#save-button'],tag:'button'},parameters:{styles:{color:'#111111'}}}]}}, + {...base,id:'legacy-add',note:'SELECTED legacy Add',changeRequest:{kind:'requested-mutation',summary:'SELECTED legacy Add',requestedMutations:[{action:'insert',target:{selectors:['#save-button'],tag:'button'},parameters:{placement:'inside-end',content:{type:'text',title:'Notice',body:'Synthetic notice'}}}]}}, + {...base,id:'hidden',note:'UNSELECTED_SENTINEL'} + ]; + await seed({[storageKey]:[...saved,...legacy]}); + let menu=await popup();await menu.click('#history-btn');let history=await frame('history'); + await until(async()=>(await history.locator('article.item').count())===6,'six historical records'); + assert.equal(ctx.pages().length,1,'History created a tab'); + for(const note of legacy.map(i=>i.note))assert.ok(await history.getByText(note,{exact:true}).count(),note); + assert.match(await history.locator('body').innerText(),/Restyle|restyle/);assert.match(await history.locator('body').innerText(),/Insert|insert/); + const evidenceImages=history.locator('img');for(let i=0;i{await evidenceImages.nth(i).scrollIntoViewIfNeeded();return evidenceImages.nth(i).evaluate(im=>im.complete&&im.naturalWidth>0);},'legacy evidence decoded');} + await history.getByRole('button',{name:'Edit feedback: SELECTED element spacing',exact:true}).click(); + await history.locator('#edit-note').fill('SELECTED revised element');await history.locator('#edit-acceptance').fill('Updated acceptance check');await history.locator('#edit-save').click(); + await until(async()=>(await getStorage())[storageKey][0].note==='SELECTED revised element','saved edit'); + const edited=(await getStorage())[storageKey][0];for(const key of ['id','selector','pageUrl','timestamp'])assert.deepEqual(edited[key],saved[0][key]);assert.deepEqual(edited.acceptance,['Updated acceptance check']); + await history.locator('#history-search').fill('legacy');await history.locator('#select-shown').click(); + assert.equal(await history.locator('article.item').count(),4); + await history.locator('h1').scrollIntoViewIfNeeded(); + await page.screenshot({path:join(out,'legacy-history.png')});check('native on-page History, decoded legacy Region/PDF/Visual/Add, and evidence-preserving edits'); + const expectedNotes=legacy.slice(0,4).map(i=>i.note); + const assertExport=text=>{for(const note of expectedNotes)assert.ok(text.includes(note),`missing ${note}`);assert.doesNotMatch(text,/UNSELECTED_SENTINEL|SELECTED revised element|PRIVATE_PATH|PRIVATE_QUERY|PRIVATE_TARGET|PRIVATE_DIRECTORY/);}; + async function preview(button){ + if((await history.locator('details.share-menu').getAttribute('open'))===null)await history.locator('details.share-menu > summary').click(); + await history.locator('#'+button).click();await history.locator('#export-preview[open]').waitFor(); + assert.match(await history.locator('#export-preview-count').innerText(),/^4 items/);assertExport(await history.locator('#export-preview-content').textContent()); + } + for(const button of ['download-json','download-html','download-ai-bundle']){ + await preview(button);const downloaded=page.waitForEvent('download');await history.getByRole('button',{name:'Share these records',exact:true}).click();const download=await downloaded;const path=join(out,download.suggestedFilename());await download.saveAs(path); + const data=button==='download-ai-bundle'?execFileSync('unzip',['-p',path],{maxBuffer:10*1024*1024}).toString():readFileSync(path,'utf8');assertExport(data); + if(button==='download-json'){const payload=JSON.parse(data);writeFileSync(join(out,'selected-handoff.json'),JSON.stringify(payload,null,2));} + if(button==='download-html'){const rendered=await ctx.newPage();await rendered.goto('file://'+path);assert.equal(await rendered.locator('article').count(),4);for(const image of await rendered.locator('img').all())assert.ok(await image.evaluate(im=>im.complete&&im.naturalWidth>0));await rendered.screenshot({path:join(out,'exported-report.png'),fullPage:true});await rendered.close();await page.bringToFront();} + } + // Use a separate extension-origin top-level document for clipboard reads: embedded frames + // intentionally do not receive clipboard-read permission. The extension files stay unchanged. + await ctx.grantPermissions(['clipboard-read','clipboard-write']); + const clipboardReader=await ctx.newPage();await clipboardReader.goto(extURL+'/history.html'); + await clipboardReader.evaluate(async()=>{window.__acceptanceClipboard=await navigator.clipboard.readText();}); + try{ + for(const button of ['copy-markdown','copy-ai']){ + await page.bringToFront();await preview(button);await history.getByRole('button',{name:'Share these records',exact:true}).click(); + await until(async()=>(await history.locator('#status').textContent()).startsWith(button==='copy-markdown'?'Markdown copied.':'AI prompt copied.'),'clipboard export status'); + await clipboardReader.bringToFront();const copied=await clipboardReader.evaluate(()=>navigator.clipboard.readText());assertExport(copied);if(button==='copy-ai')assert.match(copied,/untrusted observations/);writeFileSync(join(out,button+'.txt'),copied); + } + }finally{await clipboardReader.bringToFront();await clipboardReader.evaluate(()=>navigator.clipboard.writeText(window.__acceptanceClipboard));await clipboardReader.close();await page.bringToFront();} + check('all five reviewed selected exports, downloaded bytes, rendered HTML, and clipboard readback'); + // Exact selected deletion must preserve hidden records. + await history.locator('#select-none').click();await history.getByRole('checkbox',{name:'Select SELECTED legacy Add',exact:true}).check(); + page.once('dialog',d=>d.accept());await history.locator('#clear-all').click();await until(async()=>(await getStorage())[storageKey].length===5,'selected deletion'); + assert.ok((await getStorage())[storageKey].some(i=>i.id==='hidden'));assert.ok(!(await getStorage())[storageKey].some(i=>i.id==='legacy-add'));check('selected deletion preserves hidden records'); + await history.locator('#close-history').click(); + // Close the original source tab, then use the actual native popup on a restricted page. + const replacement=await ctx.newPage();await replacement.goto('chrome://version/');await page.close();page=replacement;pageCDP=await ctx.newCDPSession(page); + menu=await popup();await until(()=>menu.evaluate("document.querySelector('#warning').textContent.length>0"),'restricted-page warning');assert.equal(await menu.evaluate("document.querySelector('#primary-action-btn').disabled"),true); + await menu.click('#history-btn');await until(()=>menu.evaluate("location.pathname==='/history.html'&&document.querySelectorAll('article.item').length===5"),'native popup fallback History'); + assert.equal(ctx.pages().length,1);assert.ok((await menu.evaluate('document.body.innerText')).includes('UNSELECTED_SENTINEL')); + const shot=await menu.send('Page.captureScreenshot');writeFileSync(join(out,'restricted-popup-history.png'),Buffer.from(shot.data,'base64')); + check('closed-source persistence and native restricted-page popup fallback without extra tabs'); + await menu.click('#close-history');await page.goto(new URL('/sample.pdf',url).href); + menu=await popup();await until(()=>menu.evaluate("document.querySelector('#warning').textContent.length>0"),'PDF warning');assert.equal(await menu.evaluate("document.querySelector('#primary-action-btn').disabled"),true); + await menu.click('#history-btn');await until(()=>menu.evaluate("location.pathname==='/history.html'&&document.querySelectorAll('article.item').length===5"),'PDF popup History');check('real PDF viewer disables capture and opens native History fallback'); + assert.equal(hash(zip),zipHash);assert.deepEqual(files(),originalFiles);check('exact ZIP and extracted bytes unchanged after acceptance'); + + report.status='passed'; +}catch(error){report.status='failed';report.error=error.stack;throw error; +}finally{ + if(ctx){await ctx.tracing.stop({path:join(out,'trace.zip')}).catch(()=>{});await ctx.close();} + await new Promise(r=>server.close(r)); + try{assert.equal(hash(zip),zipHash,'ZIP changed during acceptance');assert.deepEqual(files(),originalFiles,'extracted extension changed during acceptance');} + catch(error){report.status='failed';report.error=error.stack;throw error;} + finally{writeFileSync(join(out,'acceptance.json'),JSON.stringify(report,null,2)+'\n');rmSync(temp,{recursive:true,force:true});} +} From 7d38f4e14a263846f51ec1ce106330d0d32b7852 Mon Sep 17 00:00:00 2001 From: StoneHub Date: Wed, 9 Sep 2026 22:21:51 -0400 Subject: [PATCH 2/2] Wait for native popup controls before browser input --- test/browser/release-acceptance.mjs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/browser/release-acceptance.mjs b/test/browser/release-acceptance.mjs index 50cbae1..bc7bd24 100644 --- a/test/browser/release-acceptance.mjs +++ b/test/browser/release-acceptance.mjs @@ -26,7 +26,7 @@ assert.equal(manifest.host_permissions,undefined);assert.equal(manifest.content_ assert.equal(manifest.version,version); const report={status:'running',sourceCommit:execFileSync('git',['rev-parse','HEAD'],{cwd:root,encoding:'utf8'}).trim(),zip:zip.split('/').at(-1),sha256:zipHash,version,files:originalFiles,checks:[],limitations:[ 'Isolated Chrome for Testing via CDP; does not certify every Chrome/Edge version or Chrome Web Store approval.', - 'Toolbar action uses the browser Extensions.triggerAction API. Keyboard selection/cancel uses trusted browser input; OS-global shortcut dispatch is covered by manifest/command-handler checks, not the host OS hotkey dispatcher.', + 'Toolbar action uses the browser Extensions.triggerAction API. Keyboard selection/cancel uses trusted browser input; OS-global shortcut dispatch is covered by manifest and command-registration checks, not the host OS hotkey dispatcher.', 'Synthetic legacy and capacity fixtures are seeded through the browser storage debugging API. No real user history or extension code is changed.' ]}; const delay=ms=>new Promise(r=>setTimeout(r,ms)); @@ -64,8 +64,8 @@ try{ const cb=e=>{if(e.sessionId!==sessionId)return;const m=JSON.parse(e.message);if(m.id!==commandId)return;clearTimeout(timer);cdp.off('Target.receivedMessageFromTarget',cb);m.error?reject(new Error(m.error.message)):resolve(m.result);}; cdp.on('Target.receivedMessageFromTarget',cb);cdp.send('Target.sendMessageToTarget',{sessionId,message:JSON.stringify({id:commandId,method,params})}).catch(reject); });} - const evaluate=async expression=>{const r=await send('Runtime.evaluate',{expression,returnByValue:true,awaitPromise:true});if(r.exceptionDetails)throw new Error(r.exceptionDetails.text);return r.result.value;}; - async function click(selector){const box=await evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});if(!e||e.disabled)throw Error('Missing/enabled control');const r=e.getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2};})()`);await send('Input.dispatchMouseEvent',{type:'mousePressed',button:'left',clickCount:1,...box});await send('Input.dispatchMouseEvent',{type:'mouseReleased',button:'left',clickCount:1,...box});} + const evaluate=async expression=>{const r=await send('Runtime.evaluate',{expression,returnByValue:true,awaitPromise:true});if(r.exceptionDetails)throw new Error(r.exceptionDetails.exception?.description||r.exceptionDetails.text);return r.result.value;}; + async function click(selector){const box=await until(()=>evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});if(!e||e.disabled)return null;const r=e.getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2};})()`),'popup control '+selector);await send('Input.dispatchMouseEvent',{type:'mousePressed',button:'left',clickCount:1,...box});await send('Input.dispatchMouseEvent',{type:'mouseReleased',button:'left',clickCount:1,...box});} return {evaluate,click,send}; } async function start(){const p=await popup();await until(()=>p.evaluate("!document.querySelector('#primary-action-btn').disabled"),'enabled pick');await p.click('#primary-action-btn');await until(async()=>(await state()).feedbackMode,'picker active');}