Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .github/workflows/env-check.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,6 @@
*.zip
*.crx
*.pem
node_modules/
package.json
package-lock.json
18 changes: 13 additions & 5 deletions extension/editor/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 || [];
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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; }
Expand Down
16 changes: 13 additions & 3 deletions extension/sidepanel/core/env-info.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand Down
91 changes: 91 additions & 0 deletions tests/env-info-matrix.mjs
Original file line number Diff line number Diff line change
@@ -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);
68 changes: 68 additions & 0 deletions tests/env-live-probe.mjs
Original file line number Diff line number Diff line change
@@ -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: '<html></html>' }));
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();
}
Loading