Enhance CI configuration and documentation; add new action palette adapter
Some checks failed
CI / validate (push) Has been cancelled

This commit is contained in:
isopropilick
2026-09-15 10:32:11 -06:00
parent 896c3a121a
commit 29899223e4
12 changed files with 296 additions and 11 deletions

View File

@@ -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();

View 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(); } }
});
}

View 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;
}

View 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'));
});

View 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);
}
});