@flowspec-qa/runner-core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api-client.d.ts +50 -0
- package/dist/api-client.d.ts.map +1 -0
- package/dist/api-client.js +70 -0
- package/dist/api-client.js.map +1 -0
- package/dist/auth.d.ts +17 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +38 -0
- package/dist/auth.js.map +1 -0
- package/dist/config.d.ts +16 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +37 -0
- package/dist/config.js.map +1 -0
- package/dist/executor.d.ts +32 -0
- package/dist/executor.d.ts.map +1 -0
- package/dist/executor.js +221 -0
- package/dist/executor.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/result-sanitizer.d.ts +21 -0
- package/dist/result-sanitizer.d.ts.map +1 -0
- package/dist/result-sanitizer.js +54 -0
- package/dist/result-sanitizer.js.map +1 -0
- package/dist/suite-manager.d.ts +25 -0
- package/dist/suite-manager.d.ts.map +1 -0
- package/dist/suite-manager.js +60 -0
- package/dist/suite-manager.js.map +1 -0
- package/dist/uploader.d.ts +16 -0
- package/dist/uploader.d.ts.map +1 -0
- package/dist/uploader.js +29 -0
- package/dist/uploader.js.map +1 -0
- package/package.json +23 -0
- package/src/api-client.ts +118 -0
- package/src/auth.ts +45 -0
- package/src/config.ts +52 -0
- package/src/executor.ts +275 -0
- package/src/index.ts +8 -0
- package/src/result-sanitizer.ts +60 -0
- package/src/suite-manager.ts +73 -0
- package/src/uploader.ts +45 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface LocalSuite {
|
|
2
|
+
runId: string;
|
|
3
|
+
entityId: string;
|
|
4
|
+
framework: 'playwright' | 'cypress' | 'maestro';
|
|
5
|
+
suiteDir: string;
|
|
6
|
+
downloadedAt: Date;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Download a suite ZIP from FlowSpecQA SaaS and unzip it to the local cache.
|
|
10
|
+
* Returns the path to the unzipped suite directory.
|
|
11
|
+
*/
|
|
12
|
+
export declare function downloadSuite(suiteId: string, entityId: string, framework: string): Promise<LocalSuite>;
|
|
13
|
+
/**
|
|
14
|
+
* List all locally cached suites.
|
|
15
|
+
*/
|
|
16
|
+
export declare function listCachedSuites(): Array<{
|
|
17
|
+
entityId: string;
|
|
18
|
+
runId: string;
|
|
19
|
+
suiteDir: string;
|
|
20
|
+
}>;
|
|
21
|
+
/**
|
|
22
|
+
* Remove a cached suite directory.
|
|
23
|
+
*/
|
|
24
|
+
export declare function evictSuite(entityId: string, runId: string): void;
|
|
25
|
+
//# sourceMappingURL=suite-manager.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"suite-manager.d.ts","sourceRoot":"","sources":["../src/suite-manager.ts"],"names":[],"mappings":"AAMA,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,YAAY,GAAG,SAAS,GAAG,SAAS,CAAC;IAChD,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,IAAI,CAAC;CACpB;AAED;;;GAGG;AACH,wBAAsB,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CA4B7G;AAED;;GAEG;AACH,wBAAgB,gBAAgB,IAAI,KAAK,CAAC;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC,CAa/F;AAED;;GAEG;AACH,wBAAgB,UAAU,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAGhE"}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import AdmZip from 'adm-zip';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { config } from './config.js';
|
|
5
|
+
import { FlowSpecQAApi } from './api-client.js';
|
|
6
|
+
/**
|
|
7
|
+
* Download a suite ZIP from FlowSpecQA SaaS and unzip it to the local cache.
|
|
8
|
+
* Returns the path to the unzipped suite directory.
|
|
9
|
+
*/
|
|
10
|
+
export async function downloadSuite(suiteId, entityId, framework) {
|
|
11
|
+
const cacheDir = config.get('suiteCacheDir');
|
|
12
|
+
const suiteDir = path.join(cacheDir, entityId, suiteId);
|
|
13
|
+
// Avoid re-downloading if already cached
|
|
14
|
+
if (fs.existsSync(path.join(suiteDir, 'package.json'))) {
|
|
15
|
+
return {
|
|
16
|
+
runId: suiteId,
|
|
17
|
+
entityId,
|
|
18
|
+
framework: framework,
|
|
19
|
+
suiteDir,
|
|
20
|
+
downloadedAt: fs.statSync(suiteDir).mtime,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
const zipBuffer = await FlowSpecQAApi.downloadSuiteZip(suiteId);
|
|
24
|
+
fs.mkdirSync(suiteDir, { recursive: true });
|
|
25
|
+
const zip = new AdmZip(zipBuffer);
|
|
26
|
+
zip.extractAllTo(suiteDir, /* overwrite */ true);
|
|
27
|
+
return {
|
|
28
|
+
runId: suiteId,
|
|
29
|
+
entityId,
|
|
30
|
+
framework: framework,
|
|
31
|
+
suiteDir,
|
|
32
|
+
downloadedAt: new Date(),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* List all locally cached suites.
|
|
37
|
+
*/
|
|
38
|
+
export function listCachedSuites() {
|
|
39
|
+
const cacheDir = config.get('suiteCacheDir');
|
|
40
|
+
if (!fs.existsSync(cacheDir))
|
|
41
|
+
return [];
|
|
42
|
+
const result = [];
|
|
43
|
+
for (const entityId of fs.readdirSync(cacheDir)) {
|
|
44
|
+
const entityDir = path.join(cacheDir, entityId);
|
|
45
|
+
if (!fs.statSync(entityDir).isDirectory())
|
|
46
|
+
continue;
|
|
47
|
+
for (const runId of fs.readdirSync(entityDir)) {
|
|
48
|
+
result.push({ entityId, runId, suiteDir: path.join(entityDir, runId) });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Remove a cached suite directory.
|
|
55
|
+
*/
|
|
56
|
+
export function evictSuite(entityId, runId) {
|
|
57
|
+
const suiteDir = path.join(config.get('suiteCacheDir'), entityId, runId);
|
|
58
|
+
fs.rmSync(suiteDir, { recursive: true, force: true });
|
|
59
|
+
}
|
|
60
|
+
//# sourceMappingURL=suite-manager.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"suite-manager.js","sourceRoot":"","sources":["../src/suite-manager.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,SAAS,CAAC;AAC7B,OAAO,EAAE,MAAM,IAAI,CAAC;AACpB,OAAO,IAAI,MAAM,MAAM,CAAC;AACxB,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AACrC,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAUhD;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,OAAe,EAAE,QAAgB,EAAE,SAAiB;IACtF,MAAM,QAAQ,GAAG,MAAM,CAAC,GAAG,CAAC,eAAe,CAAC,CAAC;IAC7C,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,QAAQ,EAAE,OAAO,CAAC,CAAC;IAExD,yCAAyC;IACzC,IAAI,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,cAAc,CAAC,CAAC,EAAE,CAAC;QACvD,OAAO;YACL,KAAK,EAAE,OAAO;YACd,QAAQ;YACR,SAAS,EAAE,SAAoC;YAC/C,QAAQ;YACR,YAAY,EAAE,EAAE,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,KAAK;SAC1C,CAAC;IACJ,CAAC;IAED,MAAM,SAAS,GAAG,MAAM,aAAa,CAAC,gBAAgB,CAAC,OAAO,CAAC,CAAC;IAChE,EAAE,CAAC,SAAS,CAAC,QAAQ,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAE5C,MAAM,GAAG,GAAG,IAAI,MAAM,CAAC,SAAS,CAAC,CAAC;IAClC,GAAG,CAAC,YAAY,CAAC,QAAQ,EAAE,eAAe,CAAC,IAAI,CAAC,CAAC;IAEjD,OAAO;QACL,KAAK,EAAE,OAAO;QACd,QAAQ;QACR,SAAS,EAAE,SAAoC;QAC/C,QAAQ;QACR,YAAY,EAAE,IAAI,IAAI,EAAE;KACzB,CAAC;AACJ,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,gBAAgB;IAC9B,MAAM,QAAQ,GAAG,MAAM,CAAC,GAAG,CAAC,eAAe,CAAC,CAAC;IAC7C,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,QAAQ,CAAC;QAAE,OAAO,EAAE,CAAC;IAExC,MAAM,MAAM,GAAiE,EAAE,CAAC;IAChF,KAAK,MAAM,QAAQ,IAAI,EAAE,CAAC,WAAW,CAAC,QAAQ,CAAC,EAAE,CAAC;QAChD,MAAM,SAAS,GAAG,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAC;QAChD,IAAI,CAAC,EAAE,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAC,WAAW,EAAE;YAAE,SAAS;QACpD,KAAK,MAAM,KAAK,IAAI,EAAE,CAAC,WAAW,CAAC,SAAS,CAAC,EAAE,CAAC;YAC9C,MAAM,CAAC,IAAI,CAAC,EAAE,QAAQ,EAAE,KAAK,EAAE,QAAQ,EAAE,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,KAAK,CAAC,EAAE,CAAC,CAAC;QAC1E,CAAC;IACH,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,UAAU,CAAC,QAAgB,EAAE,KAAa;IACxD,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,eAAe,CAAC,EAAE,QAAQ,EAAE,KAAK,CAAC,CAAC;IACzE,EAAE,CAAC,MAAM,CAAC,QAAQ,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;AACxD,CAAC"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ExecutionResult } from './executor.js';
|
|
2
|
+
export interface UploadOptions {
|
|
3
|
+
runId: string;
|
|
4
|
+
entityId: string;
|
|
5
|
+
framework: string;
|
|
6
|
+
envLabel: string;
|
|
7
|
+
skipSecurityCheck?: boolean;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Sanitize + upload test results to FlowSpecQA SaaS.
|
|
11
|
+
* Returns the exec_id assigned by the server.
|
|
12
|
+
*/
|
|
13
|
+
export declare function uploadResults(execution: ExecutionResult, options: UploadOptions): Promise<{
|
|
14
|
+
execId: string;
|
|
15
|
+
}>;
|
|
16
|
+
//# sourceMappingURL=uploader.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"uploader.d.ts","sourceRoot":"","sources":["../src/uploader.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAIrD,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,iBAAiB,CAAC,EAAE,OAAO,CAAC;CAC7B;AAED;;;GAGG;AACH,wBAAsB,aAAa,CACjC,SAAS,EAAE,eAAe,EAC1B,OAAO,EAAE,aAAa,GACrB,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,CAuB7B"}
|
package/dist/uploader.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { FlowSpecQAApi } from './api-client.js';
|
|
2
|
+
import { sanitizeResults, assertNoSecrets } from './result-sanitizer.js';
|
|
3
|
+
const RUNNER_VERSION = '0.1.0';
|
|
4
|
+
/**
|
|
5
|
+
* Sanitize + upload test results to FlowSpecQA SaaS.
|
|
6
|
+
* Returns the exec_id assigned by the server.
|
|
7
|
+
*/
|
|
8
|
+
export async function uploadResults(execution, options) {
|
|
9
|
+
// Step 1: Sanitize — strip URLs, IPs, tokens from error messages
|
|
10
|
+
const sanitized = sanitizeResults(execution.results);
|
|
11
|
+
// Step 2: Belt-and-suspenders security check
|
|
12
|
+
if (!options.skipSecurityCheck) {
|
|
13
|
+
assertNoSecrets(sanitized);
|
|
14
|
+
}
|
|
15
|
+
// Step 3: Build payload
|
|
16
|
+
const payload = {
|
|
17
|
+
run_id: options.runId,
|
|
18
|
+
entity_id: options.entityId,
|
|
19
|
+
framework: options.framework,
|
|
20
|
+
env_label: options.envLabel,
|
|
21
|
+
results: sanitized,
|
|
22
|
+
started_at: execution.startedAt.toISOString(),
|
|
23
|
+
finished_at: execution.finishedAt.toISOString(),
|
|
24
|
+
runner_version: RUNNER_VERSION,
|
|
25
|
+
};
|
|
26
|
+
const response = await FlowSpecQAApi.submitRunResult(payload);
|
|
27
|
+
return { execId: response.exec_id };
|
|
28
|
+
}
|
|
29
|
+
//# sourceMappingURL=uploader.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"uploader.js","sourceRoot":"","sources":["../src/uploader.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAyB,MAAM,iBAAiB,CAAC;AACvE,OAAO,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAGzE,MAAM,cAAc,GAAG,OAAO,CAAC;AAU/B;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CACjC,SAA0B,EAC1B,OAAsB;IAEtB,iEAAiE;IACjE,MAAM,SAAS,GAAG,eAAe,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC;IAErD,6CAA6C;IAC7C,IAAI,CAAC,OAAO,CAAC,iBAAiB,EAAE,CAAC;QAC/B,eAAe,CAAC,SAAS,CAAC,CAAC;IAC7B,CAAC;IAED,wBAAwB;IACxB,MAAM,OAAO,GAAqB;QAChC,MAAM,EAAE,OAAO,CAAC,KAAK;QACrB,SAAS,EAAE,OAAO,CAAC,QAAQ;QAC3B,SAAS,EAAE,OAAO,CAAC,SAAS;QAC5B,SAAS,EAAE,OAAO,CAAC,QAAQ;QAC3B,OAAO,EAAE,SAAS;QAClB,UAAU,EAAE,SAAS,CAAC,SAAS,CAAC,WAAW,EAAE;QAC7C,WAAW,EAAE,SAAS,CAAC,UAAU,CAAC,WAAW,EAAE;QAC/C,cAAc,EAAE,cAAc;KAC/B,CAAC;IAEF,MAAM,QAAQ,GAAG,MAAM,aAAa,CAAC,eAAe,CAAC,OAAO,CAAC,CAAC;IAC9D,OAAO,EAAE,MAAM,EAAE,QAAQ,CAAC,OAAO,EAAE,CAAC;AACtC,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@flowspec-qa/runner-core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Core modules for FlowSpecQA Runner — auth, suite download, execution, upload",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "tsc -p tsconfig.json",
|
|
10
|
+
"dev": "tsc -p tsconfig.json --watch"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"adm-zip": "^0.5.10",
|
|
14
|
+
"axios": "^1.6.0",
|
|
15
|
+
"chalk": "^5.3.0",
|
|
16
|
+
"conf": "^12.0.0",
|
|
17
|
+
"execa": "^8.0.1",
|
|
18
|
+
"fast-xml-parser": "^5.5.8"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@types/adm-zip": "^0.5.5"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
|
|
2
|
+
import { config } from './config.js';
|
|
3
|
+
|
|
4
|
+
// Lazy singleton — created on first use so baseUrl is read after config is loaded
|
|
5
|
+
let _client: AxiosInstance | null = null;
|
|
6
|
+
|
|
7
|
+
function getClient(): AxiosInstance {
|
|
8
|
+
if (!_client) {
|
|
9
|
+
_client = axios.create({
|
|
10
|
+
baseURL: config.get('baseUrl'),
|
|
11
|
+
timeout: 30_000,
|
|
12
|
+
headers: { 'Content-Type': 'application/json' },
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// Attach Bearer token (API Key) to every request
|
|
16
|
+
_client.interceptors.request.use((req) => {
|
|
17
|
+
const apiKey = config.get('apiKey');
|
|
18
|
+
if (apiKey) {
|
|
19
|
+
req.headers['Authorization'] = `Bearer ${apiKey}`;
|
|
20
|
+
}
|
|
21
|
+
return req;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// Normalize error messages
|
|
25
|
+
_client.interceptors.response.use(
|
|
26
|
+
(res) => res,
|
|
27
|
+
(err) => {
|
|
28
|
+
const msg = err.response?.data?.detail || err.response?.data?.message || err.message;
|
|
29
|
+
return Promise.reject(new Error(msg));
|
|
30
|
+
}
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
return _client;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const apiClient = {
|
|
37
|
+
async get<T>(url: string, cfg?: AxiosRequestConfig): Promise<T> {
|
|
38
|
+
const res = await getClient().get<T>(url, cfg);
|
|
39
|
+
return res.data;
|
|
40
|
+
},
|
|
41
|
+
async post<T>(url: string, data?: unknown, cfg?: AxiosRequestConfig): Promise<T> {
|
|
42
|
+
const res = await getClient().post<T>(url, data, cfg);
|
|
43
|
+
return res.data;
|
|
44
|
+
},
|
|
45
|
+
async getStream(url: string): Promise<NodeJS.ReadableStream> {
|
|
46
|
+
const res = await getClient().get(url, { responseType: 'stream' });
|
|
47
|
+
return res.data as NodeJS.ReadableStream;
|
|
48
|
+
},
|
|
49
|
+
async getArrayBuffer(url: string): Promise<Buffer> {
|
|
50
|
+
const res = await getClient().get(url, { responseType: 'arraybuffer' });
|
|
51
|
+
return Buffer.from(res.data as ArrayBuffer);
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
/** Update base URL at runtime (e.g. pointing to a self-hosted instance) */
|
|
55
|
+
setBaseUrl(url: string): void {
|
|
56
|
+
config.set('baseUrl', url);
|
|
57
|
+
_client = null; // force recreation
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// ─── Typed API methods ─────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
export interface Project {
|
|
64
|
+
id: string;
|
|
65
|
+
name: string;
|
|
66
|
+
entity_count?: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface Suite {
|
|
70
|
+
entity_id: string;
|
|
71
|
+
entity_title: string;
|
|
72
|
+
suite_id: string; // TestSuite.id — used to download files
|
|
73
|
+
framework: 'playwright' | 'cypress' | 'maestro';
|
|
74
|
+
mode?: string;
|
|
75
|
+
status?: string;
|
|
76
|
+
created_at?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const FlowSpecQAApi = {
|
|
80
|
+
async listProjects(): Promise<Project[]> {
|
|
81
|
+
return apiClient.get<Project[]>('/api/config/projects');
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
async listSuites(entityId?: string): Promise<Suite[]> {
|
|
85
|
+
const url = entityId
|
|
86
|
+
? `/api/automation/suites?entity_id=${entityId}`
|
|
87
|
+
: '/api/automation/suites';
|
|
88
|
+
const resp = await apiClient.get<{ suites: Suite[]; total: number }>(url);
|
|
89
|
+
return resp.suites ?? [];
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
/** Download the ZIP of generated test files for a suite (suite_id = TestSuite.id) */
|
|
93
|
+
async downloadSuiteZip(suiteId: string): Promise<Buffer> {
|
|
94
|
+
return apiClient.getArrayBuffer(`/api/automation/download-suite/${suiteId}`);
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
async submitRunResult(payload: RunResultPayload): Promise<{ ok: boolean; exec_id: string }> {
|
|
98
|
+
return apiClient.post('/api/automation/submit-run-result', payload);
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export interface RunResultPayload {
|
|
103
|
+
run_id: string;
|
|
104
|
+
entity_id: string;
|
|
105
|
+
framework: string;
|
|
106
|
+
env_label: string;
|
|
107
|
+
results: TestResultItem[];
|
|
108
|
+
started_at: string;
|
|
109
|
+
finished_at: string;
|
|
110
|
+
runner_version: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface TestResultItem {
|
|
114
|
+
name: string;
|
|
115
|
+
status: 'pass' | 'fail' | 'skip';
|
|
116
|
+
duration_ms: number;
|
|
117
|
+
error?: string | null;
|
|
118
|
+
}
|
package/src/auth.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { config } from './config.js';
|
|
2
|
+
|
|
3
|
+
export interface AuthStatus {
|
|
4
|
+
authenticated: boolean;
|
|
5
|
+
maskedKey?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Store the API Key locally and test it against the SaaS.
|
|
10
|
+
* Keys use the format: sk-smrt-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
|
11
|
+
*/
|
|
12
|
+
export async function login(apiKey: string): Promise<void> {
|
|
13
|
+
if (!apiKey.startsWith('sk-smrt-')) {
|
|
14
|
+
throw new Error('Invalid API Key format. Keys should start with "sk-smrt-"');
|
|
15
|
+
}
|
|
16
|
+
config.set('apiKey', apiKey);
|
|
17
|
+
// Validate immediately by making a test call
|
|
18
|
+
const { apiClient } = await import('./api-client.js');
|
|
19
|
+
await apiClient.get('/api/config/projects'); // throws if key is invalid
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function logout(): void {
|
|
23
|
+
config.set('apiKey', undefined);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function getAuthStatus(): AuthStatus {
|
|
27
|
+
const apiKey = config.get('apiKey');
|
|
28
|
+
if (!apiKey) return { authenticated: false };
|
|
29
|
+
return {
|
|
30
|
+
authenticated: true,
|
|
31
|
+
maskedKey: apiKey.slice(0, 12) + '*'.repeat(Math.max(0, apiKey.length - 12)),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Return the API Key to use as Bearer token.
|
|
37
|
+
* The SaaS get_api_key_context dependency accepts: Authorization: Bearer sk-smrt-xxx
|
|
38
|
+
*/
|
|
39
|
+
export function getBearerToken(): string {
|
|
40
|
+
const apiKey = config.get('apiKey');
|
|
41
|
+
if (!apiKey) {
|
|
42
|
+
throw new Error('Not authenticated. Run: flowspecqa auth login');
|
|
43
|
+
}
|
|
44
|
+
return apiKey;
|
|
45
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import Conf from 'conf';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
|
|
5
|
+
export interface RunnerConfig {
|
|
6
|
+
apiKey?: string;
|
|
7
|
+
runnerToken?: string;
|
|
8
|
+
runnerTokenExpiry?: string; // ISO date
|
|
9
|
+
baseUrl: string;
|
|
10
|
+
suiteCacheDir: string;
|
|
11
|
+
sanitizerPatterns: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const DEFAULT_CONFIG: RunnerConfig = {
|
|
15
|
+
baseUrl: 'https://www.flowspecqa.com',
|
|
16
|
+
suiteCacheDir: path.join(os.homedir(), '.flowspecqa', 'suites'),
|
|
17
|
+
sanitizerPatterns: [
|
|
18
|
+
// Strip URLs, IPs, auth tokens from error messages before upload
|
|
19
|
+
'https?://[^\\s]+',
|
|
20
|
+
'\\b\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\b',
|
|
21
|
+
'Bearer\\s+[A-Za-z0-9.\\-_]+',
|
|
22
|
+
'Authorization:\\s*[^\\r\\n]+',
|
|
23
|
+
'password["\']?\\s*[:=]\\s*["\']?[^"\'\\s&]+',
|
|
24
|
+
],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const conf = new Conf<RunnerConfig>({
|
|
28
|
+
projectName: 'flowspecqa-runner',
|
|
29
|
+
defaults: DEFAULT_CONFIG,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export const config = {
|
|
33
|
+
get<K extends keyof RunnerConfig>(key: K): RunnerConfig[K] {
|
|
34
|
+
return conf.get(key) as RunnerConfig[K];
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
set<K extends keyof RunnerConfig>(key: K, value: RunnerConfig[K]): void {
|
|
38
|
+
conf.set(key, value);
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
getAll(): RunnerConfig {
|
|
42
|
+
return conf.store as RunnerConfig;
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
clear(): void {
|
|
46
|
+
conf.clear();
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
get configPath(): string {
|
|
50
|
+
return conf.path;
|
|
51
|
+
},
|
|
52
|
+
};
|
package/src/executor.ts
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { execa, ExecaError } from 'execa';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { existsSync } from 'fs';
|
|
4
|
+
import type { TestResultItem } from './api-client.js';
|
|
5
|
+
|
|
6
|
+
export interface ExecutionOptions {
|
|
7
|
+
env?: Record<string, string>; // Environment vars (base URLs, tokens — stay local, never uploaded)
|
|
8
|
+
timeout?: number; // ms, default 10 min
|
|
9
|
+
workers?: number; // Playwright workers
|
|
10
|
+
headed?: boolean; // Run headed (useful for debugging)
|
|
11
|
+
reporter?: string; // Playwright reporter override
|
|
12
|
+
grepPattern?: string; // Run only matching tests
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ExecutionResult {
|
|
16
|
+
exitCode: number;
|
|
17
|
+
results: TestResultItem[];
|
|
18
|
+
startedAt: Date;
|
|
19
|
+
finishedAt: Date;
|
|
20
|
+
stdout: string;
|
|
21
|
+
stderr: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Execute a Playwright test suite and parse the results.
|
|
26
|
+
*/
|
|
27
|
+
export async function executePlaywright(
|
|
28
|
+
suiteDir: string,
|
|
29
|
+
options: ExecutionOptions = {}
|
|
30
|
+
): Promise<ExecutionResult> {
|
|
31
|
+
const startedAt = new Date();
|
|
32
|
+
|
|
33
|
+
// Install deps if node_modules missing
|
|
34
|
+
if (!isNodeModulesPresent(suiteDir)) {
|
|
35
|
+
await execa('npm', ['install', '--prefer-offline'], {
|
|
36
|
+
cwd: suiteDir,
|
|
37
|
+
env: { ...process.env, ...(options.env ?? {}) },
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const args = ['playwright', 'test',
|
|
42
|
+
'--reporter=json',
|
|
43
|
+
...(options.workers ? [`--workers=${options.workers}`] : []),
|
|
44
|
+
...(options.headed ? ['--headed'] : []),
|
|
45
|
+
...(options.grepPattern ? [`--grep`, options.grepPattern] : []),
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
let stdout = '';
|
|
49
|
+
let stderr = '';
|
|
50
|
+
let exitCode = 0;
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const result = await execa('npx', args, {
|
|
54
|
+
cwd: suiteDir,
|
|
55
|
+
env: { ...process.env, ...(options.env ?? {}) },
|
|
56
|
+
timeout: options.timeout ?? 600_000,
|
|
57
|
+
reject: false, // don't throw on test failures
|
|
58
|
+
});
|
|
59
|
+
stdout = result.stdout;
|
|
60
|
+
stderr = result.stderr;
|
|
61
|
+
exitCode = result.exitCode ?? 1;
|
|
62
|
+
} catch (err) {
|
|
63
|
+
const e = err as ExecaError;
|
|
64
|
+
stdout = e.stdout ?? '';
|
|
65
|
+
stderr = e.stderr ?? e.message;
|
|
66
|
+
exitCode = e.exitCode ?? 1;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const finishedAt = new Date();
|
|
70
|
+
const results = parsePlaywrightJson(stdout);
|
|
71
|
+
|
|
72
|
+
return { exitCode, results, startedAt, finishedAt, stdout, stderr };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Execute a Cypress test suite.
|
|
77
|
+
*/
|
|
78
|
+
export async function executeCypress(
|
|
79
|
+
suiteDir: string,
|
|
80
|
+
options: ExecutionOptions = {}
|
|
81
|
+
): Promise<ExecutionResult> {
|
|
82
|
+
const startedAt = new Date();
|
|
83
|
+
|
|
84
|
+
if (!isNodeModulesPresent(suiteDir)) {
|
|
85
|
+
await execa('npm', ['install', '--prefer-offline'], { cwd: suiteDir });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const args = ['cypress', 'run', '--reporter=json',
|
|
89
|
+
...(options.headed ? ['--headed'] : ['--headless']),
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
let stdout = '';
|
|
93
|
+
let stderr = '';
|
|
94
|
+
let exitCode = 0;
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const result = await execa('npx', args, {
|
|
98
|
+
cwd: suiteDir,
|
|
99
|
+
env: { ...process.env, ...(options.env ?? {}) },
|
|
100
|
+
timeout: options.timeout ?? 600_000,
|
|
101
|
+
reject: false,
|
|
102
|
+
});
|
|
103
|
+
stdout = result.stdout;
|
|
104
|
+
stderr = result.stderr;
|
|
105
|
+
exitCode = result.exitCode ?? 1;
|
|
106
|
+
} catch (err) {
|
|
107
|
+
const e = err as ExecaError;
|
|
108
|
+
stdout = e.stdout ?? '';
|
|
109
|
+
stderr = e.stderr ?? e.message;
|
|
110
|
+
exitCode = e.exitCode ?? 1;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const finishedAt = new Date();
|
|
114
|
+
return { exitCode, results: parseCypressJson(stdout), startedAt, finishedAt, stdout, stderr };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ─── Parsers ─────────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
function parsePlaywrightJson(stdout: string): TestResultItem[] {
|
|
120
|
+
try {
|
|
121
|
+
// Playwright --reporter=json outputs JSON on stdout
|
|
122
|
+
const json = extractJson(stdout);
|
|
123
|
+
if (!json) return [];
|
|
124
|
+
const suites = json.suites ?? [];
|
|
125
|
+
const items: TestResultItem[] = [];
|
|
126
|
+
collectPlaywrightSpecs(suites, items);
|
|
127
|
+
return items;
|
|
128
|
+
} catch {
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function collectPlaywrightSpecs(suites: any[], items: TestResultItem[]): void {
|
|
134
|
+
for (const suite of suites) {
|
|
135
|
+
for (const spec of suite.specs ?? []) {
|
|
136
|
+
for (const test of spec.tests ?? []) {
|
|
137
|
+
const result = test.results?.[0];
|
|
138
|
+
items.push({
|
|
139
|
+
name: spec.title,
|
|
140
|
+
status: result?.status === 'passed' ? 'pass' : result?.status === 'skipped' ? 'skip' : 'fail',
|
|
141
|
+
duration_ms: result?.duration ?? 0,
|
|
142
|
+
error: result?.error?.message ?? null,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
collectPlaywrightSpecs(suite.suites ?? [], items);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function parseCypressJson(stdout: string): TestResultItem[] {
|
|
151
|
+
try {
|
|
152
|
+
const json = extractJson(stdout);
|
|
153
|
+
if (!json) return [];
|
|
154
|
+
return (json.results ?? []).flatMap((r: any) =>
|
|
155
|
+
(r.tests ?? []).map((t: any) => ({
|
|
156
|
+
name: t.fullTitle ?? t.title,
|
|
157
|
+
status: t.state === 'passed' ? 'pass' : t.state === 'pending' ? 'skip' : 'fail',
|
|
158
|
+
duration_ms: t.duration ?? 0,
|
|
159
|
+
error: t.err?.message ?? null,
|
|
160
|
+
}))
|
|
161
|
+
);
|
|
162
|
+
} catch {
|
|
163
|
+
return [];
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function extractJson(text: string): any | null {
|
|
168
|
+
const match = text.match(/\{[\s\S]*\}/);
|
|
169
|
+
if (!match) return null;
|
|
170
|
+
return JSON.parse(match[0]);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function isNodeModulesPresent(dir: string): boolean {
|
|
174
|
+
return existsSync(path.join(dir, 'node_modules'));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Execute a Maestro test suite (iOS/Android YAML flows) and parse JUnit XML results.
|
|
179
|
+
* Requires: Maestro CLI installed globally (`curl -Ls 'https://get.maestro.mobile.dev' | bash`)
|
|
180
|
+
* No npm install step — Maestro uses its own runtime.
|
|
181
|
+
*/
|
|
182
|
+
export async function executeMaestro(
|
|
183
|
+
suiteDir: string,
|
|
184
|
+
options: ExecutionOptions = {}
|
|
185
|
+
): Promise<ExecutionResult> {
|
|
186
|
+
const startedAt = new Date();
|
|
187
|
+
const reportPath = path.join(suiteDir, 'report.xml');
|
|
188
|
+
const flowsDir = path.join(suiteDir, 'flows');
|
|
189
|
+
|
|
190
|
+
const args = [
|
|
191
|
+
'test',
|
|
192
|
+
'--format', 'junit',
|
|
193
|
+
'--output', reportPath,
|
|
194
|
+
existsSync(flowsDir) ? flowsDir : suiteDir,
|
|
195
|
+
];
|
|
196
|
+
|
|
197
|
+
let stdout = '';
|
|
198
|
+
let stderr = '';
|
|
199
|
+
let exitCode = 0;
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
const proc = await execa('maestro', args, {
|
|
203
|
+
cwd: suiteDir,
|
|
204
|
+
env: { ...process.env, ...(options.env ?? {}) },
|
|
205
|
+
reject: false,
|
|
206
|
+
timeout: options.timeout ?? 10 * 60 * 1000,
|
|
207
|
+
});
|
|
208
|
+
stdout = proc.stdout ?? '';
|
|
209
|
+
stderr = proc.stderr ?? '';
|
|
210
|
+
exitCode = proc.exitCode ?? 0;
|
|
211
|
+
} catch (err) {
|
|
212
|
+
const execaErr = err as ExecaError;
|
|
213
|
+
stdout = execaErr.stdout ?? '';
|
|
214
|
+
stderr = execaErr.stderr ?? '';
|
|
215
|
+
exitCode = execaErr.exitCode ?? 1;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const finishedAt = new Date();
|
|
219
|
+
const results = parseMaestroXml(reportPath);
|
|
220
|
+
|
|
221
|
+
return { exitCode, results, startedAt, finishedAt, stdout, stderr };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Parse Maestro JUnit XML report into TestResultItem[].
|
|
226
|
+
* Format: <testsuite><testcase name="..." time="2.3"><failure message="..."/></testcase></testsuite>
|
|
227
|
+
*/
|
|
228
|
+
function parseMaestroXml(reportPath: string): TestResultItem[] {
|
|
229
|
+
if (!existsSync(reportPath)) return [];
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
const { XMLParser } = require('fast-xml-parser');
|
|
233
|
+
const { readFileSync } = require('fs');
|
|
234
|
+
const xml = readFileSync(reportPath, 'utf-8');
|
|
235
|
+
const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_' });
|
|
236
|
+
const doc = parser.parse(xml);
|
|
237
|
+
|
|
238
|
+
// Support both <testsuites> (wrapper) and bare <testsuite>
|
|
239
|
+
const suites = doc?.testsuites?.testsuite ?? doc?.testsuite;
|
|
240
|
+
const suiteArray = Array.isArray(suites) ? suites : suites ? [suites] : [];
|
|
241
|
+
|
|
242
|
+
const items: TestResultItem[] = [];
|
|
243
|
+
|
|
244
|
+
for (const suite of suiteArray) {
|
|
245
|
+
const testcases = suite?.testcase;
|
|
246
|
+
const cases = Array.isArray(testcases) ? testcases : testcases ? [testcases] : [];
|
|
247
|
+
|
|
248
|
+
for (const tc of cases) {
|
|
249
|
+
const name: string = tc['@_name'] ?? tc['@_classname'] ?? 'unknown';
|
|
250
|
+
const timeStr: string = tc['@_time'] ?? '0';
|
|
251
|
+
const duration_ms = Math.round(parseFloat(timeStr) * 1000);
|
|
252
|
+
|
|
253
|
+
const hasFailure = tc.failure !== undefined;
|
|
254
|
+
const hasSkipped = tc.skipped !== undefined;
|
|
255
|
+
const failureMsg: string | null =
|
|
256
|
+
typeof tc.failure === 'object'
|
|
257
|
+
? tc.failure['@_message'] ?? null
|
|
258
|
+
: typeof tc.failure === 'string'
|
|
259
|
+
? tc.failure
|
|
260
|
+
: null;
|
|
261
|
+
|
|
262
|
+
items.push({
|
|
263
|
+
name,
|
|
264
|
+
status: hasFailure ? 'fail' : hasSkipped ? 'skip' : 'pass',
|
|
265
|
+
duration_ms,
|
|
266
|
+
error: hasFailure ? failureMsg : null,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return items;
|
|
272
|
+
} catch {
|
|
273
|
+
return [];
|
|
274
|
+
}
|
|
275
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// @flowspec-qa/runner-core — public API
|
|
2
|
+
export { config, type RunnerConfig } from './config.js';
|
|
3
|
+
export { login, logout, getAuthStatus, getBearerToken, type AuthStatus } from './auth.js';
|
|
4
|
+
export { apiClient, FlowSpecQAApi, type Project, type Suite, type RunResultPayload, type TestResultItem } from './api-client.js';
|
|
5
|
+
export { downloadSuite, listCachedSuites, evictSuite, type LocalSuite } from './suite-manager.js';
|
|
6
|
+
export { executePlaywright, executeCypress, executeMaestro, type ExecutionOptions, type ExecutionResult } from './executor.js';
|
|
7
|
+
export { sanitizeResults, assertNoSecrets } from './result-sanitizer.js';
|
|
8
|
+
export { uploadResults, type UploadOptions } from './uploader.js';
|