44
.github/workflows/ci.yml
vendored
Normal file
44
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [develop, main]
|
||||||
|
pull_request:
|
||||||
|
branches: [develop, main]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20.19.0
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install locked dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Validate template contract
|
||||||
|
run: npm run check
|
||||||
|
|
||||||
|
- name: Execute fixed QKForce profile
|
||||||
|
run: npm run qkforce:test
|
||||||
|
|
||||||
|
- name: Build portable report
|
||||||
|
if: always()
|
||||||
|
run: npm run qkforce:report
|
||||||
|
|
||||||
|
- name: Upload QKTestAnalytics results
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: qkforce-results
|
||||||
|
path: qreport-results/
|
||||||
|
if-no-files-found: warn
|
||||||
21
docs/REPORTING.md
Normal file
21
docs/REPORTING.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
> **QKForce integration:** QKTestAnalytics 0.4.1 is the pinned reporter adapter used by the fixed CWQ profile. Reports must contain test evidence only; package configuration and raw version-locked CSV inputs are excluded by the Client when it produces `results.zip`.
|
||||||
|
|
||||||
|
# QKTestAnalytics reporting
|
||||||
|
|
||||||
|
QKForce Framework CWQ integrates the official `@qacg/qk-test-analytics/adapters/wdio-cucumber` adapter through WebdriverIO lifecycle hooks. The adapter emits runner-neutral events and keeps the legacy-compatible report layout only as a portable result format; the template does not call QReport APIs directly.
|
||||||
|
|
||||||
|
## Lifecycle and evidence
|
||||||
|
|
||||||
|
1. WebdriverIO starts the adapter with its actual session instance.
|
||||||
|
2. Feature, scenario and step hooks emit ordered QKTestAnalytics events.
|
||||||
|
3. Failed steps capture a screenshot by default (`capture: 'on-failure'`).
|
||||||
|
4. Report JSON is stored in `qreport-results/media-bucket/reports/current.json` and evidence is stored by reference under `qreport-results/media-bucket/`.
|
||||||
|
5. `npm run qkforce:report` creates a portable HTML report; `npm run qkforce:analytics` creates cross-run analytics.
|
||||||
|
|
||||||
|
The adapter intentionally uses the session passed by WDIO rather than a global browser object. Custom evidence can be attached from a step definition only while a step is active.
|
||||||
|
|
||||||
|
## Result-artifact policy
|
||||||
|
|
||||||
|
Collect `qreport-results/` into the execution `results.zip`. Review evidence before sharing it: screenshots can contain application data. Do not attach passwords, tokens, cookies, authorization headers, raw CSV rows, or other secrets as QKTestAnalytics evidence or logs.
|
||||||
|
|
||||||
|
No video-generation pipeline is included. This removes the former Python/OpenCV dependency while retaining portable screenshots, report data and HTML reporting.
|
||||||
30
schemas/qkforce.config.schema.json
Normal file
30
schemas/qkforce.config.schema.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://github.com/QACGBDT/QKForce-docs/contracts/schemas/qkforce.config-v1.1.schema.json",
|
||||||
|
"title": "QKForce run configuration v1.1",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["schemaVersion", "profile", "run"],
|
||||||
|
"properties": {
|
||||||
|
"schemaVersion": { "const": 1 },
|
||||||
|
"profile": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["id", "language", "bdd", "runner", "adapter", "reporter"],
|
||||||
|
"properties": {
|
||||||
|
"id": { "const": "qkforce-wdio-cucumber-qkta" },
|
||||||
|
"language": { "const": "typescript" },
|
||||||
|
"bdd": { "const": "cucumber" },
|
||||||
|
"runner": { "const": "webdriverio" },
|
||||||
|
"adapter": { "const": "webdriverio-cucumber-node" },
|
||||||
|
"reporter": { "const": "qk-test-analytics" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"run": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["targetUrl"],
|
||||||
|
"properties": { "targetUrl": { "type": "string", "format": "uri", "maxLength": 2048 } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
30
schemas/qkforce.test-data-lock.schema.json
Normal file
30
schemas/qkforce.test-data-lock.schema.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://github.com/QACGBDT/QKForce-docs/contracts/schemas/qkforce.test-data-lock-v1.1.schema.json",
|
||||||
|
"title": "QKForce test data lock v1.1",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["schemaVersion", "datasets"],
|
||||||
|
"properties": {
|
||||||
|
"schemaVersion": { "const": 1 },
|
||||||
|
"datasets": {
|
||||||
|
"type": "array",
|
||||||
|
"maxItems": 128,
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["datasetId", "versionId", "versionNumber", "logicalName", "path", "sha256", "rowCount", "contentType"],
|
||||||
|
"properties": {
|
||||||
|
"datasetId": { "type": "string", "pattern": "^[A-Za-z0-9_-]{1,128}$" },
|
||||||
|
"versionId": { "type": "string", "pattern": "^[A-Za-z0-9_-]{1,128}$" },
|
||||||
|
"versionNumber": { "type": "integer", "minimum": 1 },
|
||||||
|
"logicalName": { "type": "string", "minLength": 1, "maxLength": 128 },
|
||||||
|
"path": { "type": "string", "pattern": "^test-data/[A-Za-z0-9][A-Za-z0-9._-]{0,239}\\.csv$" },
|
||||||
|
"sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
|
||||||
|
"rowCount": { "type": "integer", "minimum": 0 },
|
||||||
|
"contentType": { "const": "text/csv; charset=utf-8" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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