@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.
@@ -0,0 +1,60 @@
1
+ import { config } from './config.js';
2
+ import type { TestResultItem } from './api-client.js';
3
+
4
+ /**
5
+ * Sanitize test results before uploading to FlowSpecQA SaaS.
6
+ *
7
+ * Strips:
8
+ * - HTTP/HTTPS URLs (base URLs of the client's environments)
9
+ * - IP addresses
10
+ * - Authorization / Bearer tokens
11
+ * - Password patterns (key=value)
12
+ * - Anything matching custom patterns in config
13
+ *
14
+ * The test name, status, and duration are NEVER modified.
15
+ * Only `error` messages are sanitized.
16
+ */
17
+ export function sanitizeResults(results: TestResultItem[]): TestResultItem[] {
18
+ const patterns = config.get('sanitizerPatterns').map((p) => new RegExp(p, 'gi'));
19
+
20
+ return results.map((r) => ({
21
+ ...r,
22
+ error: r.error ? sanitizeString(r.error, patterns) : null,
23
+ }));
24
+ }
25
+
26
+ function sanitizeString(text: string, patterns: RegExp[]): string {
27
+ let sanitized = text;
28
+ for (const pattern of patterns) {
29
+ sanitized = sanitized.replace(pattern, '[REDACTED]');
30
+ }
31
+ // Additional pass: truncate very long error messages to 1500 chars
32
+ if (sanitized.length > 1500) {
33
+ sanitized = sanitized.slice(0, 1500) + '... [truncated]';
34
+ }
35
+ return sanitized;
36
+ }
37
+
38
+ /**
39
+ * Validate that no obvious secrets leaked through (belt-and-suspenders check).
40
+ * Throws if any result still contains a pattern that looks like a secret.
41
+ */
42
+ export function assertNoSecrets(results: TestResultItem[]): void {
43
+ const dangerPatterns = [
44
+ /https?:\/\/[a-z0-9.-]+\.(internal|local|corp|dev|staging|test)[/:]?/i,
45
+ /Bearer\s+[A-Za-z0-9.\-_]{20,}/,
46
+ /\bpassword\b/i,
47
+ ];
48
+
49
+ for (const r of results) {
50
+ if (!r.error) continue;
51
+ for (const p of dangerPatterns) {
52
+ if (p.test(r.error)) {
53
+ throw new Error(
54
+ `Security check failed: error message for test "${r.name}" may contain sensitive data. ` +
55
+ `Run with --skip-security-check to bypass (not recommended).`
56
+ );
57
+ }
58
+ }
59
+ }
60
+ }
@@ -0,0 +1,73 @@
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
+ export interface LocalSuite {
8
+ runId: string;
9
+ entityId: string;
10
+ framework: 'playwright' | 'cypress' | 'maestro';
11
+ suiteDir: string;
12
+ downloadedAt: Date;
13
+ }
14
+
15
+ /**
16
+ * Download a suite ZIP from FlowSpecQA SaaS and unzip it to the local cache.
17
+ * Returns the path to the unzipped suite directory.
18
+ */
19
+ export async function downloadSuite(suiteId: string, entityId: string, framework: string): Promise<LocalSuite> {
20
+ const cacheDir = config.get('suiteCacheDir');
21
+ const suiteDir = path.join(cacheDir, entityId, suiteId);
22
+
23
+ // Avoid re-downloading if already cached
24
+ if (fs.existsSync(path.join(suiteDir, 'package.json'))) {
25
+ return {
26
+ runId: suiteId,
27
+ entityId,
28
+ framework: framework as LocalSuite['framework'],
29
+ suiteDir,
30
+ downloadedAt: fs.statSync(suiteDir).mtime,
31
+ };
32
+ }
33
+
34
+ const zipBuffer = await FlowSpecQAApi.downloadSuiteZip(suiteId);
35
+ fs.mkdirSync(suiteDir, { recursive: true });
36
+
37
+ const zip = new AdmZip(zipBuffer);
38
+ zip.extractAllTo(suiteDir, /* overwrite */ true);
39
+
40
+ return {
41
+ runId: suiteId,
42
+ entityId,
43
+ framework: framework as LocalSuite['framework'],
44
+ suiteDir,
45
+ downloadedAt: new Date(),
46
+ };
47
+ }
48
+
49
+ /**
50
+ * List all locally cached suites.
51
+ */
52
+ export function listCachedSuites(): Array<{ entityId: string; runId: string; suiteDir: string }> {
53
+ const cacheDir = config.get('suiteCacheDir');
54
+ if (!fs.existsSync(cacheDir)) return [];
55
+
56
+ const result: Array<{ entityId: string; runId: string; suiteDir: string }> = [];
57
+ for (const entityId of fs.readdirSync(cacheDir)) {
58
+ const entityDir = path.join(cacheDir, entityId);
59
+ if (!fs.statSync(entityDir).isDirectory()) continue;
60
+ for (const runId of fs.readdirSync(entityDir)) {
61
+ result.push({ entityId, runId, suiteDir: path.join(entityDir, runId) });
62
+ }
63
+ }
64
+ return result;
65
+ }
66
+
67
+ /**
68
+ * Remove a cached suite directory.
69
+ */
70
+ export function evictSuite(entityId: string, runId: string): void {
71
+ const suiteDir = path.join(config.get('suiteCacheDir'), entityId, runId);
72
+ fs.rmSync(suiteDir, { recursive: true, force: true });
73
+ }
@@ -0,0 +1,45 @@
1
+ import { FlowSpecQAApi, type RunResultPayload } from './api-client.js';
2
+ import { sanitizeResults, assertNoSecrets } from './result-sanitizer.js';
3
+ import type { ExecutionResult } from './executor.js';
4
+
5
+ const RUNNER_VERSION = '0.1.0';
6
+
7
+ export interface UploadOptions {
8
+ runId: string;
9
+ entityId: string;
10
+ framework: string;
11
+ envLabel: string;
12
+ skipSecurityCheck?: boolean;
13
+ }
14
+
15
+ /**
16
+ * Sanitize + upload test results to FlowSpecQA SaaS.
17
+ * Returns the exec_id assigned by the server.
18
+ */
19
+ export async function uploadResults(
20
+ execution: ExecutionResult,
21
+ options: UploadOptions
22
+ ): Promise<{ execId: string }> {
23
+ // Step 1: Sanitize — strip URLs, IPs, tokens from error messages
24
+ const sanitized = sanitizeResults(execution.results);
25
+
26
+ // Step 2: Belt-and-suspenders security check
27
+ if (!options.skipSecurityCheck) {
28
+ assertNoSecrets(sanitized);
29
+ }
30
+
31
+ // Step 3: Build payload
32
+ const payload: RunResultPayload = {
33
+ run_id: options.runId,
34
+ entity_id: options.entityId,
35
+ framework: options.framework,
36
+ env_label: options.envLabel,
37
+ results: sanitized,
38
+ started_at: execution.startedAt.toISOString(),
39
+ finished_at: execution.finishedAt.toISOString(),
40
+ runner_version: RUNNER_VERSION,
41
+ };
42
+
43
+ const response = await FlowSpecQAApi.submitRunResult(payload);
44
+ return { execId: response.exec_id };
45
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "dist"
6
+ },
7
+ "include": ["src/**/*"]
8
+ }