Enhance CI configuration and documentation; add new action palette adapter
Some checks failed
CI / validate (push) Has been cancelled
Some checks failed
CI / validate (push) Has been cancelled
This commit is contained in:
@@ -6,7 +6,7 @@ Given('the user is on the login page', async () => {
|
||||
await browser.url('/login');
|
||||
});
|
||||
|
||||
When(/^the user logs in with (\\w+) and (.+)$/, async (username: string, password: string) => {
|
||||
When(/^the user logs in with (\w+) and (.+)$/, async (username: string, password: string) => {
|
||||
await (await getElement('loginPage.inputUsername')).setValue(username);
|
||||
await (await getElement('loginPage.inputPassword')).setValue(password);
|
||||
await (await getElement('loginPage.submitButton')).click();
|
||||
|
||||
45
test/support/qkforce-actions.mjs
Normal file
45
test/support/qkforce-actions.mjs
Normal file
@@ -0,0 +1,45 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
/** Adapter for the action palette emitted by QKForce. One instance per WDIO worker. */
|
||||
export function createQkfActions(browser, root = process.cwd()) {
|
||||
let tail = Promise.resolve();
|
||||
const enqueue = action => {
|
||||
const result = tail.then(action);
|
||||
tail = result;
|
||||
// Legacy saved snippets did not await calls. flush() surfaces failures at the step boundary.
|
||||
void result.catch(() => {});
|
||||
return result;
|
||||
};
|
||||
const element = selector => {
|
||||
if (typeof selector !== 'string' || !selector.trim()) throw new Error('QKForce action requires a selector string');
|
||||
return browser.$(selector);
|
||||
};
|
||||
const call = (selector, method, ...args) => enqueue(async () => (await element(selector))[method](...args));
|
||||
return Object.freeze({
|
||||
click: selector => call(selector, 'click'),
|
||||
doubleClick: selector => call(selector, 'doubleClick'),
|
||||
rightClick: selector => call(selector, 'click', { button: 'right' }),
|
||||
enterValue: (selector, value) => call(selector, 'setValue', value),
|
||||
addValue: (selector, value) => call(selector, 'addValue', value),
|
||||
clearValue: selector => call(selector, 'clearValue'),
|
||||
isVisible: selector => call(selector, 'isDisplayed'),
|
||||
exists: selector => call(selector, 'isExisting'),
|
||||
isEnabled: selector => call(selector, 'isEnabled'),
|
||||
isClickable: selector => call(selector, 'isClickable'),
|
||||
isSelected: selector => call(selector, 'isSelected'),
|
||||
waitForVisible: selector => call(selector, 'waitForDisplayed'),
|
||||
waitForExistence: selector => call(selector, 'waitForExist'),
|
||||
waitForClickable: selector => call(selector, 'waitForClickable'),
|
||||
waitForEnabled: selector => call(selector, 'waitForEnabled'),
|
||||
goto: url => enqueue(() => browser.url(url)),
|
||||
takeScreenshot: name => enqueue(async () => {
|
||||
const directory = path.resolve(root, 'qreport-results', 'screenshots');
|
||||
await fs.mkdir(directory, { recursive: true });
|
||||
const label = String(name ?? 'screenshot').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 80);
|
||||
return browser.saveScreenshot(path.join(directory, `${label}-${randomUUID()}.png`));
|
||||
}),
|
||||
async flush() { try { await tail; } finally { tail = Promise.resolve(); } }
|
||||
});
|
||||
}
|
||||
51
test/support/qkforce-generated.mjs
Normal file
51
test/support/qkforce-generated.mjs
Normal file
@@ -0,0 +1,51 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export const GENERATED_ROOT = 'test/qkforce';
|
||||
export const GENERATED_MANIFEST = `${GENERATED_ROOT}/manifest.json`;
|
||||
const fail = message => { throw new Error(`Invalid QKForce generated workspace: ${message}`); };
|
||||
|
||||
function localFile(root, relative) {
|
||||
if (typeof relative !== 'string' || !relative.startsWith(`${GENERATED_ROOT}/`)
|
||||
|| relative.includes('\\') || relative.split('/').some(part => !part || part === '.' || part === '..')) {
|
||||
fail('unsafe generated file path');
|
||||
}
|
||||
let current = path.resolve(root);
|
||||
for (const segment of relative.split('/')) {
|
||||
current = path.join(current, segment);
|
||||
let stat;
|
||||
try { stat = fs.lstatSync(current); } catch { fail('declared generated file is missing'); }
|
||||
if (stat.isSymbolicLink()) fail('generated paths must not contain symlinks');
|
||||
}
|
||||
if (!fs.statSync(current).isFile()) fail('declared generated input must be a file');
|
||||
return current;
|
||||
}
|
||||
|
||||
export function loadGeneratedWorkspace(root = process.cwd()) {
|
||||
const manifestPath = path.join(root, GENERATED_MANIFEST);
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
// A local template checkout may run the example. An API package never may.
|
||||
if (fs.existsSync(path.join(root, 'qkforce-package-manifest.json'))) fail('regenerate the project before execution');
|
||||
return null;
|
||||
}
|
||||
let manifest;
|
||||
try { manifest = JSON.parse(fs.readFileSync(localFile(root, GENERATED_MANIFEST), 'utf8')); }
|
||||
catch { fail('generated manifest is not readable JSON'); }
|
||||
if (manifest?.schemaVersion !== 1 || !Array.isArray(manifest.features)
|
||||
|| manifest.features.length === 0) fail('select at least one project test case');
|
||||
if (new Set(manifest.features).size !== manifest.features.length) fail('duplicate feature paths');
|
||||
for (const file of manifest.features) {
|
||||
if (typeof file !== 'string' || !file.startsWith(`${GENERATED_ROOT}/features/`) || !file.endsWith('.feature')) fail('invalid feature path');
|
||||
localFile(root, file);
|
||||
}
|
||||
if (manifest.steps !== `${GENERATED_ROOT}/steps.mjs` || manifest.selectors !== `${GENERATED_ROOT}/selectors.json`) fail('unsupported generated module paths');
|
||||
localFile(root, manifest.steps);
|
||||
localFile(root, manifest.selectors);
|
||||
return Object.freeze({ specs: manifest.features.map(file => `./${file}`), imports: [`./${manifest.steps}`] });
|
||||
}
|
||||
|
||||
export function loadGeneratedSelectors(root = process.cwd()) {
|
||||
const value = JSON.parse(fs.readFileSync(localFile(root, `${GENERATED_ROOT}/selectors.json`), 'utf8'));
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) fail('selectors must be an object');
|
||||
return value;
|
||||
}
|
||||
71
test/unit/qkforce-generated.test.mjs
Normal file
71
test/unit/qkforce-generated.test.mjs
Normal file
@@ -0,0 +1,71 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { loadGeneratedWorkspace, loadGeneratedSelectors } from '../support/qkforce-generated.mjs';
|
||||
import { createQkfActions } from '../support/qkforce-actions.mjs';
|
||||
|
||||
function fixture(t, features=['test/qkforce/features/project.feature']) {
|
||||
const root=fs.mkdtempSync(path.join(os.tmpdir(),'qkf-generated-'));
|
||||
t.after(()=>fs.rmSync(root,{recursive:true,force:true}));
|
||||
fs.mkdirSync(path.join(root,'test/qkforce/features'),{recursive:true});
|
||||
fs.writeFileSync(path.join(root,'test/qkforce/features/project.feature'),'Feature: project');
|
||||
fs.writeFileSync(path.join(root,'test/qkforce/steps.mjs'),'');
|
||||
fs.writeFileSync(path.join(root,'test/qkforce/selectors.json'),'{}');
|
||||
const manifest={schemaVersion:1,features,steps:'test/qkforce/steps.mjs',selectors:'test/qkforce/selectors.json'};
|
||||
fs.writeFileSync(path.join(root,'test/qkforce/manifest.json'),JSON.stringify(manifest));
|
||||
return root;
|
||||
}
|
||||
|
||||
test('runs exactly generated cases and never template or stale feature globs', t=>{
|
||||
const root=fixture(t);
|
||||
fs.writeFileSync(path.join(root,'test/qkforce/features/stale.feature'),'Feature: stale');
|
||||
assert.deepEqual(loadGeneratedWorkspace(root),{specs:['./test/qkforce/features/project.feature'],imports:['./test/qkforce/steps.mjs']});
|
||||
assert.deepEqual(loadGeneratedSelectors(root),{});
|
||||
});
|
||||
test('an empty generated selection fails instead of running the template sample',t=>{
|
||||
assert.throws(()=>loadGeneratedWorkspace(fixture(t,[])),/at least one/);
|
||||
});
|
||||
test('an API package without generated selection cannot fall back to samples',t=>{
|
||||
const root=fixture(t);fs.unlinkSync(path.join(root,'test/qkforce/manifest.json'));
|
||||
assert.equal(loadGeneratedWorkspace(root),null);
|
||||
fs.writeFileSync(path.join(root,'qkforce-package-manifest.json'),'{}');
|
||||
assert.throws(()=>loadGeneratedWorkspace(root),/regenerate/);
|
||||
});
|
||||
test('unsafe, missing, duplicate and symlinked feature inputs are rejected',t=>{
|
||||
for(const features of [['test/qkforce/features/../../secret.feature'],['test/qkforce/features/missing.feature'],['test/qkforce/features/project.feature','test/qkforce/features/project.feature']]) {
|
||||
assert.throws(()=>loadGeneratedWorkspace(fixture(t,features)));
|
||||
}
|
||||
const root=fixture(t);
|
||||
const feature=path.join(root,'test/qkforce/features/project.feature');
|
||||
fs.unlinkSync(feature);fs.symlinkSync(path.join(root,'test/qkforce/steps.mjs'),feature);
|
||||
assert.throws(()=>loadGeneratedWorkspace(root),/symlinks/);
|
||||
});
|
||||
test('legacy unawaited actions execute sequentially and finish before flush returns',async()=>{
|
||||
const events=[];
|
||||
const browser={url:async url=>{await Promise.resolve();events.push(url);},$:async selector=>({click:async()=>events.push(selector)})};
|
||||
const qkf=createQkfActions(browser);
|
||||
qkf.goto('/login');qkf.click('#submit');
|
||||
await qkf.flush();assert.deepEqual(events,['/login','#submit']);
|
||||
});
|
||||
test('queued action failures fail the step and reset for the next scenario',async()=>{
|
||||
let fail=true;
|
||||
const qkf=createQkfActions({url:async()=>{if(fail)throw new Error('navigation failed');}});
|
||||
qkf.goto('/login');await assert.rejects(qkf.flush(),/navigation failed/);
|
||||
fail=false;qkf.goto('/ok');await qkf.flush();
|
||||
});
|
||||
test('all palette element actions map to real WDIO methods',async()=>{
|
||||
const calls=[];
|
||||
const element=new Proxy({}, {get:(_,method)=>method === 'then' ? undefined : (...args)=>{calls.push([method,...args]);return true;}});
|
||||
const qkf=createQkfActions({$:()=>element});
|
||||
for(const method of ['click','doubleClick','rightClick','enterValue','addValue','clearValue','isVisible','exists','isEnabled','isClickable','isSelected','waitForVisible','waitForExistence','waitForClickable','waitForEnabled']) qkf[method]('#x','value');
|
||||
await qkf.flush();
|
||||
assert.equal(calls.length,15);assert.deepEqual(calls[2],['click',{button:'right'}]);assert.deepEqual(calls[3],['setValue','value']);
|
||||
});
|
||||
test('screenshots stay inside the results directory',async t=>{
|
||||
const root=fixture(t);let saved;
|
||||
const qkf=createQkfActions({saveScreenshot:async file=>{saved=file;}},root);
|
||||
qkf.takeScreenshot('../../outside');await qkf.flush();
|
||||
assert.equal(path.dirname(saved),path.join(root,'qreport-results/screenshots'));
|
||||
});
|
||||
24
test/unit/run-profile.test.mjs
Normal file
24
test/unit/run-profile.test.mjs
Normal file
@@ -0,0 +1,24 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { executeAndReport } from '../../scripts/run-profile.mjs';
|
||||
|
||||
test('builds the report after both passing and failing runs, preserving runner status', async () => {
|
||||
for (const code of [0, 1, 7]) {
|
||||
const events = [];
|
||||
const result = await executeAndReport(async () => { events.push('runner'); return code; }, async () => { events.push('report'); });
|
||||
assert.equal(result, code);
|
||||
assert.deepEqual(events, ['runner', 'report']);
|
||||
}
|
||||
});
|
||||
|
||||
test('attempts reporting after runner startup failure', async () => {
|
||||
let reported = false;
|
||||
assert.equal(await executeAndReport(async () => { throw new Error('spawn failed'); }, () => { reported = true; }), 1);
|
||||
assert.ok(reported);
|
||||
});
|
||||
|
||||
test('a report failure cannot convert a failed runner to success or hide its exit code', async () => {
|
||||
for (const code of [0, 7]) {
|
||||
assert.equal(await executeAndReport(async () => code, () => { throw new Error('unwritable report'); }), code || 1);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user