ok
Some checks failed
CI / validate (push) Has been cancelled

This commit is contained in:
isopropilick
2026-08-27 15:00:50 -06:00
parent b35387d841
commit 896c3a121a
19 changed files with 569 additions and 0 deletions

View File

@@ -0,0 +1,16 @@
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 { loadQkforceRunConfig, validateQkforceRunConfig } from '../support/qkforce-config.mjs';
const validConfig = () => ({ schemaVersion:1, profile:{ id:'qkforce-wdio-cucumber-qkta', language:'typescript', bdd:'cucumber', runner:'webdriverio', adapter:'webdriverio-cucumber-node', reporter:'qk-test-analytics' }, run:{ targetUrl:'https://example.test/application' } });
test('accepts the central fixed profile and run.targetUrl', () => { const config = validateQkforceRunConfig(validConfig()); assert.equal(config.baseUrl,'https://example.test/application'); assert.equal(config.profile.id,'qkforce-wdio-cucumber-qkta'); });
test('allows HTTP only for exact loopback development targets', () => { for (const targetUrl of ['http://localhost:8080','http://127.0.0.1:8080','http://[::1]:8080']) { const config=validConfig(); config.run.targetUrl=targetUrl; assert.match(validateQkforceRunConfig(config).baseUrl,/^http:/); } });
test('rejects unsafe target URLs without echoing them', () => { for (const unsafeUrl of ['http://example.test','https://user:secret@example.test','https://example.test/#token','https://example.test/?token=secret']) { const config=validConfig(); config.run.targetUrl=unsafeUrl; assert.throws(() => validateQkforceRunConfig(config), error => { assert.match(error.message,/Invalid QKForce run configuration/); assert.doesNotMatch(error.message,/secret|token/); return true; }); } });
test('enforces the 2048 UTF-8 byte target URL limit', () => { const config=validConfig(); config.run.targetUrl=`https://example.test/${'é'.repeat(1015)}`; assert.throws(() => validateQkforceRunConfig(config),/2048-byte maximum/); });
test('rejects a profile other than the central fixed profile', () => { const config=validConfig(); config.profile.adapter='playwright-cucumber-node'; assert.throws(() => validateQkforceRunConfig(config),/profile.adapter/); });
test('rejects the previous repository-local target/template shape', () => { const config=validConfig(); config.template={id:'qkforce-framework-cwq',version:'0.2.0'}; assert.throws(() => validateQkforceRunConfig(config),/config.template/); });
test('fails clearly when the workspace configuration is missing or malformed', () => { const directory=fs.mkdtempSync(path.join(os.tmpdir(),'qkforce-config-')); try { assert.throws(() => loadQkforceRunConfig(directory),/qkforce.config.json is missing/); fs.writeFileSync(path.join(directory,'qkforce.config.json'),'{'); assert.throws(() => loadQkforceRunConfig(directory),/not valid JSON/); } finally { fs.rmSync(directory,{recursive:true,force:true}); } });

View File

@@ -0,0 +1,23 @@
import assert from 'node:assert/strict';
import crypto from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import test from 'node:test';
import { CONTENT_TYPE, loadQkforceTestData, parseRfc4180Csv, validateQkforceTestDataLock } from '../support/qkforce-test-data.mjs';
const digest = value => crypto.createHash('sha256').update(value).digest('hex');
function workspace(csv, overrides={}) { const directory=fs.mkdtempSync(path.join(os.tmpdir(),'qkforce-test-data-')); fs.mkdirSync(path.join(directory,'test-data')); fs.writeFileSync(path.join(directory,'test-data','customers.csv'),csv); const lock={schemaVersion:1,datasets:[{datasetId:'tds_customers',versionId:'tdv_customers_1',versionNumber:1,logicalName:'customers',path:'test-data/customers.csv',sha256:digest(Buffer.from(csv)),rowCount:2,contentType:CONTENT_TYPE,...overrides}]}; fs.writeFileSync(path.join(directory,'qkforce.test-data.lock.json'),JSON.stringify(lock)); return {directory,lock}; }
const cleanup = directory => fs.rmSync(directory,{recursive:true,force:true});
const fixtureRoot=fileURLToPath(new URL('../fixtures/test-data/positive/',import.meta.url));
const negativeFixture=fileURLToPath(new URL('../fixtures/test-data/negative/unsafe-path.lock.json',import.meta.url));
test('starter positive and negative lock fixtures exercise the provider contract',()=>{ const provider=loadQkforceTestData(fixtureRoot,{required:true}); assert.equal(provider.dataset('customers').first({id:'1'}).name,'María, QA'); const unsafe=JSON.parse(fs.readFileSync(negativeFixture,'utf8')); assert.throws(()=>validateQkforceTestDataLock(unsafe),/safe package-local CSV path/); });
test('parses RFC-4180 quoted/multiline values, Unicode and empty cells deterministically',()=>{ const csv='id,name,note,empty\r\n1,"María, QA","line one\r\nline two",\r\n2,李,"quote ""inside""",\r\n'; const parsed=parseRfc4180Csv(csv,'customers'); assert.deepEqual(parsed.headers,['id','name','note','empty']); assert.equal(parsed.rows[0].note,'line one\r\nline two'); assert.equal(parsed.rows[1].note,'quote "inside"'); });
test('loads declared CSVs, verifies checksums and exposes result-safe provenance',()=>{ const csv='id,name,active,age\n1,María,true,30\n2,李,false,\n'; const {directory}=workspace(csv); try { const provider=loadQkforceTestData(directory,{required:true}); assert.deepEqual(provider.dataset('customers').row(1),{id:'2',name:'李',active:'false',age:''}); const provenance=provider.provenance(); assert.match(provenance.testDataLockSha256,/^[a-f0-9]{64}$/); assert.equal(JSON.stringify(provenance).includes('María'),false); } finally { cleanup(directory); } });
test('converts typed columns and explicit nullable empties',()=>{ const csv='id,active,age,score,birthday,note\n1,true,30,9.5,2026-08-26,\n2,false,,10,2024-02-29,plain\n'; const {directory}=workspace(csv); try { const dataset=loadQkforceTestData(directory).dataset('customers'); assert.deepEqual(dataset.typedRow(0,{id:'integer',active:'boolean',age:'integer',score:'number',birthday:'date',note:'string'}),{id:1,active:true,age:30,score:9.5,birthday:'2026-08-26',note:''}); assert.equal(dataset.typedRow(1,{age:{type:'integer',nullable:true}}).age,null); } finally { cleanup(directory); } });
test('type failures identify dataset/version and column without echoing sensitive values',()=>{ const csv='id,age\n1,SECRET-NOT-A-NUMBER\n2,30\n'; const {directory}=workspace(csv); try { const dataset=loadQkforceTestData(directory).dataset('customers'); assert.throws(()=>dataset.typedRow(0,{age:'integer'}),error=>{ assert.match(error.message,/datasetId=tds_customers, versionId=tdv_customers_1/); assert.match(error.message,/column "age"/); assert.doesNotMatch(error.message,/SECRET-NOT-A-NUMBER/); return true; }); } finally { cleanup(directory); } });
test('rejects path traversal, external paths and unsupported lock versions',()=>{ const valid={schemaVersion:1,datasets:[{datasetId:'tds_customers',versionId:'tdv_customers_1',versionNumber:1,logicalName:'customers',path:'test-data/customers.csv',sha256:'a'.repeat(64),rowCount:0,contentType:CONTENT_TYPE}]}; for (const unsafePath of ['../customers.csv','/tmp/customers.csv','test-data/../customers.csv','test-data/nested/customers.csv','test-data\\customers.csv']) { const candidate=structuredClone(valid); candidate.datasets[0].path=unsafePath; assert.throws(()=>validateQkforceTestDataLock(candidate),/safe package-local CSV path/); } const unsupported=structuredClone(valid); unsupported.schemaVersion=2; assert.throws(()=>validateQkforceTestDataLock(unsupported),/schemaVersion must be 1/); });
test('rejects duplicate headers, checksum mismatch, row-count mismatch and missing datasets',()=>{ assert.throws(()=>parseRfc4180Csv('id,id\n1,2\n','customers'),/duplicate header/); const csv='id,name\n1,A\n2,B\n'; const checksum=workspace(csv,{sha256:'0'.repeat(64)}); try { assert.throws(()=>loadQkforceTestData(checksum.directory),/checksum mismatch/); } finally { cleanup(checksum.directory); } const rowCount=workspace(csv,{rowCount:99}); try { assert.throws(()=>loadQkforceTestData(rowCount.directory),/row count mismatch/); } finally { cleanup(rowCount.directory); } const missing=workspace(csv); try { fs.rmSync(path.join(missing.directory,'test-data','customers.csv')); assert.throws(()=>loadQkforceTestData(missing.directory),/declared CSV file is missing/); } finally { cleanup(missing.directory); } });
test('no-data workspaces are valid and concurrent scenarios receive immutable rows',async()=>{ const emptyDirectory=fs.mkdtempSync(path.join(os.tmpdir(),'qkforce-no-data-')); try { const empty=loadQkforceTestData(emptyDirectory); assert.equal(empty.size,0); assert.deepEqual(empty.provenance(),{}); } finally { cleanup(emptyDirectory); } const csv='id,name\n1,A\n2,B\n'; const {directory}=workspace(csv); try { const provider=loadQkforceTestData(directory); const results=await Promise.all(Array.from({length:32},async(_,index)=>provider.dataset('customers').row(index%2))); assert.equal(results.filter(row=>row.id==='1').length,16); assert.equal(Object.isFrozen(results[0]),true); assert.throws(()=>{results[0].id='changed';},TypeError); } finally { cleanup(directory); } });

View File

@@ -0,0 +1,43 @@
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 { createWdioCucumberAdapter } from '@qacg/qk-test-analytics/adapters/wdio-cucumber';
test('the official WDIO/Cucumber adapter writes portable report data and evidence', async () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'qkforce-qkta-'));
try {
const reportsFile = path.join(directory, 'media-bucket', 'reports', 'current.json');
const adapter = createWdioCucumberAdapter({
capture: 'always',
filePath: reportsFile,
evidenceRoot: path.join(directory, 'media-bucket'),
runId: 'run-contract',
projectName: 'qkforce-framework-cwq'
});
const scenario = { id: 'scenario-1', name: 'records analytics', tags: [{ name: '@smoke' }] };
const step = { id: 'step-1', keyword: 'Given ', text: 'a configured target' };
await adapter.hooks.before({ browserName: 'chrome' }, ['test/features/example.feature'], {
capabilities: { browserName: 'chrome' },
takeScreenshot: async () => Buffer.from('qkforce').toString('base64')
});
await adapter.hooks.beforeFeature('test/features/example.feature', { name: 'Analytics' });
await adapter.hooks.beforeScenario({ pickle: scenario });
await adapter.hooks.beforeStep(step, scenario);
await adapter.hooks.afterStep(step, scenario, { passed: true, duration: 12 });
await adapter.hooks.afterScenario({ pickle: scenario }, { passed: true, duration: 25 });
await adapter.hooks.afterFeature('test/features/example.feature', { name: 'Analytics' });
await adapter.hooks.after(0);
const report = JSON.parse(fs.readFileSync(reportsFile, 'utf8'));
const execution = report['run-contract'];
assert.equal(execution.execution_summary.project_name, 'qkforce-framework-cwq');
assert.equal(execution.Analytics['records analytics'].test_summary.status, 'PASSED');
assert.equal(execution.Analytics['records analytics']['a configured target'].status, 'PASSED');
assert.ok(fs.readdirSync(path.join(directory, 'media-bucket', 'screenshots')).some(name => name.endsWith('.png')));
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
});