@scantrix/cli 1.0.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,261 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.evaluateNumericExpression = evaluateNumericExpression;
7
+ exports.extractPlaywrightConfig = extractPlaywrightConfig;
8
+ const promises_1 = __importDefault(require("fs/promises"));
9
+ const path_1 = __importDefault(require("path"));
10
+ const astConfigParser_1 = require("./astConfigParser");
11
+ // small helpers
12
+ function matchFirst(content, re) {
13
+ const m = content.match(re);
14
+ return m?.[1];
15
+ }
16
+ /**
17
+ * Evaluate common numeric expressions found in Playwright configs.
18
+ *
19
+ * Handles:
20
+ * - Plain integers: 60000
21
+ * - Underscore notation: 60_000
22
+ * - Scientific notation: 6e4
23
+ * - Simple multiplication: 60 * 1000, 1000 * 60, 2 * 60 * 1000
24
+ *
25
+ * Does NOT handle:
26
+ * - Ternary expressions: process.env.CI ? 30000 : 60000
27
+ * - Variable references: MY_TIMEOUT
28
+ * - Function calls: getTimeout()
29
+ * - Template literals or string concatenation
30
+ *
31
+ * Returns undefined for expressions it cannot evaluate.
32
+ */
33
+ function evaluateNumericExpression(expr) {
34
+ if (!expr)
35
+ return undefined;
36
+ const cleaned = expr
37
+ .trim()
38
+ .replace(/_/g, "") // 60_000 -> 60000
39
+ .replace(/\s+/g, " "); // normalise whitespace
40
+ // Direct number (handles 60000, 6e4, 0x... etc.)
41
+ const direct = Number(cleaned);
42
+ if (Number.isFinite(direct))
43
+ return direct;
44
+ // Simple multiplication chain: N * M [ * P ... ]
45
+ const parts = cleaned.split(/\s*\*\s*/);
46
+ if (parts.length >= 2 && parts.every((p) => /^\d+(?:\.\d+)?(?:e\d+)?$/.test(p))) {
47
+ const result = parts.reduce((acc, p) => acc * Number(p), 1);
48
+ if (Number.isFinite(result))
49
+ return result;
50
+ }
51
+ return undefined;
52
+ }
53
+ // very small “object block” extraction: finds `key: { ... }` and returns {...} as text
54
+ function extractBlock(content, key) {
55
+ const idx = content.indexOf(`${key}:`);
56
+ if (idx < 0)
57
+ return undefined;
58
+ const braceStart = content.indexOf("{", idx);
59
+ if (braceStart < 0)
60
+ return undefined;
61
+ let depth = 0;
62
+ for (let i = braceStart; i < content.length; i++) {
63
+ const ch = content[i];
64
+ if (ch === "{")
65
+ depth++;
66
+ if (ch === "}")
67
+ depth--;
68
+ if (depth === 0) {
69
+ return content.slice(braceStart, i + 1);
70
+ }
71
+ }
72
+ return undefined;
73
+ }
74
+ function extractArrayBlock(content, key) {
75
+ const idx = content.indexOf(`${key}:`);
76
+ if (idx < 0)
77
+ return undefined;
78
+ const arrStart = content.indexOf("[", idx);
79
+ if (arrStart < 0)
80
+ return undefined;
81
+ let depth = 0;
82
+ for (let i = arrStart; i < content.length; i++) {
83
+ const ch = content[i];
84
+ if (ch === "[")
85
+ depth++;
86
+ if (ch === "]")
87
+ depth--;
88
+ if (depth === 0) {
89
+ return content.slice(arrStart, i + 1);
90
+ }
91
+ }
92
+ return undefined;
93
+ }
94
+ function parseLaunchArgs(useBlock) {
95
+ if (!useBlock)
96
+ return undefined;
97
+ const launchBlock = extractBlock(useBlock, "launchOptions");
98
+ if (!launchBlock)
99
+ return undefined;
100
+ // args: ['--a','--b']
101
+ const m = launchBlock.match(/\bargs\s*:\s*\[([\s\S]*?)\]/);
102
+ if (!m)
103
+ return undefined;
104
+ const inside = m[1];
105
+ const args = [...inside.matchAll(/['"`]([^'"`]+)['"`]/g)].map((x) => x[1]);
106
+ return args.length ? args : undefined;
107
+ }
108
+ function parseReporters(content) {
109
+ // We only need to detect common reporters + key fields.
110
+ // Find occurrences like ['html', { outputFolder: 'playwright-report', open: 'never' }]
111
+ const reporters = [];
112
+ // html
113
+ const htmlMatches = [...content.matchAll(/\[\s*['"`]html['"`]\s*,\s*\{([\s\S]*?)\}\s*\]/g)];
114
+ for (const m of htmlMatches) {
115
+ const block = m[1];
116
+ const outputFolder = matchFirst(block, /\boutputFolder\s*:\s*['"`]([^'"`]+)['"`]/);
117
+ const open = matchFirst(block, /\bopen\s*:\s*['"`]([^'"`]+)['"`]/);
118
+ reporters.push({ name: "html", details: { outputFolder, open } });
119
+ }
120
+ // junit
121
+ const junitMatches = [...content.matchAll(/\[\s*['"`]junit['"`]\s*,\s*\{([\s\S]*?)\}\s*\]/g)];
122
+ for (const m of junitMatches) {
123
+ const block = m[1];
124
+ const outputFile = matchFirst(block, /\boutputFile\s*:\s*['"`]([^'"`]+)['"`]/);
125
+ reporters.push({ name: "junit", details: { outputFile } });
126
+ }
127
+ // list
128
+ if (/\[\s*['"`]list['"`]\s*\]/.test(content) || /['"`]list['"`]/.test(content)) {
129
+ reporters.push({ name: "list" });
130
+ }
131
+ // azure reporter
132
+ if (/@alex_neo\/playwright-azure-reporter/.test(content)) {
133
+ const orgUrl = matchFirst(content, /\borgUrl\s*:\s*['"`]([^'"`]+)['"`]/);
134
+ const projectName = matchFirst(content, /\bprojectName\s*:\s*['"`]([^'"`]+)['"`]/);
135
+ const uploadAttachments = /uploadAttachments\s*:\s*true/.test(content);
136
+ const attachmentsType = [...content.matchAll(/\battachmentsType\s*:\s*\[([\s\S]*?)\]/g)]
137
+ .flatMap((m) => [...m[1].matchAll(/['"`]([^'"`]+)['"`]/g)].map((x) => x[1]));
138
+ reporters.push({
139
+ name: "@alex_neo/playwright-azure-reporter",
140
+ details: {
141
+ orgUrl,
142
+ projectName,
143
+ uploadAttachments,
144
+ attachmentsType: attachmentsType.length ? attachmentsType : undefined,
145
+ },
146
+ });
147
+ }
148
+ return reporters.length ? reporters : undefined;
149
+ }
150
+ function parseProjects(projectsBlock) {
151
+ if (!projectsBlock)
152
+ return undefined;
153
+ const projects = [];
154
+ // very lightweight: find "name: '...'" occurrences and nearby grep/browserName/isMobile text
155
+ const entries = projectsBlock.split(/\}\s*,\s*\{/g).map((s) => s.replace(/^\[/, "{").replace(/\]$/, "}"));
156
+ for (const entry of entries) {
157
+ const name = matchFirst(entry, /\bname\s*:\s*['"`]([^'"`]+)['"`]/);
158
+ if (!name)
159
+ continue;
160
+ const grep = matchFirst(entry, /\bgrep\s*:\s*(\/[^\/]+\/[gimsuy]*)/);
161
+ const browserName = matchFirst(entry, /\bbrowserName\s*:\s*['"`]([^'"`]+)['"`]/);
162
+ const isMobile = /\bisMobile\s*:\s*true/.test(entry) ? true : /\bisMobile\s*:\s*false/.test(entry) ? false : undefined;
163
+ projects.push({ name, grep, browserName, isMobile });
164
+ }
165
+ return projects.length ? projects : undefined;
166
+ }
167
+ async function extractPlaywrightConfig(repoPath,
168
+ /** Optional direct path to the config file (skips root-level search). */
169
+ configFilePath) {
170
+ // Find config file — prefer the explicitly provided path, then search repo root
171
+ let configPath = configFilePath;
172
+ if (!configPath) {
173
+ const candidates = [
174
+ path_1.default.join(repoPath, "playwright.config.ts"),
175
+ path_1.default.join(repoPath, "playwright.config.js"),
176
+ path_1.default.join(repoPath, "playwright.config.mjs"),
177
+ path_1.default.join(repoPath, "playwright.config.cjs"),
178
+ ];
179
+ for (const c of candidates) {
180
+ try {
181
+ await promises_1.default.access(c);
182
+ configPath = c;
183
+ break;
184
+ }
185
+ catch {
186
+ // ignore
187
+ }
188
+ }
189
+ }
190
+ if (!configPath)
191
+ return undefined;
192
+ const content = await promises_1.default.readFile(configPath, "utf8");
193
+ // ── Strategy 1: AST-based parsing (handles computed values, spreads, etc.) ──
194
+ const astResult = (0, astConfigParser_1.parseConfigWithAst)(configPath, content);
195
+ if (astResult) {
196
+ // Augment with env hints (regex-based, cheap, and AST doesn't do these)
197
+ astResult.envHints = {
198
+ headlessEnvVar: content.includes("HEADLESS_MODE") ? "HEADLESS_MODE" : undefined,
199
+ workersEnvVar: content.includes("process.env.WORKERS") ? "WORKERS" : undefined,
200
+ ciEnvVar: content.includes("process.env.CI") ? "CI" : undefined,
201
+ azureTokenEnvVar: content.includes("AZURE_TOKEN") ? "AZURE_TOKEN" : undefined,
202
+ planIdEnvVar: content.includes("PLAN_ID") ? "PLAN_ID" : undefined,
203
+ };
204
+ // Augment with reporters if AST didn't capture them well (complex array syntax)
205
+ if (!astResult.reporters?.length) {
206
+ astResult.reporters = parseReporters(content);
207
+ }
208
+ return astResult;
209
+ }
210
+ // ── Strategy 2: Regex fallback (legacy, handles simpler configs) ──
211
+ const summary = { configPath };
212
+ // top-level
213
+ summary.testDir = matchFirst(content, /\btestDir\s*:\s*['"`]([^'"`]+)['"`]/);
214
+ // Timeout: extract the raw expression after `timeout:` and evaluate it.
215
+ // Supports: 60000, 60_000, 6e4, 60 * 1000, 1000 * 60, 2 * 60 * 1000.
216
+ const rawTimeout = matchFirst(content, /(?<![.\w])timeout\s*:\s*([^,\n{}]+)/);
217
+ summary.timeoutMs = evaluateNumericExpression(rawTimeout);
218
+ // expect.timeout (often written as 20 * 1000)
219
+ const expectBlock = extractBlock(content, "expect");
220
+ if (expectBlock) {
221
+ const rawExpectTimeout = matchFirst(expectBlock, /\btimeout\s*:\s*([^,\n{}]+)/);
222
+ summary.expectTimeoutMs = evaluateNumericExpression(rawExpectTimeout);
223
+ }
224
+ summary.workers = matchFirst(content, /\bworkers\s*:\s*([^,\n]+)/)?.trim();
225
+ summary.retries = matchFirst(content, /\bretries\s*:\s*([^,\n]+)/)?.trim();
226
+ summary.globalSetup = matchFirst(content, /\bglobalSetup\s*:\s*require\.resolve\(\s*['"`]([^'"`]+)['"`]\s*\)/);
227
+ // use block
228
+ const useBlock = extractBlock(content, "use");
229
+ if (useBlock) {
230
+ const headlessExpr = matchFirst(useBlock, /\bheadless\s*:\s*([^,\n]+)/)?.trim();
231
+ const rawActionTimeout = matchFirst(useBlock, /\bactionTimeout\s*:\s*([^,\n{}]+)/);
232
+ const traceExpr = matchFirst(useBlock, /\btrace\s*:\s*([^,\n]+)/)?.trim();
233
+ const videoExpr = matchFirst(useBlock, /\bvideo\s*:\s*['"`]([^'"`]+)['"`]/);
234
+ const screenshotExpr = matchFirst(useBlock, /\bscreenshot\s*:\s*['"`]([^'"`]+)['"`]/);
235
+ const ignoreHttps = /\bignoreHTTPSErrors\s*:\s*true/.test(useBlock) ? true : /\bignoreHTTPSErrors\s*:\s*false/.test(useBlock) ? false : undefined;
236
+ const launchArgs = parseLaunchArgs(useBlock);
237
+ const baseURLExpr = matchFirst(useBlock, /\bbaseURL\s*:\s*(['"`][^'"`]+['"`]|[^,\n]+)/)?.trim();
238
+ summary.use = {
239
+ headless: headlessExpr,
240
+ actionTimeoutMs: evaluateNumericExpression(rawActionTimeout),
241
+ trace: traceExpr,
242
+ video: videoExpr,
243
+ screenshot: screenshotExpr,
244
+ ignoreHTTPSErrors: ignoreHttps,
245
+ launchArgs,
246
+ baseURL: baseURLExpr,
247
+ };
248
+ }
249
+ // reporters + projects
250
+ summary.reporters = parseReporters(content);
251
+ summary.projects = parseProjects(extractArrayBlock(content, "projects"));
252
+ // env hinting (we can detect usage by simple matches)
253
+ summary.envHints = {
254
+ headlessEnvVar: content.includes("HEADLESS_MODE") ? "HEADLESS_MODE" : undefined,
255
+ workersEnvVar: content.includes("process.env.WORKERS") ? "WORKERS" : undefined,
256
+ ciEnvVar: content.includes("process.env.CI") ? "CI" : undefined,
257
+ azureTokenEnvVar: content.includes("AZURE_TOKEN") ? "AZURE_TOKEN" : undefined,
258
+ planIdEnvVar: content.includes("PLAN_ID") ? "PLAN_ID" : undefined,
259
+ };
260
+ return summary;
261
+ }
@@ -0,0 +1,217 @@
1
+ "use strict";
2
+ /**
3
+ * Cypress configuration extractor.
4
+ *
5
+ * Detects Cypress projects and parses their configuration from:
6
+ * - cypress.config.ts / cypress.config.js (Cypress 10+)
7
+ * - cypress.json (Cypress <10, legacy)
8
+ *
9
+ * Also detects the Cypress directory structure (cypress/e2e, cypress/integration).
10
+ */
11
+ var __importDefault = (this && this.__importDefault) || function (mod) {
12
+ return (mod && mod.__esModule) ? mod : { "default": mod };
13
+ };
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.extractCypressConfig = extractCypressConfig;
16
+ const promises_1 = __importDefault(require("fs/promises"));
17
+ const path_1 = __importDefault(require("path"));
18
+ const typescript_1 = __importDefault(require("typescript"));
19
+ async function extractCypressConfig(repoPath) {
20
+ // Priority order: TS > JS > JSON
21
+ const candidates = [
22
+ { file: "cypress.config.ts", format: "ts" },
23
+ { file: "cypress.config.js", format: "js" },
24
+ { file: "cypress.config.mjs", format: "js" },
25
+ { file: "cypress.json", format: "json" },
26
+ ];
27
+ let configPath;
28
+ let configFormat = "ts";
29
+ for (const c of candidates) {
30
+ const full = path_1.default.join(repoPath, c.file);
31
+ try {
32
+ await promises_1.default.access(full);
33
+ configPath = full;
34
+ configFormat = c.format;
35
+ break;
36
+ }
37
+ catch {
38
+ // not found
39
+ }
40
+ }
41
+ if (!configPath) {
42
+ // Also check if cypress/ directory exists without config (very old projects)
43
+ try {
44
+ const cypressDir = path_1.default.join(repoPath, "cypress");
45
+ const stat = await promises_1.default.stat(cypressDir);
46
+ if (stat.isDirectory()) {
47
+ return {
48
+ configPath: cypressDir,
49
+ configFormat: "json",
50
+ testDir: "cypress",
51
+ };
52
+ }
53
+ }
54
+ catch {
55
+ // no cypress directory either
56
+ }
57
+ return undefined;
58
+ }
59
+ const content = await promises_1.default.readFile(configPath, "utf8");
60
+ if (configFormat === "json") {
61
+ return parseJsonConfig(configPath, content);
62
+ }
63
+ return parseTsJsConfig(configPath, content, configFormat);
64
+ }
65
+ // ─── JSON Config (Cypress <10) ───────────────────────────────────────
66
+ function parseJsonConfig(configPath, content) {
67
+ try {
68
+ const json = JSON.parse(content);
69
+ return {
70
+ configPath,
71
+ configFormat: "json",
72
+ baseUrl: json.baseUrl,
73
+ defaultCommandTimeout: asNum(json.defaultCommandTimeout),
74
+ requestTimeout: asNum(json.requestTimeout),
75
+ responseTimeout: asNum(json.responseTimeout),
76
+ pageLoadTimeout: asNum(json.pageLoadTimeout),
77
+ viewportWidth: asNum(json.viewportWidth),
78
+ viewportHeight: asNum(json.viewportHeight),
79
+ video: asBool(json.video),
80
+ screenshotOnRunFailure: asBool(json.screenshotOnRunFailure),
81
+ retries: parseRetries(json.retries),
82
+ testDir: json.integrationFolder ?? "cypress/integration",
83
+ };
84
+ }
85
+ catch {
86
+ return undefined;
87
+ }
88
+ }
89
+ // ─── TS/JS Config (Cypress 10+) ─────────────────────────────────────
90
+ function parseTsJsConfig(configPath, content, format) {
91
+ try {
92
+ const sourceFile = typescript_1.default.createSourceFile(configPath, content, typescript_1.default.ScriptTarget.Latest, true);
93
+ const configObj = findDefineConfigOrDefault(sourceFile);
94
+ if (!configObj)
95
+ return undefined;
96
+ const raw = evalObj(configObj);
97
+ // Cypress 10+ configs nest most settings under `e2e:` or `component:`
98
+ const e2e = raw.e2e ?? raw;
99
+ return {
100
+ configPath,
101
+ configFormat: format,
102
+ baseUrl: asStr(e2e.baseUrl),
103
+ specPattern: asStr(e2e.specPattern),
104
+ defaultCommandTimeout: asNum(e2e.defaultCommandTimeout),
105
+ requestTimeout: asNum(e2e.requestTimeout),
106
+ responseTimeout: asNum(e2e.responseTimeout),
107
+ pageLoadTimeout: asNum(e2e.pageLoadTimeout),
108
+ viewportWidth: asNum(e2e.viewportWidth ?? raw.viewportWidth),
109
+ viewportHeight: asNum(e2e.viewportHeight ?? raw.viewportHeight),
110
+ video: asBool(e2e.video ?? raw.video),
111
+ screenshotOnRunFailure: asBool(e2e.screenshotOnRunFailure ?? raw.screenshotOnRunFailure),
112
+ retries: parseRetries(e2e.retries ?? raw.retries),
113
+ testDir: asStr(e2e.specPattern)
114
+ ? path_1.default.dirname(asStr(e2e.specPattern).split("*")[0]) || "cypress/e2e"
115
+ : "cypress/e2e",
116
+ };
117
+ }
118
+ catch {
119
+ return undefined;
120
+ }
121
+ }
122
+ function findDefineConfigOrDefault(sourceFile) {
123
+ let result;
124
+ function visit(node) {
125
+ if (result)
126
+ return;
127
+ if (typescript_1.default.isCallExpression(node) &&
128
+ typescript_1.default.isIdentifier(node.expression) &&
129
+ node.expression.text === "defineConfig" &&
130
+ node.arguments.length >= 1 &&
131
+ typescript_1.default.isObjectLiteralExpression(node.arguments[0])) {
132
+ result = node.arguments[0];
133
+ return;
134
+ }
135
+ if (typescript_1.default.isExportAssignment(node) && !node.isExportEquals) {
136
+ let expr = node.expression;
137
+ if (typescript_1.default.isAsExpression(expr))
138
+ expr = expr.expression;
139
+ if (typescript_1.default.isObjectLiteralExpression(expr)) {
140
+ result = expr;
141
+ return;
142
+ }
143
+ }
144
+ typescript_1.default.forEachChild(node, visit);
145
+ }
146
+ visit(sourceFile);
147
+ return result;
148
+ }
149
+ // ─── Lightweight AST evaluator ───────────────────────────────────────
150
+ function evalNode(node) {
151
+ if (typescript_1.default.isNumericLiteral(node))
152
+ return Number(node.text);
153
+ if (typescript_1.default.isStringLiteral(node))
154
+ return node.text;
155
+ if (node.kind === typescript_1.default.SyntaxKind.TrueKeyword)
156
+ return true;
157
+ if (node.kind === typescript_1.default.SyntaxKind.FalseKeyword)
158
+ return false;
159
+ if (typescript_1.default.isObjectLiteralExpression(node))
160
+ return evalObj(node);
161
+ if (typescript_1.default.isArrayLiteralExpression(node))
162
+ return node.elements.map(evalNode);
163
+ if (typescript_1.default.isBinaryExpression(node)) {
164
+ const l = evalNode(node.left);
165
+ const r = evalNode(node.right);
166
+ if (typeof l === "number" && typeof r === "number") {
167
+ if (node.operatorToken.kind === typescript_1.default.SyntaxKind.AsteriskToken)
168
+ return l * r;
169
+ if (node.operatorToken.kind === typescript_1.default.SyntaxKind.PlusToken)
170
+ return l + r;
171
+ }
172
+ return node.getText();
173
+ }
174
+ if (typescript_1.default.isConditionalExpression(node))
175
+ return node.getText();
176
+ if (typescript_1.default.isPropertyAccessExpression(node))
177
+ return node.getText();
178
+ if (typescript_1.default.isParenthesizedExpression(node))
179
+ return evalNode(node.expression);
180
+ return node.getText();
181
+ }
182
+ function evalObj(node) {
183
+ const result = {};
184
+ for (const prop of node.properties) {
185
+ if (typescript_1.default.isPropertyAssignment(prop) && typescript_1.default.isIdentifier(prop.name)) {
186
+ result[prop.name.text] = evalNode(prop.initializer);
187
+ }
188
+ }
189
+ return result;
190
+ }
191
+ // ─── Helpers ─────────────────────────────────────────────────────────
192
+ function asNum(v) {
193
+ if (typeof v === "number" && Number.isFinite(v))
194
+ return v;
195
+ return undefined;
196
+ }
197
+ function asStr(v) {
198
+ if (typeof v === "string")
199
+ return v;
200
+ return undefined;
201
+ }
202
+ function asBool(v) {
203
+ if (typeof v === "boolean")
204
+ return v;
205
+ return undefined;
206
+ }
207
+ function parseRetries(v) {
208
+ if (typeof v === "number")
209
+ return { runMode: v, openMode: 0 };
210
+ if (v && typeof v === "object") {
211
+ return {
212
+ runMode: asNum(v.runMode),
213
+ openMode: asNum(v.openMode),
214
+ };
215
+ }
216
+ return undefined;
217
+ }