From 896c3a121a1a49b88fb5f3cb707b35af98b75512 Mon Sep 17 00:00:00 2001 From: isopropilick Date: Thu, 27 Aug 2026 15:00:50 -0600 Subject: [PATCH] ok --- .github/workflows/ci.yml | 44 +++++ docs/REPORTING.md | 21 +++ schemas/qkforce.config.schema.json | 30 ++++ schemas/qkforce.test-data-lock.schema.json | 30 ++++ test/features/login.feature | 22 +++ .../test-data/negative/unsafe-path.lock.json | 13 ++ .../positive/qkforce.test-data.lock.json | 13 ++ .../positive/test-data/customers.csv | 4 + test/selectors/actionHelper.ts | 37 ++++ test/selectors/index.ts | 7 + test/selectors/page-objects/login.ts | 9 + test/selectors/page-objects/secure.ts | 7 + test/step-definitions/steps.ts | 21 +++ test/support/qkforce-config.mjs | 53 ++++++ test/support/qkforce-test-data.mjs | 162 ++++++++++++++++++ test/support/test-data.ts | 14 ++ test/unit/qkforce-config.test.mjs | 16 ++ test/unit/qkforce-test-data.test.mjs | 23 +++ test/unit/wdio-qkta-adapter.test.mjs | 43 +++++ 19 files changed, 569 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 docs/REPORTING.md create mode 100644 schemas/qkforce.config.schema.json create mode 100644 schemas/qkforce.test-data-lock.schema.json create mode 100644 test/features/login.feature create mode 100644 test/fixtures/test-data/negative/unsafe-path.lock.json create mode 100644 test/fixtures/test-data/positive/qkforce.test-data.lock.json create mode 100644 test/fixtures/test-data/positive/test-data/customers.csv create mode 100644 test/selectors/actionHelper.ts create mode 100644 test/selectors/index.ts create mode 100644 test/selectors/page-objects/login.ts create mode 100644 test/selectors/page-objects/secure.ts create mode 100644 test/step-definitions/steps.ts create mode 100644 test/support/qkforce-config.mjs create mode 100644 test/support/qkforce-test-data.mjs create mode 100644 test/support/test-data.ts create mode 100644 test/unit/qkforce-config.test.mjs create mode 100644 test/unit/qkforce-test-data.test.mjs create mode 100644 test/unit/wdio-qkta-adapter.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bcdee01 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/docs/REPORTING.md b/docs/REPORTING.md new file mode 100644 index 0000000..2df51dc --- /dev/null +++ b/docs/REPORTING.md @@ -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. diff --git a/schemas/qkforce.config.schema.json b/schemas/qkforce.config.schema.json new file mode 100644 index 0000000..2c1ac2e --- /dev/null +++ b/schemas/qkforce.config.schema.json @@ -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 } } + } + } +} diff --git a/schemas/qkforce.test-data-lock.schema.json b/schemas/qkforce.test-data-lock.schema.json new file mode 100644 index 0000000..3411ccb --- /dev/null +++ b/schemas/qkforce.test-data-lock.schema.json @@ -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" } + } + } + } + } +} diff --git a/test/features/login.feature b/test/features/login.feature new file mode 100644 index 0000000..21fa4d2 --- /dev/null +++ b/test/features/login.feature @@ -0,0 +1,22 @@ +Feature: The Internet Guinea Pig Website + + Background: + Given the user is on the login page + + @smoke + Scenario Outline: Successful login - + When the user logs in with and + Then the user should see a flash message containing "" + 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 - + When the user logs in with and + Then the user should see a flash message containing "" + + Examples: + | ID | username | password | message | + | 02 | foobar | barfoo | Your username is invalid! | diff --git a/test/fixtures/test-data/negative/unsafe-path.lock.json b/test/fixtures/test-data/negative/unsafe-path.lock.json new file mode 100644 index 0000000..81d2740 --- /dev/null +++ b/test/fixtures/test-data/negative/unsafe-path.lock.json @@ -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" + }] +} diff --git a/test/fixtures/test-data/positive/qkforce.test-data.lock.json b/test/fixtures/test-data/positive/qkforce.test-data.lock.json new file mode 100644 index 0000000..e2e3edc --- /dev/null +++ b/test/fixtures/test-data/positive/qkforce.test-data.lock.json @@ -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" + }] +} diff --git a/test/fixtures/test-data/positive/test-data/customers.csv b/test/fixtures/test-data/positive/test-data/customers.csv new file mode 100644 index 0000000..a1537a2 --- /dev/null +++ b/test/fixtures/test-data/positive/test-data/customers.csv @@ -0,0 +1,4 @@ +id,name,active,age,note +1,"María, QA",true,30,"line one +line two" +2,李,false,, diff --git a/test/selectors/actionHelper.ts b/test/selectors/actionHelper.ts new file mode 100644 index 0000000..18af7a9 --- /dev/null +++ b/test/selectors/actionHelper.ts @@ -0,0 +1,37 @@ +import { $, $$ } from '@wdio/globals'; +import { dictionary } from './index.js'; + +type SelectorNode = string | Record; + +export function getSelector(route: string): string { + const value = route.split('.').reduce((current, segment) => { + if (!current || typeof current !== 'object') return undefined; + return (current as Record)[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> { + 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> { + const selector = getSelector(route); + const firstElement = $(selector); + await firstElement.waitForExist({ timeout: 60_000 }); + await firstElement.waitForDisplayed({ timeout: 60_000 }); + return $$(selector); +} + +export type { SelectorNode }; diff --git a/test/selectors/index.ts b/test/selectors/index.ts new file mode 100644 index 0000000..a6b9eca --- /dev/null +++ b/test/selectors/index.ts @@ -0,0 +1,7 @@ +import loginSelectors from './page-objects/login.js'; +import secureSelectors from './page-objects/secure.js'; + +export const dictionary = { + ...loginSelectors, + ...secureSelectors, +}; diff --git a/test/selectors/page-objects/login.ts b/test/selectors/page-objects/login.ts new file mode 100644 index 0000000..8f29b85 --- /dev/null +++ b/test/selectors/page-objects/login.ts @@ -0,0 +1,9 @@ +const loginSelectors = { + loginPage: { + inputUsername: '#username', + inputPassword: '#password', + submitButton: 'button[type="submit"]', + }, +}; + +export default loginSelectors; diff --git a/test/selectors/page-objects/secure.ts b/test/selectors/page-objects/secure.ts new file mode 100644 index 0000000..706464f --- /dev/null +++ b/test/selectors/page-objects/secure.ts @@ -0,0 +1,7 @@ +const secureSelectors = { + securePage: { + flashAlert: '#flash', + }, +}; + +export default secureSelectors; diff --git a/test/step-definitions/steps.ts b/test/step-definitions/steps.ts new file mode 100644 index 0000000..6c4f4dd --- /dev/null +++ b/test/step-definitions/steps.ts @@ -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); +}); diff --git a/test/support/qkforce-config.mjs b/test/support/qkforce-config.mjs new file mode 100644 index 0000000..b8484f8 --- /dev/null +++ b/test/support/qkforce-config.mjs @@ -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 }; diff --git a/test/support/qkforce-test-data.mjs b/test/support/qkforce-test-data.mjs new file mode 100644 index 0000000..671f126 --- /dev/null +++ b/test/support/qkforce-test-data.mjs @@ -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 }; diff --git a/test/support/test-data.ts b/test/support/test-data.ts new file mode 100644 index 0000000..62f3fd2 --- /dev/null +++ b/test/support/test-data.ts @@ -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>; +export type QkforceRawRow = Readonly>; +export type QkforceTypedRow = Readonly>; +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>):readonly QkforceRawRow[]; first(criteria:Readonly>):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; } diff --git a/test/unit/qkforce-config.test.mjs b/test/unit/qkforce-config.test.mjs new file mode 100644 index 0000000..65b32dd --- /dev/null +++ b/test/unit/qkforce-config.test.mjs @@ -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}); } }); diff --git a/test/unit/qkforce-test-data.test.mjs b/test/unit/qkforce-test-data.test.mjs new file mode 100644 index 0000000..218f291 --- /dev/null +++ b/test/unit/qkforce-test-data.test.mjs @@ -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); } }); diff --git a/test/unit/wdio-qkta-adapter.test.mjs b/test/unit/wdio-qkta-adapter.test.mjs new file mode 100644 index 0000000..ef31d47 --- /dev/null +++ b/test/unit/wdio-qkta-adapter.test.mjs @@ -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 }); + } +});