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/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; } 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, 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(); +}