From 892847b8f7b7e27288f885be2dd9b6419c88d79e Mon Sep 17 00:00:00 2001 From: Temo Sulava Date: Thu, 8 Oct 2026 07:40:49 +0000 Subject: [PATCH 1/4] T15: add pinned CI and reproducible production ZIP with native action verification --- .github/workflows/verify.yml | 45 +++++++++++++++++++++++ .gitignore | 3 ++ .npmrc | 4 +- THIRD_PARTY_NOTICES.md | 2 + apps/extension/license-inventory.ts | 20 ++++++++++ apps/extension/wxt.config.ts | 3 +- docs/decisions/003-dependency-security.md | 2 + docs/evidence/T15.md | 9 +++++ docs/licenses/WXT-MIT.txt | 21 +++++++++++ docs/packaging.md | 11 ++++++ docs/tasks/status.json | 4 +- scripts/complete-task.py | 12 +++++- scripts/package.mjs | 4 ++ scripts/package.py | 34 +++++++++++++++++ tests/extension/production.spec.ts | 15 ++++++++ 15 files changed, 183 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/verify.yml create mode 100644 apps/extension/license-inventory.ts create mode 100644 docs/evidence/T15.md create mode 100644 docs/licenses/WXT-MIT.txt create mode 100644 docs/packaging.md create mode 100644 scripts/package.mjs create mode 100644 scripts/package.py create mode 100644 tests/extension/production.spec.ts diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 0000000..f45fa6a --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,45 @@ +name: Verify and package +on: + pull_request: + push: + branches: [main] + workflow_dispatch: +permissions: + contents: read +concurrency: + group: verify-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +jobs: + verify: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4, official tag checked 2026-10-08 + with: + persist-credentials: false + - uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4 + with: + version: 11.19.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24.19.0 + - run: pnpm audit + - run: pnpm install --frozen-lockfile + - run: pnpm exec playwright install --with-deps chromium + - run: pnpm verify + - run: pnpm package + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + if: failure() + with: + name: synthetic-test-failure + path: | + artifacts/ + playwright-report/ + retention-days: 3 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: accesslab-production + path: | + dist/ + artifacts/activation-performance.json + retention-days: 7 diff --git a/.gitignore b/.gitignore index 123240b..a3008e0 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ test-results/ artifacts/ dist/ .output-test/ + +.pnpm-cache/ +__pycache__/ diff --git a/.npmrc b/.npmrc index 4d3dc7f..f9f6200 100644 --- a/.npmrc +++ b/.npmrc @@ -1,3 +1,3 @@ -store-dir=/workspace/.pnpm-store -cache-dir=/workspace/.pnpm-cache +store-dir=.pnpm-store +cache-dir=.pnpm-cache strict-peer-dependencies=false diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 9b570ba..bc9bef7 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -2,3 +2,5 @@ Project-owned code: MIT, see LICENSE. Dependencies retain their own licenses; this project does not relicense them. axe-core (Deque Systems) uses MPL-2.0; its source and license are available at https://github.com/dequelabs/axe-core and in node_modules/axe-core/LICENSE. React MIT; WXT MIT; Playwright Apache-2.0; pnpm MIT. Preserve notices in production packaging. Color-vision matrices: Machado, Oliveira & Fernandes (2009), DOI 10.1109/TVCG.2009.113, full-severity supplementary tables. Values verified against Colorspacious cvd.py (MIT, Copyright 2014–2015 Colorspacious developers), revision 58948923b706879a54071568c7501be3f108797c. License text retained in docs/licenses/colorspacious-MIT.txt. No Colorspacious executable code or dependency is bundled. + +Production builds emit a package/version/SPDX inventory and full license text for every retained third-party module under licenses/. Current runtime packages are React, ReactDOM, Scheduler, axe-core, WXT and @wxt-dev/browser. The WXT npm package omits its license file; WXT and its browser subpackage use the repository MIT text preserved in docs/licenses/WXT-MIT.txt, retrieved from official wxt-dev/wxt revision c89749b30fb61745d174c77b19a0d7ca3bd14992. Exact version tag v0.21.4 was unavailable; license metadata in installed packages is MIT. The packaging script includes unminified axe-core source form in licenses/axe-core-source.txt; the corresponding locked project build source is in this repository. diff --git a/apps/extension/license-inventory.ts b/apps/extension/license-inventory.ts new file mode 100644 index 0000000..6a2cd79 --- /dev/null +++ b/apps/extension/license-inventory.ts @@ -0,0 +1,20 @@ +import { existsSync,readFileSync } from 'node:fs'; +import { dirname,join,parse } from 'node:path'; +import { createHash } from 'node:crypto'; +import type { Plugin } from 'vite'; +// Record packages actually retained in each production bundle, never machine paths. +export function licenseInventory():Plugin{return{name:'accesslab-license-inventory',generateBundle(_options,bundle){ + const packages=new Map(); + for(const output of Object.values(bundle))if(output.type==='chunk')for(const [id,module] of Object.entries(output.modules)){ + if(!module.renderedLength||!id.includes('node_modules'))continue;let dir=dirname(id.split('?')[0]); + while(dir!==parse(dir).root){const manifest=join(dir,'package.json');if(existsSync(manifest)){ + const pkg=JSON.parse(readFileSync(manifest,'utf8')) as {name:string;version:string;license:string};if(!pkg.name)break; + const name=pkg.name.replace(/[^a-zA-Z0-9._-]/g,'-');packages.set(pkg.name,{name:pkg.name,version:pkg.version,license:pkg.license}); + let license=['LICENSE','LICENSE.md','LICENSE.txt','license','license.md'].map(file=>join(dir,file)).find(existsSync); + if(!license&&['wxt','@wxt-dev/browser'].includes(pkg.name))license=join(import.meta.dirname,'../../docs/licenses/WXT-MIT.txt'); + if(!license)throw Error(`Missing license text for bundled ${pkg.name}`); + this.emitFile({type:'asset',fileName:`licenses/${name}.txt`,source:readFileSync(license,'utf8')});break; + }dir=dirname(dir);} + } + if(packages.size){const content=JSON.stringify([...packages.values()].sort((a,b)=>a.nameb.name?1:0),null,2)+'\n';const hash=createHash('sha256').update(content).digest('hex').slice(0,12);this.emitFile({type:'asset',fileName:`licenses/inventory-${hash}.json`,source:content});} +}};} diff --git a/apps/extension/wxt.config.ts b/apps/extension/wxt.config.ts index 52f8f36..fb73901 100644 --- a/apps/extension/wxt.config.ts +++ b/apps/extension/wxt.config.ts @@ -1,6 +1,7 @@ +import { licenseInventory } from './license-inventory'; import { defineConfig } from 'wxt'; export default defineConfig({ outDir: process.env.ACCESSLAB_TEST === '1' ? '.output-test' : '.output', - vite: () => ({ define: { __ACCESSLAB_TEST__: JSON.stringify(process.env.ACCESSLAB_TEST === '1') } }), + vite: () => ({ plugins:[licenseInventory()],define: { __ACCESSLAB_TEST__: JSON.stringify(process.env.ACCESSLAB_TEST === '1') } }), manifest: { name: 'AccessLab', description: 'Find accessibility barriers. Inspect the cause. Verify the fix.', permissions: ['activeTab', 'scripting', 'storage'], ...(process.env.ACCESSLAB_TEST === '1' ? { host_permissions: ['http://127.0.0.1/*'] } : {}), action: { default_title: 'Open AccessLab' } }, }); diff --git a/docs/decisions/003-dependency-security.md b/docs/decisions/003-dependency-security.md index d5701fd..a6362fc 100644 --- a/docs/decisions/003-dependency-security.md +++ b/docs/decisions/003-dependency-security.md @@ -7,3 +7,5 @@ Current lockfile: pnpm's frozen-install supply-chain policy check passed 324 ent Future dependency changes must record their assessment here or in task evidence. Prefer existing dependencies and local code over unnecessary packages. T06: declaring already-locked React 19.3.0, ReactDOM 19.3.0 and axe-core 4.14.0 at root for component/browser test access. Rechecked official metadata first: React/ReactDOM point to github.com/react/react, Testing Library to testing-library/react-testing-library; provenance metadata present; no installation lifecycle hooks. axe assessment above. No new version selected. + +T15: no new npm package/version added. Rechecked pnpm audit against 324 locked entries: zero known advisories. CI uses official actions/checkout, actions/setup-node, actions/upload-artifact and pnpm/action-setup v4 tag commit IDs queried from their official GitHub repositories on 2026-10-08 and pinned in the workflow. Playwright browser/system libraries remain fetched by its official installer with verification intact. Bundle graph inventory identifies six runtime packages and emits full license text; WXT/npm browser package omit license text, so official project MIT license was retrieved at pinned repository revision c89749b30fb61745d174c77b19a0d7ca3bd14992 (package MIT metadata agrees; exact v0.21.4 Git tag unavailable). Python archiver uses only standard library. diff --git a/docs/evidence/T15.md b/docs/evidence/T15.md new file mode 100644 index 0000000..02d079b --- /dev/null +++ b/docs/evidence/T15.md @@ -0,0 +1,9 @@ +# T15 — pinned CI and deterministic production packaging + +Added least-privilege GitHub Actions (contents:read, no persisted checkout credentials, pinned official action commits, exact Node/pnpm, advisory check, frozen install, official Playwright/system libraries, full verify, package). Synthetic failure artifacts retain three days; production/timing artifacts seven. Completion helper waits for reported CI checks before PR merge. No new npm dependency/version; audit recheck: 324 locked entries, zero known advisories. .npmrc caches now use portable relative locations; lockfile unchanged. + +Production builds inspect actual retained module graphs and emit six runtime package/version/SPDX inventories/full licenses. WXT/browser omitted package licenses restored from pinned official project MIT text; provenance/source limitation recorded in notices/ADR003. Standard-library Python archiver includes project/third-party notices, installation docs, unminified axe source form; sorted files/fixed timestamps/permissions/stored ZIP entries avoid compression variations. Rejects wrong permissions/host/static/external configuration, test hook, source maps and dynamic/remote executable code. No public release/store publish/tag. + +Fresh source checkout from staged Git tree in /tmp/accesslab-clean-T15: frozen install exit 0 with supply-chain policy/integrity checks intact; full pnpm verify exit 0 (lint/types/build, 21 unit, 4 component, 32 loaded-extension, 3 visual). Production native Chrome browser-level Extensions.triggerAction actually grants activeTab with no host permissions, runs bundled audit, handles chrome://settings restriction and clears badge/title on successful retry; test activation hook absent. Agent opened artifacts/production-action.png: normal panel/audit result, host layout preserved, synthetic input remains outside report fields. Current stable Chrome human walkthrough still pending. + +Fresh checkout package and two repeated local packages byte-identical, cmp exit 0. ZIP has 22 files, SHA-256 c654c1316599476bee17808a4cc5ae34f1d767396021a5452993770caec6a7f9. Artifact dist/accesslab-0.1.0-chrome.zip and checksum ignored in Git. Clean logs in /tmp/accesslab-T15-clean-verify.log and package log. Remote CI pending at PR creation; actual result/run URL will be recorded after completion, never inferred from workflow presence. diff --git a/docs/licenses/WXT-MIT.txt b/docs/licenses/WXT-MIT.txt new file mode 100644 index 0000000..5d1a7c7 --- /dev/null +++ b/docs/licenses/WXT-MIT.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Aaron + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/packaging.md b/docs/packaging.md new file mode 100644 index 0000000..df3507e --- /dev/null +++ b/docs/packaging.md @@ -0,0 +1,11 @@ +# Verification and production packaging + +Use Node 24.19.0, pnpm 11.19.0 and Python 3. Run `pnpm install --frozen-lockfile`, `pnpm exec playwright install chromium`, `pnpm verify`, then `pnpm package`. On a fresh Linux runner, `pnpm exec playwright install --with-deps chromium` installs required signed system libraries too. Review dependency identity, scripts, integrity/provenance and advisories before adding/updating packages (ADR 003); keep TLS, signatures and integrity checks enabled. `pnpm audit` reports known advisories, not a guarantee of uncompromised dependencies. + +`pnpm build` writes the production extension to apps/extension/.output/chrome-mv3. `pnpm build:test` writes a separate localhost-only automation build to .output-test, with a worker-only activation hook. Never distribute .output-test. Production action testing uses Chrome's native browser-level Extensions.triggerAction capability, on an isolated test browser with its explicit debugging flag; the shipped artifact has no such flag or test hook. + +`pnpm package` checks the production manifest's three permissions (activeTab, scripting, storage), rejects broad permissions/static scripts/external connections, checks for test activation and dynamic/remote executable code, and creates dist/accesslab-0.1.0-chrome.zip plus .zip.sha256. It includes installation instructions, local docs, actual bundled-package inventories and full licenses, project MIT notice and axe-core source form. Extract, enable Chrome Developer mode and Load unpacked from the extracted directory. This creates no store publication or public release. + +Archives use sorted files, fixed timestamps/permissions and stored ZIP entries, so the archiver does not depend on compression-library variations. Locked build output must also be identical: compare hashes across repeated builds and clean source checkouts. The checksum file uses standard SHA-256 format (`sha256sum -c accesslab-0.1.0-chrome.zip.sha256` from dist/). Output is ignored in Git. + +The GitHub workflow uses official actions pinned by commit, Node/pnpm pins, frozen install, official Playwright browser installation, all gates and packaging. Token permission is contents:read, checkout credentials are not retained, runs cancel superseded revisions, synthetic failure artifacts expire after three days and production artifacts after seven. No secrets, deployment, release tag or upload to a store is required. T15 evidence records actual local/remote outcomes; a configured workflow alone is not proof of a successful remote run. diff --git a/docs/tasks/status.json b/docs/tasks/status.json index a161d91..57c01bd 100644 --- a/docs/tasks/status.json +++ b/docs/tasks/status.json @@ -177,7 +177,7 @@ "dependencies": [ "T14" ], - "state": "pending", + "state": "verified", "acceptanceCriteria": "AccessLab-Implementation-Plan.md section 9 T15", "commit": null, "evidencePath": "docs/evidence/T15.md", @@ -209,5 +209,5 @@ "humanGates": [] } ], - "checkpoint": "T14 verified. Next task in dependency order." + "checkpoint": "T15 verified. Next task in dependency order." } diff --git a/scripts/complete-task.py b/scripts/complete-task.py index dc0766f..48a9d6e 100644 --- a/scripts/complete-task.py +++ b/scripts/complete-task.py @@ -1,4 +1,4 @@ -import sys,json,subprocess +import sys,json,subprocess,time from pathlib import Path id=sys.argv[1]; summary=sys.argv[2] s=json.loads(Path('docs/tasks/status.json').read_text());t=next(x for x in s['tasks'] if x['id']==id) @@ -17,6 +17,16 @@ print(pr.stdout,pr.stderr) if pr.returncode==0: url=pr.stdout.strip().splitlines()[-1] + if Path('.github/workflows/verify.yml').exists(): + for attempt in range(12): + result=subprocess.run(['gh','pr','checks',url,'--json','name,state,link'],capture_output=True,text=True) + try: checks=json.loads(result.stdout) + except json.JSONDecodeError: checks=[] + if isinstance(checks,list) and checks: break + time.sleep(5) + else: raise RuntimeError('CI checks not reported; investigate workflow scheduling before merge') + with open(f'/tmp/accesslab-{id}-pr-checks.log','w') as log: + subprocess.run(['gh','pr','checks',url,'--watch','--interval','10'],stdout=log,stderr=subprocess.STDOUT,check=True) subprocess.run(['gh','pr','merge',url,'--merge'],check=True) subprocess.run(['git','fetch','origin','main'],check=True) subprocess.run(['git','merge','--ff-only','origin/main'],check=True) diff --git a/scripts/package.mjs b/scripts/package.mjs new file mode 100644 index 0000000..16ea0d0 --- /dev/null +++ b/scripts/package.mjs @@ -0,0 +1,4 @@ +import { spawnSync } from 'node:child_process'; +const result=spawnSync('python3',['-I','scripts/package.py'],{stdio:'inherit'}); +if(result.error)throw result.error; +process.exit(result.status??1); diff --git a/scripts/package.py b/scripts/package.py new file mode 100644 index 0000000..519feaf --- /dev/null +++ b/scripts/package.py @@ -0,0 +1,34 @@ +"""Create a deterministic production archive without third-party packaging tools.""" +from pathlib import Path +import json,re,hashlib,zipfile +root=Path(__file__).resolve().parent.parent +build=root/'apps/extension/.output/chrome-mv3' +manifest=json.loads((build/'manifest.json').read_text()) +assert manifest['manifest_version']==3 +assert sorted(manifest['permissions'])==['activeTab','scripting','storage'] +assert not any(manifest.get(key) for key in ['host_permissions','optional_host_permissions','content_scripts','externally_connectable','web_accessible_resources','optional_permissions']) +assert manifest['background']=={'service_worker':'background.js'} +assert manifest['action']=={'default_title':'Open AccessLab'} +files={p.relative_to(build).as_posix():p.read_bytes() for p in build.rglob('*') if p.is_file()} +assert files and not any(name.endswith('.map') for name in files) +for name,data in files.items(): + if name.endswith('.js'): + code=data.decode() + assert '__accesslabTestActivate' not in code, 'Test activation in production' + assert not re.search(r'\beval\s*\(|\bnew\s+Function\b|\bimport\s*\(\s*[\'\"]https?://',code), 'Dynamic/remote executable code' + if name.endswith('.html'): + assert not re.search(r']*src=[\'\"]https?://',data.decode()), 'Remote script' +for name in ['LICENSE','THIRD_PARTY_NOTICES.md']: + files[name]=(root/name).read_bytes() +for label,path in {'axe-core-MPL-2.0.txt':'node_modules/axe-core/LICENSE','React-MIT.txt':'node_modules/react/LICENSE','ReactDOM-MIT.txt':'node_modules/react-dom/LICENSE','WXT-MIT.txt':'docs/licenses/WXT-MIT.txt','Colorspacious-MIT.txt':'docs/licenses/colorspacious-MIT.txt','axe-core-source.txt':'node_modules/axe-core/axe.js'}.items(): + files['licenses/'+label]=(root/path).read_bytes() +files['INSTALL.txt']=b'AccessLab 0.1 private engineering beta. Extract this archive, open chrome://extensions, enable Developer mode, choose Load unpacked, and select this directory. Activate on an ordinary HTTP(S) page using the toolbar action. No automatic activation or all-site access. Read the packaged docs.html and project release checklist. Axe-core source and licenses are included under licenses/.\n' +dist=root/'dist';dist.mkdir(exist_ok=True) +archive=dist/f"accesslab-{manifest['version']}-chrome.zip" +with zipfile.ZipFile(archive,'w',compression=zipfile.ZIP_STORED) as out: + for name,data in sorted(files.items()): + info=zipfile.ZipInfo(name,(1980,1,1,0,0,0));info.create_system=3;info.external_attr=0o100644<<16;info.compress_type=zipfile.ZIP_STORED + out.writestr(info,data) +digest=hashlib.sha256(archive.read_bytes()).hexdigest() +checksum=archive.with_suffix('.zip.sha256');checksum.write_text(f'{digest} {archive.name}\n') +print(f'{archive}: {len(files)} files; SHA-256 {digest}') diff --git a/tests/extension/production.spec.ts b/tests/extension/production.spec.ts new file mode 100644 index 0000000..7d3dff8 --- /dev/null +++ b/tests/extension/production.spec.ts @@ -0,0 +1,15 @@ +import { test,expect,chromium } from '@playwright/test'; +import { mkdtemp,rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join,resolve } from 'node:path'; +test('production extension native action grants activeTab, runs audit and gives restricted-page feedback',async()=>{ + const profile=await mkdtemp(join(tmpdir(),'accesslab-production-'));const extension=resolve('apps/extension/.output/chrome-mv3'); + const context=await chromium.launchPersistentContext(profile,{channel:'chromium',...(process.env.ACCESSLAB_CHROMIUM?{executablePath:process.env.ACCESSLAB_CHROMIUM}:{}),headless:true,ignoreDefaultArgs:['--disable-extensions'],args:['--enable-unsafe-extension-debugging',`--disable-extensions-except=${extension}`,`--load-extension=${extension}`]}); + try{const worker=context.serviceWorkers()[0]??await context.waitForEvent('serviceworker');const id=new URL(worker.url()).host;const page=await context.newPage();await page.goto('http://127.0.0.1:4173/');const browserCdp=await context.browser()!.newBrowserCDPSession();const targets=await browserCdp.send('Target.getTargets',{filter:[{type:'tab'}]});const target=targets.targetInfos.find(t=>t.url===page.url())?.targetId;expect(target).toBeTruthy(); + const click=()=>browserCdp.send('Extensions.triggerAction',{id,targetId:target!});await click();await expect(page.locator('[data-accesslab-root]')).toHaveCount(1);await click();await expect(page.locator('[data-accesslab-root]')).toHaveCount(1); + const production=await worker.evaluate(()=>{const api=globalThis as unknown as {chrome:{runtime:{getManifest:()=>{permissions:string[];host_permissions?:string[];content_scripts?:unknown[]}}};__accesslabTestActivate?:unknown};return{manifest:api.chrome.runtime.getManifest(),testHook:typeof api.__accesslabTestActivate};});expect(production.manifest.permissions.sort()).toEqual(['activeTab','scripting','storage']);expect(production.manifest.host_permissions).toBeUndefined();expect(production.manifest.content_scripts).toBeUndefined();expect(production.testHook).toBe('undefined'); + await page.getByRole('button',{name:'Run audit',exact:true}).click();await expect(page.getByRole('status')).toContainText('Scan complete');await page.screenshot({path:'artifacts/production-action.png',caret:'initial'}); + await page.goto('chrome://settings/');await click();const badge=()=>worker.evaluate(async()=>{const api=globalThis as unknown as {chrome:{tabs:{query:(x:object)=>Promise<{id:number}[]>};action:{getBadgeText:(x:object)=>Promise;getTitle:(x:object)=>Promise}}};const tab=(await api.chrome.tabs.query({active:true,currentWindow:true}))[0];return{badge:await api.chrome.action.getBadgeText({tabId:tab.id}),title:await api.chrome.action.getTitle({tabId:tab.id})};});await expect.poll(badge).toMatchObject({badge:'!'});expect((await badge()).title).toContain('ordinary http(s)'); + await page.goto('http://127.0.0.1:4173/');await click();await expect(page.locator('[data-accesslab-root]')).toHaveCount(1);await expect.poll(badge).toEqual({badge:'',title:'Open AccessLab'});await page.getByRole('button',{name:'Close AccessLab'}).click();await expect(page.locator('[data-accesslab-root],[data-accesslab-overlay]')).toHaveCount(0);await browserCdp.detach(); + }finally{await context.close();await rm(profile,{recursive:true,force:true});} +}); From 4f6a55382dcd7096b78fb496b5b84b6d6ac0f568 Mon Sep 17 00:00:00 2001 From: Temo Sulava Date: Thu, 8 Oct 2026 07:40:49 +0000 Subject: [PATCH 2/4] docs: record T15 completion --- docs/tasks/status.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/tasks/status.json b/docs/tasks/status.json index 57c01bd..5c341c0 100644 --- a/docs/tasks/status.json +++ b/docs/tasks/status.json @@ -177,9 +177,9 @@ "dependencies": [ "T14" ], - "state": "verified", + "state": "done", "acceptanceCriteria": "AccessLab-Implementation-Plan.md section 9 T15", - "commit": null, + "commit": "892847b8f7b7e27288f885be2dd9b6419c88d79e", "evidencePath": "docs/evidence/T15.md", "blockedReason": null, "humanGates": [] From 4994588fac05433455aec8b890384ec1877daa17 Mon Sep 17 00:00:00 2001 From: Temo Sulava Date: Thu, 8 Oct 2026 07:42:39 +0000 Subject: [PATCH 3/4] fix: read CI status with supported GitHub CLI fields --- scripts/complete-task.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/complete-task.py b/scripts/complete-task.py index 48a9d6e..121b1b0 100644 --- a/scripts/complete-task.py +++ b/scripts/complete-task.py @@ -19,8 +19,8 @@ url=pr.stdout.strip().splitlines()[-1] if Path('.github/workflows/verify.yml').exists(): for attempt in range(12): - result=subprocess.run(['gh','pr','checks',url,'--json','name,state,link'],capture_output=True,text=True) - try: checks=json.loads(result.stdout) + result=subprocess.run(['gh','pr','view',url,'--json','statusCheckRollup'],capture_output=True,text=True) + try: checks=json.loads(result.stdout).get('statusCheckRollup',[]) except json.JSONDecodeError: checks=[] if isinstance(checks,list) and checks: break time.sleep(5) From 9f7db16d014ebcaa04bfe70c1514a2c2344c29ef Mon Sep 17 00:00:00 2001 From: Temo Sulava Date: Thu, 8 Oct 2026 07:43:12 +0000 Subject: [PATCH 4/4] fix: wait for the verification workflow before merge --- scripts/complete-task.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/complete-task.py b/scripts/complete-task.py index 121b1b0..20722ba 100644 --- a/scripts/complete-task.py +++ b/scripts/complete-task.py @@ -22,7 +22,7 @@ result=subprocess.run(['gh','pr','view',url,'--json','statusCheckRollup'],capture_output=True,text=True) try: checks=json.loads(result.stdout).get('statusCheckRollup',[]) except json.JSONDecodeError: checks=[] - if isinstance(checks,list) and checks: break + if isinstance(checks,list) and any(c.get('name')=='verify' and c.get('workflowName')=='Verify and package' for c in checks): break time.sleep(5) else: raise RuntimeError('CI checks not reported; investigate workflow scheduling before merge') with open(f'/tmp/accesslab-{id}-pr-checks.log','w') as log: