From af6a2bed1808bde91697a1d56d50b9c540f8d644 Mon Sep 17 00:00:00 2001 From: gololdf1sh Date: Thu, 20 Aug 2026 18:51:27 +0300 Subject: [PATCH 1/3] fix(editor): BDD tests drop the duplicate parameters UI (#32) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In a gherkin project the parameters ARE the body's Examples tables: the backend derives the params/examples records from them, and the web app offers no separate parameters UI at all. The editor still showed both — the dedicated table under the read-only description and the editable grid in edit mode — printing the same data twice, and the grid was a second write path into body-owned state (a direct params write on a BDD test answers 400 "Examples already exists"). The language probe added by #35 is now shared through one memoized promise per document and started at boot for test documents too. A gherkin answer skips the view table before its read and disables the grid the way basic mode already does: section hidden, Save writes no params, drafts carry none. Markdown projects are untouched, and a failed probe keeps today's behaviour. Co-authored-by: Claude Fable 5 --- extension/editor/editor.js | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/extension/editor/editor.js b/extension/editor/editor.js index 72fadcd..7ed7a38 100644 --- a/extension/editor/editor.js +++ b/extension/editor/editor.js @@ -409,6 +409,12 @@ return String(attrs.lang || '').toLowerCase(); } catch { return ''; } } + // Shared by the create seed (#35) and both parameter blocks (#32) — one probe per document. + let projectLangPromise = null; + function projectLangOnce() { + if (!projectLangPromise) projectLangPromise = loadProjectLang(); + return projectLangPromise; + } // Silent to screen readers: the sentence they get is on the heading that holds it (renderView). function skBar(cls, w) { @@ -934,10 +940,11 @@ }; } - // The read-only table under a test's description. Optional by contract: no session, no parameters - // or a failed read all draw nothing rather than saying so. + // The read-only table under a test's description. Optional by contract: no session, no parameters, + // a failed read or a BDD project (#32 — the body's Examples already show this data) draw nothing. async function appendParamsTable(pane, uid) { if (!uid || TestomatAPI.jwtAvailable() === false) return; + if ((await projectLangOnce()) === 'gherkin') return; let read = null; try { read = await TestomatAPI.getTestParams(uid); } catch (e) { console.debug('parameters unavailable', e); return; } const rows = read.examples || []; @@ -2044,8 +2051,10 @@ // ---- parameters: what the test already has (#5) -------------------------- // Session-only, so basic mode drops the block whole rather than offering a grid that could not // be saved. Any other failure is said once and leaves an empty grid to write in. + // BDD drops it too (#32): the body's Examples own the data — a grid write collides server-side. async function loadParams() { if (TestomatAPI.jwtAvailable() === false) { paramsCtl.disable(); return; } + if ((await projectLangOnce()) === 'gherkin') { paramsCtl.disable(); return; } if (!editing) { paramsCtl.ready(); return; } try { const read = await TestomatAPI.getTestParams(uid); @@ -2372,9 +2381,8 @@ if (cx.test) renderView({ ctx: cx.ctx, uid: cx.test, loading: true }); // The template seed rides along with the probe — loadTemplates swallows every failure. const templatesLoad = cx.suite ? loadTemplates() : null; - // Fired here rather than at the create branch so the language read (#35) overlaps the - // template read instead of queueing a second round trip behind it. - const projectLangLoad = cx.suite ? loadProjectLang() : null; + // Started at boot so the language read (#35, #32) overlaps the other round trips. + const projectLangLoad = (cx.suite || cx.test) ? projectLangOnce() : null; // #187 — a direct load (restored tab, bookmark) never passed the Tests tab's own gate. if (await readonlyGate()) { renderMessage(READONLY_BLOCK, { back: panelCtx }); return; } From fafcba3c52a2e3dc9b7d51572a427d41f5d88d19 Mon Sep 17 00:00:00 2001 From: gololdf1sh Date: Thu, 20 Aug 2026 20:32:31 +0300 Subject: [PATCH 2/3] =?UTF-8?q?test(env):=20platform/browser=20detection?= =?UTF-8?q?=20probes=20=E2=80=94=20the=20"before"=20picture=20(#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reporter's Ubuntu machine lands in testrun meta as OS "Android": the client-hints platform lies on some desktop Linux builds, and env-info.js takes it at its word. Before touching the detector, this pins down what it answers today. The sample matrix drives the real env-info.js source with recorded hints+UA pairs for every target combination (Windows/macOS/Ubuntu/ChromeOS x Chrome/Edge/Opera, headless, and the lying-mobile negatives) and is deliberately red on the bug cases. The live probe injects the same source into a real branded browser and checks the answer against the actual OS — run by a 3-OS x Chrome/Edge workflow on every push of this branch. A plain page stands in for the panel (Chrome 137+ refuses --load-extension) served as a local secure context, because hints exist only there. Co-authored-by: Claude Fable 5 --- .github/workflows/env-check.yml | 53 +++++++++++++++++++ .gitignore | 3 ++ tests/env-info-matrix.mjs | 91 +++++++++++++++++++++++++++++++++ tests/env-live-probe.mjs | 68 ++++++++++++++++++++++++ 4 files changed, 215 insertions(+) create mode 100644 .github/workflows/env-check.yml create mode 100644 tests/env-info-matrix.mjs create mode 100644 tests/env-live-probe.mjs diff --git a/.github/workflows/env-check.yml b/.github/workflows/env-check.yml new file mode 100644 index 0000000..9841389 --- /dev/null +++ b/.github/workflows/env-check.yml @@ -0,0 +1,53 @@ +name: env-check + +on: + workflow_dispatch: + push: + branches: ['fix/12-*'] + paths: + - 'tests/**' + - '.github/workflows/env-check.yml' + - 'extension/sidepanel/core/env-info.js' + +jobs: + check: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + browser: [chrome, msedge] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: sample-matrix + id: sample-matrix + shell: bash + run: node tests/env-info-matrix.mjs + + # Runs even when the matrix is red: before the fix it fails by design and the + # live probe result is still the evidence we need. + - name: install playwright + if: always() + shell: bash + run: | + npm init -y > /dev/null + npm install --no-audit --no-fund --silent playwright + + - name: live-probe + if: always() + shell: bash + env: + BROWSER_CHANNEL: ${{ matrix.browser }} + EXPECT_OS: ${{ runner.os == 'Windows' && 'Windows' || runner.os == 'macOS' && 'macOS' || 'Linux' }} + run: | + if [ "${{ runner.os }}" = "Linux" ]; then + xvfb-run -a node tests/env-live-probe.mjs + else + node tests/env-live-probe.mjs + fi diff --git a/.gitignore b/.gitignore index 1f8158a..5b0fee3 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ *.zip *.crx *.pem +node_modules/ +package.json +package-lock.json diff --git a/tests/env-info-matrix.mjs b/tests/env-info-matrix.mjs new file mode 100644 index 0000000..55ad56d --- /dev/null +++ b/tests/env-info-matrix.mjs @@ -0,0 +1,91 @@ +#!/usr/bin/env node +// Sample matrix for envOs()/envBrowser() (#12). Expectations describe the POST-FIX +// truth: a UA-CH platform that contradicts a desktop UA must not win. +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { runInNewContext } from 'node:vm'; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); +const source = readFileSync(join(repoRoot, 'extension/sidepanel/core/env-info.js'), 'utf8'); + +// env-info.js is a plain top-level script, so its declarations land on the sandbox. +function loadEnvInfo(navigator) { + const sandbox = { navigator }; + runInNewContext(source, sandbox); + return sandbox; +} + +const WIN_CHROME = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36'; +const MAC_CHROME = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36'; +const LINUX_CHROME = + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36'; +const WIN_EDGE = `${WIN_CHROME} Edg/138.0.0.0`; +const MAC_EDGE = `${MAC_CHROME} Edg/138.0.0.0`; +const LINUX_EDGE = `${LINUX_CHROME} Edg/138.0.0.0`; +const MAC_OPERA = `${MAC_CHROME} OPR/122.0.0.0`; +const CROS_CHROME = + 'Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36'; +const MAC_HEADLESS = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/149.0.0.0 Safari/537.36'; +const ANDROID_MOBILE = + 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Mobile Safari/537.36'; +const IPHONE_SAFARI = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1'; + +const CHROME_BRANDS = [ + { brand: 'Not)A;Brand', version: '99' }, + { brand: 'Google Chrome', version: '138' }, + { brand: 'Chromium', version: '138' }, +]; +const EDGE_BRANDS = [ + { brand: 'Not)A;Brand', version: '99' }, + { brand: 'Microsoft Edge', version: '138' }, + { brand: 'Chromium', version: '138' }, +]; +const OPERA_BRANDS = [ + { brand: 'Not)A;Brand', version: '99' }, + { brand: 'Opera', version: '122' }, + { brand: 'Chromium', version: '138' }, +]; + +const cases = [ + { name: 'win-chrome', uaData: { platform: 'Windows', brands: CHROME_BRANDS }, ua: WIN_CHROME, os: 'Windows', browser: 'Chrome 138' }, + { name: 'mac-chrome', uaData: { platform: 'macOS', brands: CHROME_BRANDS }, ua: MAC_CHROME, os: 'macOS', browser: 'Chrome 138' }, + { name: 'linux-chrome', uaData: { platform: 'Linux', brands: CHROME_BRANDS }, ua: LINUX_CHROME, os: 'Linux', browser: 'Chrome 138' }, + { name: 'win-edge', uaData: { platform: 'Windows', brands: EDGE_BRANDS }, ua: WIN_EDGE, os: 'Windows', browser: 'Edge 138' }, + { name: 'mac-edge', uaData: { platform: 'macOS', brands: EDGE_BRANDS }, ua: MAC_EDGE, os: 'macOS', browser: 'Edge 138' }, + { name: 'linux-edge', uaData: { platform: 'Linux', brands: EDGE_BRANDS }, ua: LINUX_EDGE, os: 'Linux', browser: 'Edge 138' }, + { name: 'mac-opera', uaData: { platform: 'macOS', brands: OPERA_BRANDS }, ua: MAC_OPERA, os: 'macOS', browser: 'Opera 122' }, + { name: 'cros-chrome', uaData: { platform: 'Chrome OS', brands: CHROME_BRANDS }, ua: CROS_CHROME, os: 'Chrome OS', browser: 'Chrome 138' }, + // Today's honest fallback: \bChrome\/ does not match inside HeadlessChrome/. + { name: 'headless-no-hints', uaData: undefined, ua: MAC_HEADLESS, os: 'macOS', browser: 'Unknown' }, + { name: 'issue12-lying-android-linux', uaData: { platform: 'Android', brands: CHROME_BRANDS }, ua: LINUX_CHROME, os: 'Linux', browser: 'Chrome 138' }, + { name: 'lying-android-windows', uaData: { platform: 'Android', brands: CHROME_BRANDS }, ua: WIN_CHROME, os: 'Windows', browser: 'Chrome 138' }, + { name: 'real-android-consistent', uaData: { platform: 'Android', brands: CHROME_BRANDS }, ua: ANDROID_MOBILE, os: 'Android', browser: 'Chrome 138' }, + { name: 'ios-ua-fallback', uaData: undefined, ua: IPHONE_SAFARI, os: 'iOS', browser: 'Safari 17' }, + { name: 'linux-ua-fallback', uaData: undefined, ua: LINUX_CHROME, os: 'Linux', browser: 'Chrome 138' }, +]; + +let passed = 0; +let failed = 0; + +for (const testCase of cases) { + const env = loadEnvInfo({ userAgent: testCase.ua, userAgentData: testCase.uaData }); + const actual = { os: env.envOs(), browser: env.envBrowser() }; + if (actual.os === testCase.os && actual.browser === testCase.browser) { + passed += 1; + console.log(`ok ${testCase.name}`); + } else { + failed += 1; + console.log( + `FAIL ${testCase.name}: expected OS=${testCase.os} Browser=${testCase.browser} ` + + `got OS=${actual.os} Browser=${actual.browser}`, + ); + } +} + +console.log(`${passed} passed, ${failed} failed`); +if (failed) process.exit(1); diff --git a/tests/env-live-probe.mjs b/tests/env-live-probe.mjs new file mode 100644 index 0000000..5ccf20f --- /dev/null +++ b/tests/env-live-probe.mjs @@ -0,0 +1,68 @@ +#!/usr/bin/env node +// Reads what the panel's env-info would report in a real branded browser on a real OS (#12). +// A blank page is a valid stand-in: platform/brand client hints are browser-global, not +// per-document, and branded Chrome 137+ refuses --load-extension. +// The page must be a SECURE context though — navigator.userAgentData is undefined on +// about:blank/http, which would silently measure the UA-string fallback instead. +import { readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const CHANNELS = ['chrome', 'msedge']; +const EXPECTED_OS = ['Windows', 'macOS', 'Linux']; + +const channel = process.env.BROWSER_CHANNEL; +const expectOs = process.env.EXPECT_OS; + +if (!CHANNELS.includes(channel)) { + console.error(`BROWSER_CHANNEL must be one of ${CHANNELS.join(' | ')}, got: ${channel ?? '(unset)'}`); + process.exit(1); +} +if (!EXPECTED_OS.includes(expectOs)) { + console.error(`EXPECT_OS must be one of ${EXPECTED_OS.join(' | ')}, got: ${expectOs ?? '(unset)'}`); + process.exit(1); +} + +const { chromium } = await import('playwright'); + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const envInfoSource = readFileSync(join(repoRoot, 'extension/sidepanel/core/env-info.js'), 'utf8'); + +const browser = await chromium.launch({ channel, headless: false }); + +try { + const page = await browser.newPage(); + // Empty https page fulfilled locally: a secure context without a server or network. + await page.route('**/*', (route) => route.fulfill({ contentType: 'text/html', body: '' })); + await page.goto('https://env-probe.local/'); + // Plain top-level script: injecting it makes envOs/envBrowser page globals. + await page.addScriptTag({ content: envInfoSource }); + + const info = await page.evaluate(() => ({ + os: envOs(), + browser: envBrowser(), + uaDataPlatform: navigator.userAgentData ? navigator.userAgentData.platform : null, + ua: navigator.userAgent, + })); + console.log(JSON.stringify(info, null, 2)); + + const browserPattern = channel === 'msedge' ? /^Edge \d+$/ : /^Chrome \d+$/; + const problems = []; + if (info.uaDataPlatform === null) { + problems.push('UA client hints unavailable — this run measured the UA-string fallback, not the hints path'); + } + if (info.os !== expectOs) problems.push(`OS: expected "${expectOs}", got "${info.os}"`); + if (!browserPattern.test(info.browser)) { + problems.push(`Browser: expected ${browserPattern} for channel "${channel}", got "${info.browser}"`); + } + + if (problems.length) { + console.error(`\nenv-live-probe FAILED on ${channel}:\n ${problems.join('\n ')}`); + console.error(`evidence: ${JSON.stringify(info)}`); + process.exitCode = 1; + } else { + console.log(`\nenv-live-probe ok: ${channel} on ${expectOs} reported OS="${info.os}" Browser="${info.browser}"`); + } +} finally { + await browser.close(); +} From fc7097f5b4ed58b945044e868c89fc263c98e19e Mon Sep 17 00:00:00 2001 From: gololdf1sh Date: Thu, 20 Aug 2026 20:45:53 +0300 Subject: [PATCH 3/3] fix(env): a mobile platform hint the UA does not confirm is the Linux lie (#12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some desktop-Linux Chrome builds answer "Android" in the client-hints platform, and envOs took the hint at its word — a reporter's Ubuntu landed in testrun meta as Android. Extensions run on desktop browsers only, so a mobile hint the UA string does not confirm can never be true: the UA parse wins now, and when it answers nothing the machine is called Linux, after the builds that lie. The sample matrix caught two more detector defects, fixed alongside: the iOS branch of uaOs was unreachable (every iOS UA carries "like Mac OS X" and the Mac check ran first), and the Safari pattern missed mobile Safari, whose UA interleaves Mobile/15E148 between the version and Safari. The matrix is 14/14 with untouched expectations; the env-check workflow is the same picture on real Chrome and Edge across the three desktop OS. Co-authored-by: Claude Fable 5 --- extension/sidepanel/core/env-info.js | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/extension/sidepanel/core/env-info.js b/extension/sidepanel/core/env-info.js index b29ef44..064dbb8 100644 --- a/extension/sidepanel/core/env-info.js +++ b/extension/sidepanel/core/env-info.js @@ -27,16 +27,18 @@ function uaBrowser(ua) { for (const [re, name] of [ [/\bEdg\/(\d+)/, 'Edge'], [/\bOPR\/(\d+)/, 'Opera'], [/\bChrome\/(\d+)/, 'Chrome'], [/\bFirefox\/(\d+)/, 'Firefox'], - [/Version\/(\d+)[\d.]*\s+Safari/, 'Safari'], + // Mobile Safari interleaves `Mobile/15E148` between the version and `Safari/`. + [/Version\/(\d+)[\d.]*.*\bSafari\//, 'Safari'], ]) { const m = ua.match(re); if (m) return `${name} ${m[1]}`; } return 'Unknown'; } function uaOs(ua) { if (/Windows NT/.test(ua)) return 'Windows'; + // iOS before macOS: every iOS UA carries "like Mac OS X". + if (/(iPhone|iPad|iPod)/.test(ua)) return 'iOS'; if (/Mac OS X/.test(ua)) return 'macOS'; if (/Android/.test(ua)) return 'Android'; - if (/(iPhone|iPad|iPod)/.test(ua)) return 'iOS'; if (/CrOS/.test(ua)) return 'Chrome OS'; if (/Linux/.test(ua)) return 'Linux'; return 'Unknown'; @@ -59,8 +61,16 @@ function envBrowser() { } // OS name: UA-CH platform ("macOS"/"Windows"/"Linux"/…), else a UA-string parse. +// A mobile platform hint the UA string does not confirm is the #12 lie — extensions +// run on desktop only — so the UA parse wins; those builds are Linux when it is Unknown. function envOs() { - return navigator.userAgentData?.platform || uaOs(navigator.userAgent); + const hinted = navigator.userAgentData?.platform; + if (!hinted) return uaOs(navigator.userAgent); + if (hinted === 'Android' || hinted === 'iOS') { + const parsed = uaOs(navigator.userAgent); + if (parsed !== hinted) return parsed === 'Unknown' ? 'Linux' : parsed; + } + return hinted; } // Active tab URL via resolveSiteTab. '' for anything but a readable http(s) tab,