Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
d27f9d7
fix(browser): use localhost for medic websocket host
GitToTheHub May 17, 2026
34f15da
fix(browser): allow localhost medic endpoints
GitToTheHub May 17, 2026
92b2c97
fix(browser): support explicit target and log run command
GitToTheHub May 17, 2026
5c0889d
fix(browser): allow medic websocket in test page CSP
GitToTheHub May 17, 2026
bd99c81
fix(browser): try multiple medic.json paths
GitToTheHub May 17, 2026
eae8fed
fix(browser): pass medic logurl via start-page query
GitToTheHub May 17, 2026
a81cd1c
fix(browser): avoid localhost ipv6 websocket mismatch
GitToTheHub May 17, 2026
6dba899
chore(debug): stream spawn output in verbose mode
GitToTheHub May 17, 2026
509c2eb
fix(browser): complete tests while browser server is running
GitToTheHub May 17, 2026
73f2273
fix(ci-browser): launch fallback headless chrome
GitToTheHub May 17, 2026
e910ef8
fix(browser): remove query startPage to avoid 404
GitToTheHub May 17, 2026
b2adbe3
fix(ci-browser): wait for server url before fallback launch
GitToTheHub May 17, 2026
ede6a8e
fix(ci-browser): bind medic server to 127.0.0.1
GitToTheHub May 17, 2026
2a28e85
fix(ci-browser): launch fallback only on start page
GitToTheHub May 18, 2026
13b0b0e
fix(browser): keep index start page for browser runs
GitToTheHub May 18, 2026
0648889
fix(browser): ensure cdvtests/ dir exists for browser platform tests
GitToTheHub May 18, 2026
b74ba18
fix(browser): use GitToTheHub test-framework fork
GitToTheHub May 18, 2026
2083988
fix(android): do not fail run when device log collection fails
GitToTheHub May 18, 2026
4f97f34
test(ci): use apache test-framework PR #69 in harness
GitToTheHub May 18, 2026
276aa1c
fix(lint): align fallback browser helper with eslint rules
GitToTheHub May 18, 2026
9608e48
fix(security): avoid TOCTOU check in browser CSP update
GitToTheHub May 18, 2026
ffb5c17
chore: use master of `cordova-plugin-test-framework`
GitToTheHub Jun 6, 2026
419a62c
iOS 26.x test
GitToTheHub Jun 6, 2026
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
2 changes: 1 addition & 1 deletion .github/workflows/ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ jobs:

- os-version: macos-26
ios-version: 26.x
xcode-version: 26.1
xcode-version: 26.x

steps:
- uses: actions/checkout@v6
Expand Down
1 change: 1 addition & 0 deletions conf/pr/local/browser.config.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"platform": "browser@latest",
"action": "run",
"target": "chrome",
"cleanUpAfterRun": true,
"verbose": true
}
6 changes: 5 additions & 1 deletion lib/LocalServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,10 @@ class LocalServer extends EventEmitter {
if (platform === utilities.ANDROID) {
return '10.0.2.2';
}
// Browser target should prefer IPv4 loopback to avoid localhost -> ::1 resolution mismatches.
if (platform === utilities.BROWSER) {
return '127.0.0.1';
}
// iOS simulator/devices & Android device with reverse, or desktop
return '127.0.0.1';
}
Expand Down Expand Up @@ -240,7 +244,7 @@ function attemptToCreateServer () {
const server = http.createServer();
server.on('error', reject);
server.on('listening', () => resolve(server));
server.listen(0, '0.0.0.0');
server.listen(0, '127.0.0.1');
});
}

Expand Down
100 changes: 100 additions & 0 deletions lib/ParamedicApp.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class ParamedicApp {
this.platformId = this.config.getPlatformId();
this.isAndroid = this.platformId === utilities.ANDROID;
this.isIos = this.platformId === utilities.IOS;
this.isBrowser = this.platformId === utilities.BROWSER;

logger.info('---------------------------------------------------------');
logger.info('1. Create Cordova app with platform and plugin(s) to test');
Expand Down Expand Up @@ -60,7 +61,9 @@ class ParamedicApp {
prepareProjectToRunTests () {
return this.installPlatform()
.then(() => this.installPlugins())
.then(() => this.ensureCdvTestsExists())
.then(() => this.setUpStartPage())
.then(() => this.updateBrowserTestPageCSP())
.then(() => this.checkPlatformRequirements())
.then(() => this.checkDumpAndroidManifest())
.then(() => this.checkDumpAndroidConfigXml());
Expand Down Expand Up @@ -106,10 +109,107 @@ class ParamedicApp {
logger.normal('[paramedic] Setting the app start page to the test page');
const filePath = path.join(this.tempFolder.name, 'config.xml');
let config = fs.readFileSync(filePath, utilities.DEFAULT_ENCODING);

config = config.replace('src="index.html"', 'src="cdvtests/index.html"');
fs.writeFileSync(filePath, config, utilities.DEFAULT_ENCODING);
}

/**
* For browser platform, explicitly copies cordova-plugin-test-framework assets to
* www/cdvtests/ because the Cordova <asset> element may not execute reliably on
* the browser platform in CI environments.
*/
ensureCdvTestsExists () {
if (!this.isBrowser) {
return;
}

const tfPluginAssetsDir = path.join(this.tempFolder.name, 'plugins', 'cordova-plugin-test-framework', 'www', 'assets');
const cdvTestsDir = path.join(this.tempFolder.name, 'www', 'cdvtests');

if (!fs.existsSync(tfPluginAssetsDir)) {
logger.warn('[paramedic] cordova-plugin-test-framework assets not found at: ' + tfPluginAssetsDir);
return;
}

if (fs.existsSync(path.join(cdvTestsDir, 'index.html'))) {
logger.info('[paramedic] www/cdvtests/index.html already exists, skipping copy.');
return;
}

logger.info('[paramedic] Copying cordova-plugin-test-framework assets to www/cdvtests/');
fs.cpSync(tfPluginAssetsDir, cdvTestsDir, { recursive: true });
logger.info('[paramedic] Done copying to www/cdvtests/');
}

/**
* Ensures browser test page CSP allows localhost HTTP/WS endpoints used by Paramedic.
*/
updateBrowserTestPageCSP () {
if (!this.isBrowser) {
return;
}

const testPagePath = path.join(this.tempFolder.name, 'www', 'cdvtests', 'index.html');
const addrs = [
'http://localhost:*',
'http://127.0.0.1:*',
'ws://localhost:*',
'ws://127.0.0.1:*'
];

let html;
try {
html = fs.readFileSync(testPagePath, utilities.DEFAULT_ENCODING);
} catch (err) {
if (err && err.code === 'ENOENT') {
return;
}
throw err;
}

const cspMetaRegex = /<meta[^>]+http-equiv=["']Content-Security-Policy["'][^>]*>/i;
const cspContentRegex = /content=["']([^"']*)["']/i;

if (cspMetaRegex.test(html)) {
html = html.replace(cspMetaRegex, (metaTag) => {
const contentMatch = metaTag.match(cspContentRegex);
if (!contentMatch) {
return metaTag;
}

let cspContent = contentMatch[1];
const connectSrcRegex = /connect-src\s+([^;]+)/i;

if (connectSrcRegex.test(cspContent)) {
cspContent = cspContent.replace(connectSrcRegex, (full, sources) => {
const sourceSet = new Set(sources.trim().split(/\s+/).filter(Boolean));
addrs.forEach((addr) => sourceSet.add(addr));
return `connect-src ${Array.from(sourceSet).join(' ')}`;
});
} else {
cspContent = `${cspContent.trim().replace(/;?$/, ';')} connect-src 'self' ${addrs.join(' ')};`;
}

return metaTag.replace(cspContentRegex, `content="${cspContent}"`);
});
} else {
const cspTag = `<meta http-equiv="Content-Security-Policy" content="default-src 'self' data: gap: https://ssl.gstatic.com 'unsafe-eval'; style-src 'self' 'unsafe-inline'; media-src *; connect-src 'self' ${addrs.join(' ')};">`;
if (/<head[^>]*>/i.test(html)) {
html = html.replace(/<head[^>]*>/i, (headTag) => `${headTag}\n ${cspTag}`);
}
}

try {
fs.writeFileSync(testPagePath, html, utilities.DEFAULT_ENCODING);
} catch (err) {
if (err && err.code === 'ENOENT') {
return;
}
throw err;
}
}

/**
* Installs the Cordova platform for testing
*
Expand Down
179 changes: 176 additions & 3 deletions lib/paramedic.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
const Server = require('./LocalServer');
const path = require('path');
const fs = require('fs');
const { spawn } = require('node:child_process');
const { setTimeout: timelimit } = require('node:timers/promises');

const { logger, utilities, spawnAsync } = require('./utils');
Expand Down Expand Up @@ -272,16 +273,177 @@ class ParamedicRunner {
});
});

if (this.isBrowser) {
return await this.waitForBrowserTests(cmdArgs, testResults);
}

// This spawns the Cordova run command. It will build, run, and trigger the automatic tests.
await spawnAsync(
this.config.getCli(),
cmdArgs,
{ cwd: this.tempFolder.name }
{ cwd: this.tempFolder.name, verbose: this.config.isVerbose() }
);

return testResults;
}

async waitForBrowserTests (cmdArgs, testResults) {
let fallbackBrowserProc = null;
let fallbackBrowserStarted = false;

const launchFallbackBrowser = (url) => {
if (fallbackBrowserStarted || process.env.CI !== 'true') {
return;
}

// Only needed for browser+chrome runs in CI where auto-open may be skipped.
if (this.config.getTarget() !== 'chrome') {
return;
}

fallbackBrowserStarted = true;

const waitForReachableUrl = async (candidateUrl) => {
const maxAttempts = 60;
const retryDelayMs = 500;

const canFetch = typeof fetch === 'function';
if (!canFetch) {
return candidateUrl;
}

for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
const response = await fetch(candidateUrl, { redirect: 'follow' });
if (response.ok) {
return candidateUrl;
}
} catch {
// Continue retrying until timeout.
}

await timelimit(retryDelayMs);
}

logger.warn(`[paramedic] Start page was not reachable in time, launching anyway: ${candidateUrl}`);
return candidateUrl;
};

const chromeCmdCandidates = [
process.env.CHROME_BIN,
'google-chrome',
'google-chrome-stable',
'chromium-browser',
'chromium'
].filter(Boolean);

const tryLaunch = async () => {
const launchUrl = await waitForReachableUrl(url);

const launchArgs = [
'--no-sandbox',
'--disable-gpu',
'--headless=new',
'--disable-dev-shm-usage',
'--autoplay-policy=no-user-gesture-required',
launchUrl
];

const tryBinary = (idx) => {
if (idx >= chromeCmdCandidates.length) {
logger.warn('[paramedic] Unable to launch fallback Chrome (no known chrome binary found).');
return;
}

const cmd = chromeCmdCandidates[idx];
logger.warn(`[paramedic] Launching fallback browser: ${cmd}`);

const proc = spawn(cmd, launchArgs, { stdio: 'pipe' });
let launched = false;

proc.on('spawn', () => {
launched = true;
fallbackBrowserProc = proc;
logger.info('[paramedic] Fallback browser launched.');
});

proc.on('error', (err) => {
if (!launched && err && err.code === 'ENOENT') {
tryBinary(idx + 1);
return;
}
logger.warn(`[paramedic] Fallback browser error: ${err}`);
});

if (proc.stderr) {
proc.stderr.on('data', (data) => {
const text = data.toString();
if (this.config.isVerbose() && text.trim()) {
logger.warn(text.trimEnd());
}
});
}
};

tryBinary(0);
};

tryLaunch().catch((err) => {
logger.warn(`[paramedic] Fallback browser launch failed: ${err}`);
});
};

const browserRun = spawn(this.config.getCli(), cmdArgs, {
cwd: this.tempFolder.name,
stdio: 'pipe'
});

if (browserRun.stdout) {
browserRun.stdout.on('data', (data) => {
const text = data.toString();
if (this.config.isVerbose() && text.trim()) {
logger.info(text.trimEnd());
}

const match = text.match(/Static file server running\s*@\s*(http:\/\/\S+)/i);
if (match && match[1]) {
launchFallbackBrowser(match[1]);
}
});
}

if (browserRun.stderr) {
browserRun.stderr.on('data', (data) => {
const text = data.toString();
if (this.config.isVerbose() && text.trim()) {
logger.warn(text.trimEnd());
}
});
}

const browserExit = new Promise((resolve, reject) => {
browserRun.on('error', reject);
browserRun.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`[paramedic] Browser run exited with code ${code} before tests completed`));
}
});
});

try {
return await Promise.race([testResults, browserExit]);
} finally {
if (!browserRun.killed) {
browserRun.kill('SIGINT');
}
if (fallbackBrowserProc && !fallbackBrowserProc.killed) {
fallbackBrowserProc.kill('SIGINT');
}
}
}

/**
* Creates the run/build command.
*
Expand All @@ -295,10 +457,17 @@ class ParamedicRunner {
...utilities.PARAMEDIC_COMMON_ARGS
];

if (this.isBrowser || this.config.getAction() === 'build') {
if (this.config.getAction() === 'build') {
return args;
}

if (this.isBrowser) {
const target = this.config.getTarget();
return target
? [...args, '--target', target]
: args;
}

const targetChooser = new ParamedicTargetChooser(this.tempFolder.name, this.config);
this.targetObj = await targetChooser.chooseTarget(this.config.getTarget());

Expand Down Expand Up @@ -342,7 +511,11 @@ class ParamedicRunner {
logger.info('[paramedic] Collecting Device Logs');
const outputDir = this.config.getOutputDir() ? this.config.getOutputDir() : this.tempFolder.name;
const paramedicLogCollector = new ParamedicLogCollector(this.config.getPlatformId(), this.tempFolder.name, outputDir, this.targetObj);
await paramedicLogCollector.collectLogs();
try {
await paramedicLogCollector.collectLogs();
} catch (err) {
logger.warn(`[paramedic] Unable to collect device logs, continuing cleanup: ${err.message}`);
}
}

uninstallApp () {
Expand Down
Loading