22
test/features/login.feature
Normal file
22
test/features/login.feature
Normal file
@@ -0,0 +1,22 @@
|
||||
Feature: The Internet Guinea Pig Website
|
||||
|
||||
Background:
|
||||
Given the user is on the login page
|
||||
|
||||
@smoke
|
||||
Scenario Outline: Successful login - <ID>
|
||||
When the user logs in with <username> and <password>
|
||||
Then the user should see a flash message containing "<message>"
|
||||
And the login form should no longer be visible
|
||||
|
||||
Examples:
|
||||
| ID | username | password | message |
|
||||
| 01 | tomsmith | SuperSecretPassword! | You logged into a secure area! |
|
||||
|
||||
Scenario Outline: Failed login - <ID>
|
||||
When the user logs in with <username> and <password>
|
||||
Then the user should see a flash message containing "<message>"
|
||||
|
||||
Examples:
|
||||
| ID | username | password | message |
|
||||
| 02 | foobar | barfoo | Your username is invalid! |
|
||||
13
test/fixtures/test-data/negative/unsafe-path.lock.json
vendored
Normal file
13
test/fixtures/test-data/negative/unsafe-path.lock.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"datasets": [{
|
||||
"datasetId": "tds_customers",
|
||||
"versionId": "tdv_customers_1",
|
||||
"versionNumber": 1,
|
||||
"logicalName": "customers",
|
||||
"path": "../customers.csv",
|
||||
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"rowCount": 0,
|
||||
"contentType": "text/csv; charset=utf-8"
|
||||
}]
|
||||
}
|
||||
13
test/fixtures/test-data/positive/qkforce.test-data.lock.json
vendored
Normal file
13
test/fixtures/test-data/positive/qkforce.test-data.lock.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"datasets": [{
|
||||
"datasetId": "tds_customers",
|
||||
"versionId": "tdv_customers_1",
|
||||
"versionNumber": 1,
|
||||
"logicalName": "customers",
|
||||
"path": "test-data/customers.csv",
|
||||
"sha256": "f7148bc12caa28c0ef9f89e4062773b08e74b17c1eeef0a625d597cc0484a73c",
|
||||
"rowCount": 2,
|
||||
"contentType": "text/csv; charset=utf-8"
|
||||
}]
|
||||
}
|
||||
4
test/fixtures/test-data/positive/test-data/customers.csv
vendored
Normal file
4
test/fixtures/test-data/positive/test-data/customers.csv
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
id,name,active,age,note
|
||||
1,"María, QA",true,30,"line one
|
||||
line two"
|
||||
2,李,false,,
|
||||
|
37
test/selectors/actionHelper.ts
Normal file
37
test/selectors/actionHelper.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { $, $$ } from '@wdio/globals';
|
||||
import { dictionary } from './index.js';
|
||||
|
||||
type SelectorNode = string | Record<string, unknown>;
|
||||
|
||||
export function getSelector(route: string): string {
|
||||
const value = route.split('.').reduce<unknown>((current, segment) => {
|
||||
if (!current || typeof current !== 'object') return undefined;
|
||||
return (current as Record<string, unknown>)[segment];
|
||||
}, dictionary);
|
||||
|
||||
if (typeof value !== 'string' || value.trim() === '') {
|
||||
throw new Error(`Selector route "${route}" does not resolve to a selector string.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function getElement(route: string, shouldBeVisible = true): Promise<ReturnType<typeof $>> {
|
||||
const element = $(getSelector(route));
|
||||
if (shouldBeVisible) {
|
||||
await element.waitForExist({ timeout: 60_000 });
|
||||
await element.waitForDisplayed({ timeout: 60_000 });
|
||||
} else {
|
||||
await element.waitForDisplayed({ timeout: 60_000, reverse: true });
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
export async function getElements(route: string): Promise<ReturnType<typeof $$>> {
|
||||
const selector = getSelector(route);
|
||||
const firstElement = $(selector);
|
||||
await firstElement.waitForExist({ timeout: 60_000 });
|
||||
await firstElement.waitForDisplayed({ timeout: 60_000 });
|
||||
return $$(selector);
|
||||
}
|
||||
|
||||
export type { SelectorNode };
|
||||
7
test/selectors/index.ts
Normal file
7
test/selectors/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import loginSelectors from './page-objects/login.js';
|
||||
import secureSelectors from './page-objects/secure.js';
|
||||
|
||||
export const dictionary = {
|
||||
...loginSelectors,
|
||||
...secureSelectors,
|
||||
};
|
||||
9
test/selectors/page-objects/login.ts
Normal file
9
test/selectors/page-objects/login.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
const loginSelectors = {
|
||||
loginPage: {
|
||||
inputUsername: '#username',
|
||||
inputPassword: '#password',
|
||||
submitButton: 'button[type="submit"]',
|
||||
},
|
||||
};
|
||||
|
||||
export default loginSelectors;
|
||||
7
test/selectors/page-objects/secure.ts
Normal file
7
test/selectors/page-objects/secure.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
const secureSelectors = {
|
||||
securePage: {
|
||||
flashAlert: '#flash',
|
||||
},
|
||||
};
|
||||
|
||||
export default secureSelectors;
|
||||
21
test/step-definitions/steps.ts
Normal file
21
test/step-definitions/steps.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Given, Then, When } from '@wdio/cucumber-framework';
|
||||
import { expect } from '@wdio/globals';
|
||||
import { getElement } from '../selectors/actionHelper.js';
|
||||
|
||||
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) => {
|
||||
await (await getElement('loginPage.inputUsername')).setValue(username);
|
||||
await (await getElement('loginPage.inputPassword')).setValue(password);
|
||||
await (await getElement('loginPage.submitButton')).click();
|
||||
});
|
||||
|
||||
Then('the user should see a flash message containing {string}', async (message: string) => {
|
||||
await expect(await getElement('securePage.flashAlert')).toHaveText(expect.stringContaining(message));
|
||||
});
|
||||
|
||||
Then('the login form should no longer be visible', async () => {
|
||||
await getElement('loginPage.inputUsername', false);
|
||||
});
|
||||
53
test/support/qkforce-config.mjs
Normal file
53
test/support/qkforce-config.mjs
Normal file
@@ -0,0 +1,53 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const CONFIG_FILE = 'qkforce.config.json';
|
||||
const MAX_TARGET_URL_BYTES = 2048;
|
||||
const FIXED_PROFILE = Object.freeze({
|
||||
id: 'qkforce-wdio-cucumber-qkta',
|
||||
language: 'typescript',
|
||||
bdd: 'cucumber',
|
||||
runner: 'webdriverio',
|
||||
adapter: 'webdriverio-cucumber-node',
|
||||
reporter: 'qk-test-analytics'
|
||||
});
|
||||
|
||||
const fail = message => { throw new Error(`Invalid QKForce run configuration: ${message}`); };
|
||||
const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
const rejectUnknownKeys = (value, allowed, location) => { for (const key of Object.keys(value)) if (!allowed.includes(key)) fail(`${location}.${key} is not supported`); };
|
||||
|
||||
const validateTargetUrl = value => {
|
||||
if (typeof value !== 'string' || value.length === 0) fail('run.targetUrl is required');
|
||||
if (Buffer.byteLength(value, 'utf8') > MAX_TARGET_URL_BYTES) fail('run.targetUrl exceeds the 2048-byte maximum');
|
||||
let url;
|
||||
try { url = new URL(value); } catch { fail('run.targetUrl must be an absolute URL'); }
|
||||
if (url.username || url.password) fail('run.targetUrl must not contain credentials');
|
||||
if (url.hash) fail('run.targetUrl must not contain a fragment');
|
||||
if (url.search) fail('run.targetUrl must not contain a query string');
|
||||
const loopback = url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]';
|
||||
if (url.protocol !== 'https:' && !(loopback && url.protocol === 'http:')) fail('run.targetUrl must use HTTPS unless it is an exact loopback development URL');
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
export function validateQkforceRunConfig(candidate) {
|
||||
if (!isObject(candidate)) fail('the document must be a JSON object');
|
||||
rejectUnknownKeys(candidate, ['schemaVersion','profile','run'], 'config');
|
||||
if (candidate.schemaVersion !== 1) fail('schemaVersion must be 1');
|
||||
if (!isObject(candidate.profile)) fail('profile must be an object');
|
||||
rejectUnknownKeys(candidate.profile, Object.keys(FIXED_PROFILE), 'profile');
|
||||
for (const [key, expected] of Object.entries(FIXED_PROFILE)) if (candidate.profile[key] !== expected) fail(`profile.${key} must be ${expected}`);
|
||||
if (!isObject(candidate.run)) fail('run must be an object');
|
||||
rejectUnknownKeys(candidate.run, ['targetUrl'], 'run');
|
||||
const targetUrl = validateTargetUrl(candidate.run.targetUrl);
|
||||
return Object.freeze({ schemaVersion: 1, profile: FIXED_PROFILE, run: Object.freeze({ targetUrl }), baseUrl: targetUrl });
|
||||
}
|
||||
|
||||
export function loadQkforceRunConfig(rootDirectory = process.cwd()) {
|
||||
const configPath = path.resolve(rootDirectory, CONFIG_FILE);
|
||||
let parsed;
|
||||
try { parsed = JSON.parse(fs.readFileSync(configPath, 'utf8')); }
|
||||
catch (error) { if (error && error.code === 'ENOENT') fail(`${CONFIG_FILE} is missing`); fail(`${CONFIG_FILE} is not valid JSON`); }
|
||||
return validateQkforceRunConfig(parsed);
|
||||
}
|
||||
|
||||
export { CONFIG_FILE, FIXED_PROFILE, MAX_TARGET_URL_BYTES };
|
||||
162
test/support/qkforce-test-data.mjs
Normal file
162
test/support/qkforce-test-data.mjs
Normal file
@@ -0,0 +1,162 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { TextDecoder } from 'node:util';
|
||||
|
||||
const LOCK_FILE = 'qkforce.test-data.lock.json';
|
||||
const LOCK_SCHEMA_VERSION = 1;
|
||||
const MAX_DATASETS = 128;
|
||||
const CONTENT_TYPE = 'text/csv; charset=utf-8';
|
||||
const DATA_PATH_PATTERN = /^test-data\/[A-Za-z0-9][A-Za-z0-9._-]{0,239}\.csv$/;
|
||||
const ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
|
||||
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
|
||||
const BLOCKED_HEADERS = new Set(['__proto__', 'prototype', 'constructor']);
|
||||
const DATASET_KEYS = ['datasetId','versionId','versionNumber','logicalName','path','sha256','rowCount','contentType'];
|
||||
const fail = message => { throw new Error(`Invalid QKForce test data: ${message}`); };
|
||||
const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
const rejectUnknownKeys = (value, allowed, location) => { for (const key of Object.keys(value)) if (!allowed.includes(key)) fail(`${location}.${key} is not supported`); };
|
||||
const sha256 = value => crypto.createHash('sha256').update(value).digest('hex');
|
||||
const datasetLabel = metadata => `${metadata.logicalName} (datasetId=${metadata.datasetId}, versionId=${metadata.versionId})`;
|
||||
|
||||
export function validateQkforceTestDataLock(candidate) {
|
||||
if (!isObject(candidate)) fail('the lock document must be a JSON object');
|
||||
rejectUnknownKeys(candidate, ['schemaVersion','datasets'], 'lock');
|
||||
if (candidate.schemaVersion !== LOCK_SCHEMA_VERSION) fail(`schemaVersion must be ${LOCK_SCHEMA_VERSION}`);
|
||||
if (!Array.isArray(candidate.datasets)) fail('datasets must be an array');
|
||||
if (candidate.datasets.length > MAX_DATASETS) fail(`datasets must contain at most ${MAX_DATASETS} entries`);
|
||||
const seenDatasetIds = new Set(), seenVersionIds = new Set(), seenLogicalNames = new Set(), seenPaths = new Set();
|
||||
const datasets = candidate.datasets.map((entry, index) => {
|
||||
const location = `datasets[${index}]`;
|
||||
if (!isObject(entry)) fail(`${location} must be an object`);
|
||||
rejectUnknownKeys(entry, DATASET_KEYS, location);
|
||||
for (const key of DATASET_KEYS) if (!(key in entry)) fail(`${location}.${key} is required`);
|
||||
if (typeof entry.datasetId !== 'string' || !ID_PATTERN.test(entry.datasetId)) fail(`${location}.datasetId is invalid`);
|
||||
if (typeof entry.versionId !== 'string' || !ID_PATTERN.test(entry.versionId)) fail(`${location}.versionId is invalid`);
|
||||
if (!Number.isInteger(entry.versionNumber) || entry.versionNumber < 1) fail(`${location}.versionNumber must be an integer greater than zero`);
|
||||
if (typeof entry.logicalName !== 'string' || entry.logicalName.length < 1 || entry.logicalName.length > 128) fail(`${location}.logicalName is invalid`);
|
||||
if (typeof entry.path !== 'string' || !DATA_PATH_PATTERN.test(entry.path)) fail(`${location}.path must be a safe package-local CSV path below test-data/`);
|
||||
if (typeof entry.sha256 !== 'string' || !SHA256_PATTERN.test(entry.sha256)) fail(`${location}.sha256 must be a lowercase SHA-256 digest`);
|
||||
if (!Number.isInteger(entry.rowCount) || entry.rowCount < 0) fail(`${location}.rowCount must be a non-negative integer`);
|
||||
if (entry.contentType !== CONTENT_TYPE) fail(`${location}.contentType must be ${CONTENT_TYPE}`);
|
||||
if (seenDatasetIds.has(entry.datasetId)) fail(`${location}.datasetId is duplicated`);
|
||||
if (seenVersionIds.has(entry.versionId)) fail(`${location}.versionId is duplicated`);
|
||||
if (seenLogicalNames.has(entry.logicalName)) fail(`${location}.logicalName is duplicated`);
|
||||
if (seenPaths.has(entry.path)) fail(`${location}.path is duplicated`);
|
||||
seenDatasetIds.add(entry.datasetId); seenVersionIds.add(entry.versionId); seenLogicalNames.add(entry.logicalName); seenPaths.add(entry.path);
|
||||
return Object.freeze({ ...entry });
|
||||
});
|
||||
return Object.freeze({ schemaVersion: LOCK_SCHEMA_VERSION, datasets: Object.freeze(datasets) });
|
||||
}
|
||||
|
||||
export function parseRfc4180Csv(text, identity = 'dataset') {
|
||||
if (typeof text !== 'string') fail(`${identity}: CSV content must be UTF-8 text`);
|
||||
if (text.charCodeAt(0) === 0xFEFF) text = text.slice(1);
|
||||
const parsed = []; let row = []; let field = ''; let inQuotes = false; let afterQuote = false; let atFieldStart = true;
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const char = text[index];
|
||||
if (inQuotes) {
|
||||
if (char === '"') { if (text[index + 1] === '"') { field += '"'; index += 1; } else { inQuotes = false; afterQuote = true; } }
|
||||
else if (char === '\r' && text[index + 1] === '\n') { field += '\r\n'; index += 1; }
|
||||
else field += char;
|
||||
continue;
|
||||
}
|
||||
if (afterQuote) {
|
||||
if (char === ',') { row.push(field); field = ''; afterQuote = false; atFieldStart = true; continue; }
|
||||
if (char === '\n' || char === '\r') { if (char === '\r' && text[index + 1] === '\n') index += 1; row.push(field); parsed.push(row); row = []; field = ''; afterQuote = false; atFieldStart = true; continue; }
|
||||
fail(`${identity}: malformed CSV near row ${parsed.length + 1}; characters after a closing quote are not allowed`);
|
||||
}
|
||||
if (char === '"') { if (!atFieldStart || field.length > 0) fail(`${identity}: malformed CSV near row ${parsed.length + 1}; quote inside an unquoted field`); inQuotes = true; atFieldStart = false; continue; }
|
||||
if (char === ',') { row.push(field); field = ''; atFieldStart = true; continue; }
|
||||
if (char === '\n' || char === '\r') { if (char === '\r' && text[index + 1] === '\n') index += 1; row.push(field); parsed.push(row); row = []; field = ''; atFieldStart = true; continue; }
|
||||
field += char; atFieldStart = false;
|
||||
}
|
||||
if (inQuotes) fail(`${identity}: malformed CSV; quoted field is not terminated`);
|
||||
if (row.length > 0 || field.length > 0 || afterQuote || !atFieldStart) { row.push(field); parsed.push(row); }
|
||||
if (parsed.length === 0) fail(`${identity}: CSV must contain a header row`);
|
||||
const headers = parsed[0]; const seenHeaders = new Set();
|
||||
headers.forEach((header, index) => {
|
||||
if (header.length === 0 || header.trim().length === 0 || header !== header.trim()) fail(`${identity}: header ${index + 1} is empty or malformed`);
|
||||
if (/[\u0000-\u001F\u007F]/u.test(header)) fail(`${identity}: header ${index + 1} contains a control character`);
|
||||
if (BLOCKED_HEADERS.has(header)) fail(`${identity}: header ${index + 1} is reserved`);
|
||||
if (seenHeaders.has(header)) fail(`${identity}: duplicate header "${header}"`);
|
||||
seenHeaders.add(header);
|
||||
});
|
||||
const rows = parsed.slice(1).map((values, index) => {
|
||||
if (values.length !== headers.length) fail(`${identity}: row ${index + 2} has ${values.length} columns; expected ${headers.length}`);
|
||||
const record = {}; headers.forEach((header, column) => { record[header] = values[column]; }); return Object.freeze(record);
|
||||
});
|
||||
return Object.freeze({ headers: Object.freeze([...headers]), rows: Object.freeze(rows) });
|
||||
}
|
||||
|
||||
const TYPE_NAMES = new Set(['string','integer','number','boolean','date']);
|
||||
const validateIsoDate = value => { if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; const [y,m,d] = value.split('-').map(Number); const date = new Date(Date.UTC(y,m-1,d)); return date.getUTCFullYear()===y && date.getUTCMonth()===m-1 && date.getUTCDate()===d; };
|
||||
const normalizeRule = (rule, column, identity) => {
|
||||
if (typeof rule === 'string') { if (!TYPE_NAMES.has(rule)) fail(`${identity}: schema for column "${column}" uses unsupported type ${rule}`); return { type: rule, nullable: false }; }
|
||||
if (!isObject(rule)) fail(`${identity}: schema for column "${column}" must be a type or descriptor`);
|
||||
rejectUnknownKeys(rule, ['type','nullable'], `schema.${column}`);
|
||||
if (!TYPE_NAMES.has(rule.type)) fail(`${identity}: schema for column "${column}" uses an unsupported type`);
|
||||
if ('nullable' in rule && typeof rule.nullable !== 'boolean') fail(`${identity}: schema nullable flag for column "${column}" must be boolean`);
|
||||
return { type: rule.type, nullable: rule.nullable === true };
|
||||
};
|
||||
const convertCell = (value, rule, column, identity, rowNumber) => {
|
||||
if (value === '' && rule.nullable) return null;
|
||||
const location = `${identity} row ${rowNumber} column "${column}"`;
|
||||
switch (rule.type) {
|
||||
case 'string': return value;
|
||||
case 'integer': { if (!/^[+-]?\d+$/.test(value)) fail(`${location} is not a valid integer`); const converted = Number(value); if (!Number.isSafeInteger(converted)) fail(`${location} is outside the safe integer range`); return converted; }
|
||||
case 'number': { if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(value)) fail(`${location} is not a valid number`); const converted = Number(value); if (!Number.isFinite(converted)) fail(`${location} is not a finite number`); return converted; }
|
||||
case 'boolean': if (value === 'true') return true; if (value === 'false') return false; fail(`${location} is not a valid boolean`); break;
|
||||
case 'date': if (!validateIsoDate(value)) fail(`${location} is not a valid ISO date`); return value;
|
||||
default: fail(`${location} uses an unsupported type`);
|
||||
}
|
||||
};
|
||||
|
||||
class DatasetView {
|
||||
#metadata; #headers; #rows;
|
||||
constructor(metadata, parsed) { this.#metadata = metadata; this.#headers = parsed.headers; this.#rows = parsed.rows; Object.freeze(this); }
|
||||
get metadata() { return this.#metadata; } get headers() { return this.#headers; } get rowCount() { return this.#rows.length; }
|
||||
rows() { return this.#rows; }
|
||||
row(index) { if (!Number.isInteger(index) || index < 0 || index >= this.#rows.length) fail(`${datasetLabel(this.#metadata)}: row index ${index} is out of range`); return this.#rows[index]; }
|
||||
where(criteria) {
|
||||
if (!isObject(criteria) || Object.keys(criteria).length === 0) fail(`${datasetLabel(this.#metadata)}: filter criteria must be a non-empty object`);
|
||||
for (const [column,value] of Object.entries(criteria)) { if (!this.#headers.includes(column)) fail(`${datasetLabel(this.#metadata)}: filter column "${column}" is not declared`); if (typeof value !== 'string') fail(`${datasetLabel(this.#metadata)}: filter values must be strings`); }
|
||||
return Object.freeze(this.#rows.filter(row => Object.entries(criteria).every(([column,value]) => row[column] === value)));
|
||||
}
|
||||
first(criteria) { const matches = this.where(criteria); if (matches.length === 0) fail(`${datasetLabel(this.#metadata)}: filter matched no rows`); return matches[0]; }
|
||||
typedRow(index, schema) {
|
||||
const row = this.row(index); if (!isObject(schema) || Object.keys(schema).length === 0) fail(`${datasetLabel(this.#metadata)}: typed schema must be a non-empty object`);
|
||||
const typed = { ...row }; for (const [column,rawRule] of Object.entries(schema)) { if (!this.#headers.includes(column)) fail(`${datasetLabel(this.#metadata)}: schema column "${column}" is not declared`); typed[column] = convertCell(row[column], normalizeRule(rawRule,column,datasetLabel(this.#metadata)), column, datasetLabel(this.#metadata), index + 1); }
|
||||
return Object.freeze(typed);
|
||||
}
|
||||
}
|
||||
|
||||
class QkforceTestDataProvider {
|
||||
#datasets; #byLogicalName; #byDatasetId; #lockSha256;
|
||||
constructor(datasets, lockSha256) { this.#datasets = Object.freeze(datasets); this.#byLogicalName = new Map(datasets.map(d => [d.metadata.logicalName,d])); this.#byDatasetId = new Map(datasets.map(d => [d.metadata.datasetId,d])); this.#lockSha256 = lockSha256; Object.freeze(this); }
|
||||
get size() { return this.#datasets.length; }
|
||||
datasets() { return Object.freeze(this.#datasets.map(d => d.metadata)); }
|
||||
has(nameOrId) { return this.#byLogicalName.has(nameOrId) || this.#byDatasetId.has(nameOrId); }
|
||||
dataset(nameOrId) { const dataset = this.#byLogicalName.get(nameOrId) ?? this.#byDatasetId.get(nameOrId); if (!dataset) fail(`dataset "${nameOrId}" is not declared by ${LOCK_FILE}`); return dataset; }
|
||||
provenance() { if (this.#lockSha256 === undefined) return Object.freeze({}); return Object.freeze({ testDataLockSha256: this.#lockSha256, testData: Object.freeze(this.#datasets.map(d => Object.freeze({ datasetId:d.metadata.datasetId, versionId:d.metadata.versionId, sha256:d.metadata.sha256 }))) }); }
|
||||
}
|
||||
|
||||
function readLock(rootDirectory, required) {
|
||||
const lockPath = path.resolve(rootDirectory, LOCK_FILE); let bytes;
|
||||
try { bytes = fs.readFileSync(lockPath); } catch (error) { if (error && error.code === 'ENOENT') { if (required) fail(`${LOCK_FILE} is missing`); return undefined; } fail(`${LOCK_FILE} cannot be read`); }
|
||||
let parsed; try { parsed = JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(bytes)); } catch { fail(`${LOCK_FILE} is not valid UTF-8 JSON`); }
|
||||
return { parsed, digest: sha256(bytes) };
|
||||
}
|
||||
function loadDataset(rootDirectory, metadata) {
|
||||
const dataRoot = path.resolve(rootDirectory,'test-data'); const absolutePath = path.resolve(rootDirectory,metadata.path); if (!absolutePath.startsWith(`${dataRoot}${path.sep}`)) fail(`${datasetLabel(metadata)}: path escapes test-data/`);
|
||||
let stat; try { stat = fs.lstatSync(absolutePath); } catch (error) { if (error && error.code === 'ENOENT') fail(`${datasetLabel(metadata)}: declared CSV file is missing`); fail(`${datasetLabel(metadata)}: declared CSV file cannot be inspected`); }
|
||||
if (stat.isSymbolicLink()) fail(`${datasetLabel(metadata)}: symbolic links are not allowed for CSV inputs`); if (!stat.isFile()) fail(`${datasetLabel(metadata)}: declared CSV path is not a regular file`);
|
||||
let bytes; try { bytes = fs.readFileSync(absolutePath); } catch { fail(`${datasetLabel(metadata)}: declared CSV file cannot be read`); }
|
||||
if (sha256(bytes) !== metadata.sha256) fail(`${datasetLabel(metadata)}: checksum mismatch`);
|
||||
let text; try { text = new TextDecoder('utf-8',{fatal:true}).decode(bytes); } catch { fail(`${datasetLabel(metadata)}: CSV file is not valid UTF-8`); }
|
||||
const parsed = parseRfc4180Csv(text,datasetLabel(metadata)); if (parsed.rows.length !== metadata.rowCount) fail(`${datasetLabel(metadata)}: row count mismatch; lock declares ${metadata.rowCount} rows but CSV contains ${parsed.rows.length}`); return new DatasetView(metadata,parsed);
|
||||
}
|
||||
export function loadQkforceTestData(rootDirectory = process.cwd(), options = {}) {
|
||||
if (!isObject(options)) fail('loader options must be an object'); rejectUnknownKeys(options,['required'],'options'); if ('required' in options && typeof options.required !== 'boolean') fail('options.required must be boolean');
|
||||
const lock = readLock(rootDirectory, options.required === true); if (!lock) return new QkforceTestDataProvider([],undefined); const validated = validateQkforceTestDataLock(lock.parsed); return new QkforceTestDataProvider(validated.datasets.map(metadata => loadDataset(rootDirectory,metadata)),lock.digest);
|
||||
}
|
||||
export { CONTENT_TYPE, DATA_PATH_PATTERN, LOCK_FILE, LOCK_SCHEMA_VERSION, MAX_DATASETS };
|
||||
14
test/support/test-data.ts
Normal file
14
test/support/test-data.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { loadQkforceTestData } from './qkforce-test-data.mjs';
|
||||
|
||||
export type QkforceScalarType = 'string' | 'integer' | 'number' | 'boolean' | 'date';
|
||||
export interface QkforceTypeDescriptor { type: QkforceScalarType; nullable?: boolean; }
|
||||
export type QkforceTypeRule = QkforceScalarType | QkforceTypeDescriptor;
|
||||
export type QkforceTypedSchema = Readonly<Record<string, QkforceTypeRule>>;
|
||||
export type QkforceRawRow = Readonly<Record<string, string>>;
|
||||
export type QkforceTypedRow = Readonly<Record<string, string | number | boolean | null>>;
|
||||
export interface QkforceDatasetMetadata { readonly datasetId:string; readonly versionId:string; readonly versionNumber:number; readonly logicalName:string; readonly path:string; readonly sha256:string; readonly rowCount:number; readonly contentType:'text/csv; charset=utf-8'; }
|
||||
export interface QkforceDataset { readonly metadata:QkforceDatasetMetadata; readonly headers:readonly string[]; readonly rowCount:number; rows():readonly QkforceRawRow[]; row(index:number):QkforceRawRow; where(criteria:Readonly<Record<string,string>>):readonly QkforceRawRow[]; first(criteria:Readonly<Record<string,string>>):QkforceRawRow; typedRow(index:number,schema:QkforceTypedSchema):QkforceTypedRow; }
|
||||
export interface QkforceTestDataProvenance { readonly testDataLockSha256?:string; readonly testData?:readonly Readonly<{datasetId:string;versionId:string;sha256:string}>[]; }
|
||||
export interface QkforceTestDataProvider { readonly size:number; datasets():readonly QkforceDatasetMetadata[]; has(nameOrId:string):boolean; dataset(nameOrId:string):QkforceDataset; provenance():QkforceTestDataProvenance; }
|
||||
export interface LoadQkforceTestDataOptions { required?: boolean; }
|
||||
export function loadTestData(rootDirectory:string = process.cwd(), options:LoadQkforceTestDataOptions = {}):QkforceTestDataProvider { return loadQkforceTestData(rootDirectory, options) as QkforceTestDataProvider; }
|
||||
16
test/unit/qkforce-config.test.mjs
Normal file
16
test/unit/qkforce-config.test.mjs
Normal 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}); } });
|
||||
23
test/unit/qkforce-test-data.test.mjs
Normal file
23
test/unit/qkforce-test-data.test.mjs
Normal 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); } });
|
||||
43
test/unit/wdio-qkta-adapter.test.mjs
Normal file
43
test/unit/wdio-qkta-adapter.test.mjs
Normal 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 });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user