@probolabs/playwright 1.4.0-rc.6 → 1.4.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/cli.js CHANGED
@@ -1006,6 +1006,7 @@ class ProboCodeGenerator {
1006
1006
  */
1007
1007
  static async fetchScenarioData(scenarioId, apiToken, apiUrl) {
1008
1008
  const normalizedUrl = this.normalizeApiUrl(apiUrl);
1009
+ // Scenario interactions endpoint is under /api/ (special endpoint)
1009
1010
  const url = `${normalizedUrl}/api/scenarios/${scenarioId}/interactions`;
1010
1011
  const response = await fetch(url, {
1011
1012
  method: 'GET',
@@ -1040,6 +1041,54 @@ class ProboCodeGenerator {
1040
1041
  }
1041
1042
  return response.json();
1042
1043
  }
1044
+ /**
1045
+ * Fetch project data from the backend API
1046
+ */
1047
+ static async fetchProjectData(projectId, apiToken, apiUrl) {
1048
+ const normalizedUrl = this.normalizeApiUrl(apiUrl);
1049
+ // Projects endpoint is at root level, not under /api/
1050
+ const url = `${normalizedUrl}/projects/${projectId}/`;
1051
+ const response = await fetch(url, {
1052
+ method: 'GET',
1053
+ headers: {
1054
+ 'Authorization': `Token ${apiToken}`,
1055
+ 'Content-Type': 'application/json',
1056
+ },
1057
+ });
1058
+ if (!response.ok) {
1059
+ const errorText = await this.readResponseErrorText(response);
1060
+ throw new Error(`Failed to fetch project ${projectId}: ${response.status} ${errorText}`);
1061
+ }
1062
+ return response.json();
1063
+ }
1064
+ /**
1065
+ * Fetch all scenarios for a project from the backend API
1066
+ */
1067
+ static async fetchProjectScenarios(projectId, apiToken, apiUrl) {
1068
+ const normalizedUrl = this.normalizeApiUrl(apiUrl);
1069
+ // Scenarios endpoint is at root level, not under /api/
1070
+ const url = `${normalizedUrl}/scenarios/?project_id=${projectId}`;
1071
+ const response = await fetch(url, {
1072
+ method: 'GET',
1073
+ headers: {
1074
+ 'Authorization': `Token ${apiToken}`,
1075
+ 'Content-Type': 'application/json',
1076
+ },
1077
+ });
1078
+ if (!response.ok) {
1079
+ const errorText = await this.readResponseErrorText(response);
1080
+ throw new Error(`Failed to fetch scenarios for project ${projectId}: ${response.status} ${errorText}`);
1081
+ }
1082
+ const data = await response.json();
1083
+ // Handle both array and paginated responses
1084
+ if (Array.isArray(data)) {
1085
+ return data;
1086
+ }
1087
+ if (data.results && Array.isArray(data.results)) {
1088
+ return data.results;
1089
+ }
1090
+ return [];
1091
+ }
1043
1092
  /**
1044
1093
  * Convert backend interaction format to Interaction[] format
1045
1094
  */
@@ -1190,6 +1239,48 @@ class ProboCodeGenerator {
1190
1239
  scenarios: scenarioResults,
1191
1240
  };
1192
1241
  }
1242
+ /**
1243
+ * Generate code for all scenarios in a project
1244
+ * Returns map of scenario names to generated code
1245
+ */
1246
+ static async generateCodeForProject(projectId, apiToken, apiUrl, options) {
1247
+ // Validate inputs
1248
+ if (!apiToken) {
1249
+ throw new Error('API token is required');
1250
+ }
1251
+ if (!apiUrl) {
1252
+ throw new Error('API URL is required');
1253
+ }
1254
+ // Fetch project data and scenarios
1255
+ const [projectData, scenarios] = await Promise.all([
1256
+ this.fetchProjectData(projectId, apiToken, apiUrl),
1257
+ this.fetchProjectScenarios(projectId, apiToken, apiUrl),
1258
+ ]);
1259
+ if (scenarios.length === 0) {
1260
+ throw new Error(`No scenarios found in project "${projectData.name}" (ID: ${projectId})`);
1261
+ }
1262
+ // Generate code for each scenario
1263
+ const scenarioResults = await Promise.all(scenarios.map(async (scenario) => {
1264
+ try {
1265
+ const code = await this.generateCodeForScenario(scenario.id, apiToken, apiUrl, options);
1266
+ return {
1267
+ scenarioId: scenario.id,
1268
+ scenarioName: scenario.name,
1269
+ code: code,
1270
+ };
1271
+ }
1272
+ catch (error) {
1273
+ // Log error but continue with other scenarios
1274
+ console.error(`Failed to generate code for scenario ${scenario.id}: ${error.message}`);
1275
+ throw error; // Re-throw to fail fast for now
1276
+ }
1277
+ }));
1278
+ return {
1279
+ projectId: projectData.id,
1280
+ projectName: projectData.name,
1281
+ scenarios: scenarioResults,
1282
+ };
1283
+ }
1193
1284
  }
1194
1285
 
1195
1286
  const execAsync = promisify(exec);
@@ -1311,6 +1402,33 @@ async function updateRunStatus(runId, testSuiteId, updates, apiToken, apiUrl) {
1311
1402
  * TestSuiteRunner - Handles test suite file generation and execution
1312
1403
  */
1313
1404
  class TestSuiteRunner {
1405
+ /**
1406
+ * Lookup project ID by name
1407
+ */
1408
+ static async lookupProjectByName(projectName, apiToken, apiUrl) {
1409
+ const baseUrl = apiUrl.endsWith('/') ? apiUrl.slice(0, -1) : apiUrl;
1410
+ // Projects endpoint is at root level, not under /api/
1411
+ const url = `${baseUrl}/projects/?name=${encodeURIComponent(projectName)}`;
1412
+ const response = await fetch$1(url, {
1413
+ method: 'GET',
1414
+ headers: {
1415
+ 'Authorization': `Token ${apiToken}`,
1416
+ 'Content-Type': 'application/json',
1417
+ },
1418
+ });
1419
+ if (!response.ok) {
1420
+ const errorText = await response.text();
1421
+ throw new Error(`Failed to lookup project: ${response.status} ${errorText}`);
1422
+ }
1423
+ const data = await response.json();
1424
+ if (Array.isArray(data) && data.length > 0) {
1425
+ return data[0].id;
1426
+ }
1427
+ if (data.results && Array.isArray(data.results) && data.results.length > 0) {
1428
+ return data.results[0].id;
1429
+ }
1430
+ throw new Error(`Project "${projectName}" not found`);
1431
+ }
1314
1432
  /**
1315
1433
  * Lookup test suite ID by name and project
1316
1434
  */
@@ -1446,7 +1564,10 @@ class TestSuiteRunner {
1446
1564
  }
1447
1565
  }
1448
1566
  }
1449
- const sanitizedName = slugify(codeGenResult.testSuiteName);
1567
+ // Type guard: check for projectId to distinguish ProjectCodeGenResult from TestSuiteCodeGenResult
1568
+ const sanitizedName = 'projectId' in codeGenResult && 'projectName' in codeGenResult
1569
+ ? slugify(codeGenResult.projectName)
1570
+ : slugify(codeGenResult.testSuiteName);
1450
1571
  const packageJsonContent = generatePackageJson({
1451
1572
  name: sanitizedName,
1452
1573
  hasSecrets: hasSecrets,
@@ -1479,12 +1600,12 @@ class TestSuiteRunner {
1479
1600
  * Generates files, installs dependencies, and executes tests
1480
1601
  */
1481
1602
  static async runTestSuite(testSuiteId, apiToken, apiUrl, testSuiteName, runId, options = {}) {
1482
- const { outputDir, includeReporter = true, playwrightArgs = [], onStatusUpdate, onStdout, onStderr, onReporterEvent, } = options;
1603
+ const { outputDir, includeReporter = true, playwrightArgs = [], skipRunCreation = false, onStatusUpdate, onStdout, onStderr, onReporterEvent, } = options;
1483
1604
  const testSuiteDir = outputDir || getDefaultTestSuiteDir(testSuiteId, testSuiteName);
1484
1605
  let currentRunId = runId;
1485
1606
  try {
1486
- // Create run record if not provided (needed for run-specific report directories)
1487
- if (!currentRunId) {
1607
+ // Create run record if not provided and not skipping run creation
1608
+ if (!currentRunId && !skipRunCreation) {
1488
1609
  try {
1489
1610
  const createResponse = await fetch$1(`${apiUrl}/test-suites/${testSuiteId}/runs/`, {
1490
1611
  method: 'POST',
@@ -1909,6 +2030,233 @@ class TestSuiteRunner {
1909
2030
  };
1910
2031
  }
1911
2032
  }
2033
+ /**
2034
+ * Generate all files for a project (all scenarios)
2035
+ */
2036
+ static async generateProjectFiles(projectId, apiToken, apiUrl, outputDir, projectName, includeReporter = true) {
2037
+ const projectDir = outputDir || getDefaultTestSuiteDir(projectId, projectName || `project-${projectId}`);
2038
+ // Generate code for all scenarios
2039
+ const codeGenResult = await ProboCodeGenerator.generateCodeForProject(projectId, apiToken, apiUrl);
2040
+ if (codeGenResult.scenarios.length === 0) {
2041
+ throw new Error(`No scenarios found in project "${codeGenResult.projectName}" (ID: ${projectId}). Cannot generate test files.`);
2042
+ }
2043
+ // Delete everything in the project directory except node_modules
2044
+ if (fs.existsSync(projectDir)) {
2045
+ try {
2046
+ const nodeModulesPath = path.join(projectDir, 'node_modules');
2047
+ const hasNodeModules = fs.existsSync(nodeModulesPath);
2048
+ // Temporarily move node_modules out of the way if it exists
2049
+ let tempNodeModulesPath = null;
2050
+ if (hasNodeModules) {
2051
+ tempNodeModulesPath = path.join(projectDir, '..', `node_modules.temp.${projectId}`);
2052
+ // Remove temp directory if it exists from a previous failed run
2053
+ if (fs.existsSync(tempNodeModulesPath)) {
2054
+ fs.rmSync(tempNodeModulesPath, { recursive: true, force: true });
2055
+ }
2056
+ fs.renameSync(nodeModulesPath, tempNodeModulesPath);
2057
+ console.log(`📦 Preserved node_modules temporarily`);
2058
+ }
2059
+ // Delete everything in the directory
2060
+ const entries = fs.readdirSync(projectDir, { withFileTypes: true });
2061
+ for (const entry of entries) {
2062
+ const entryPath = path.join(projectDir, entry.name);
2063
+ try {
2064
+ if (entry.isDirectory()) {
2065
+ fs.rmSync(entryPath, { recursive: true, force: true });
2066
+ }
2067
+ else {
2068
+ fs.unlinkSync(entryPath);
2069
+ }
2070
+ }
2071
+ catch (error) {
2072
+ console.warn(`⚠️ Failed to delete ${entry.name}:`, error);
2073
+ }
2074
+ }
2075
+ console.log(`🗑️ Cleaned project directory (preserved node_modules)`);
2076
+ // Move node_modules back
2077
+ if (hasNodeModules && tempNodeModulesPath) {
2078
+ fs.renameSync(tempNodeModulesPath, nodeModulesPath);
2079
+ console.log(`📦 Restored node_modules`);
2080
+ }
2081
+ }
2082
+ catch (error) {
2083
+ console.warn(`⚠️ Failed to clean project directory ${projectDir}:`, error);
2084
+ // Continue anyway - we'll overwrite files as needed
2085
+ }
2086
+ }
2087
+ // Ensure directories exist (will recreate after deletion)
2088
+ ensureDirectoryExists(projectDir);
2089
+ const testsDir = path.join(projectDir, 'tests');
2090
+ ensureDirectoryExists(testsDir);
2091
+ // Save each scenario's code to a .spec.ts file
2092
+ for (const scenario of codeGenResult.scenarios) {
2093
+ const fileName = `${slugify(scenario.scenarioName)}.spec.ts`;
2094
+ const filePath = path.join(testsDir, fileName);
2095
+ fs.writeFileSync(filePath, scenario.code, 'utf-8');
2096
+ console.log(`✅ Generated test file: ${filePath}`);
2097
+ }
2098
+ // Generate package.json
2099
+ await this.generatePackageJson(projectDir, codeGenResult);
2100
+ // Generate playwright.config.ts (no runId for project runs)
2101
+ await this.generatePlaywrightConfig(projectDir, includeReporter, undefined);
2102
+ // Generate custom reporter file for live progress streaming (only if requested)
2103
+ if (includeReporter) {
2104
+ await this.generateProboReporter(projectDir);
2105
+ }
2106
+ }
2107
+ /**
2108
+ * Run all scenarios in a project
2109
+ * Generates files, installs dependencies, and executes tests
2110
+ * Does not create run records in the database (CLI mode)
2111
+ */
2112
+ static async runProjectScenarios(projectId, apiToken, apiUrl, projectName, options = {}) {
2113
+ const { outputDir, includeReporter = false, // CLI mode - no custom reporter
2114
+ playwrightArgs = [], onStdout, onStderr, } = options;
2115
+ const projectDir = outputDir || getDefaultTestSuiteDir(projectId, projectName || `project-${projectId}`);
2116
+ try {
2117
+ // Generate all files
2118
+ console.log(`📝 Generating test files for project ${projectId}...`);
2119
+ await this.generateProjectFiles(projectId, apiToken, apiUrl, projectDir, projectName, includeReporter);
2120
+ // Count total tests
2121
+ const testsTotal = countTotalTests(projectDir);
2122
+ if (testsTotal === 0) {
2123
+ throw new Error(`No test files generated for project ${projectId}`);
2124
+ }
2125
+ // Install dependencies
2126
+ console.log(`📦 Installing dependencies in ${projectDir}...`);
2127
+ try {
2128
+ const { stdout: installStdout, stderr: installStderr } = await execAsync('npm install', { cwd: projectDir, timeout: 300000 } // 5 minute timeout for install
2129
+ );
2130
+ console.log('✅ Dependencies installed successfully');
2131
+ if (installStdout)
2132
+ console.log(installStdout);
2133
+ if (installStderr)
2134
+ console.warn(installStderr);
2135
+ }
2136
+ catch (installError) {
2137
+ console.error('❌ Failed to install dependencies:', installError);
2138
+ const errorMsg = `Failed to install dependencies: ${installError.message}`;
2139
+ return {
2140
+ success: false,
2141
+ exitCode: installError.code || 1,
2142
+ stdout: installError.stdout || '',
2143
+ stderr: installError.stderr || installError.message || '',
2144
+ error: errorMsg,
2145
+ };
2146
+ }
2147
+ // Run Playwright tests with streaming output
2148
+ console.log(`🚀 Running Playwright tests in ${projectDir}...`);
2149
+ if (playwrightArgs.length > 0) {
2150
+ console.log(`🧪 Playwright extra args: ${playwrightArgs.join(' ')}`);
2151
+ }
2152
+ return new Promise(async (resolve) => {
2153
+ var _a, _b;
2154
+ let stdout = '';
2155
+ let stderr = '';
2156
+ let hasResolved = false;
2157
+ let stdoutLineBuffer = '';
2158
+ // Use spawn for streaming output
2159
+ const testProcess = spawn('npx', ['playwright', 'test', ...playwrightArgs], {
2160
+ cwd: projectDir,
2161
+ shell: true,
2162
+ stdio: ['ignore', 'pipe', 'pipe'],
2163
+ });
2164
+ // Stream stdout line by line
2165
+ (_a = testProcess.stdout) === null || _a === void 0 ? void 0 : _a.on('data', async (data) => {
2166
+ var _a;
2167
+ const chunk = data.toString();
2168
+ stdoutLineBuffer += chunk;
2169
+ // Process complete lines
2170
+ const parts = stdoutLineBuffer.split(/\n/);
2171
+ stdoutLineBuffer = (_a = parts.pop()) !== null && _a !== void 0 ? _a : '';
2172
+ for (const rawLine of parts) {
2173
+ const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine;
2174
+ stdout += rawLine + '\n';
2175
+ // Call stdout callback if provided
2176
+ if (onStdout) {
2177
+ await onStdout(rawLine + '\n');
2178
+ }
2179
+ }
2180
+ });
2181
+ // Stream stderr line by line
2182
+ (_b = testProcess.stderr) === null || _b === void 0 ? void 0 : _b.on('data', async (data) => {
2183
+ const chunk = data.toString();
2184
+ stderr += chunk;
2185
+ // Call stderr callback if provided
2186
+ if (onStderr) {
2187
+ await onStderr(chunk);
2188
+ }
2189
+ });
2190
+ // Handle process completion
2191
+ testProcess.on('close', async (code) => {
2192
+ if (hasResolved)
2193
+ return;
2194
+ hasResolved = true;
2195
+ // Flush any remaining buffered stdout
2196
+ if (stdoutLineBuffer) {
2197
+ stdout += stdoutLineBuffer;
2198
+ if (onStdout) {
2199
+ await onStdout(stdoutLineBuffer);
2200
+ }
2201
+ }
2202
+ const exitCode = code !== null && code !== void 0 ? code : 1;
2203
+ const success = exitCode === 0;
2204
+ const isTestFailure = exitCode === 1 && stdout.length > 0;
2205
+ console.log(success ? '✅ Tests completed successfully' : (isTestFailure ? '⚠️ Tests completed with failures' : '❌ Test execution failed'));
2206
+ resolve({
2207
+ success: success,
2208
+ exitCode: exitCode,
2209
+ stdout: stdout,
2210
+ stderr: stderr,
2211
+ error: success || isTestFailure ? undefined : `Test execution failed with exit code ${exitCode}`,
2212
+ });
2213
+ });
2214
+ // Handle process errors
2215
+ testProcess.on('error', async (error) => {
2216
+ if (hasResolved)
2217
+ return;
2218
+ hasResolved = true;
2219
+ const errorMessage = error.message || String(error);
2220
+ stderr += errorMessage;
2221
+ console.error('❌ Test execution failed:', error);
2222
+ resolve({
2223
+ success: false,
2224
+ exitCode: 1,
2225
+ stdout: stdout,
2226
+ stderr: stderr,
2227
+ error: `Test execution failed: ${errorMessage}`,
2228
+ });
2229
+ });
2230
+ // Set timeout (1 hour)
2231
+ setTimeout(async () => {
2232
+ if (!hasResolved) {
2233
+ hasResolved = true;
2234
+ testProcess.kill();
2235
+ const errorMessage = 'Test execution timed out after 1 hour';
2236
+ stderr += errorMessage;
2237
+ resolve({
2238
+ success: false,
2239
+ exitCode: 1,
2240
+ stdout: stdout,
2241
+ stderr: stderr,
2242
+ error: errorMessage,
2243
+ });
2244
+ }
2245
+ }, 3600000); // 1 hour timeout
2246
+ });
2247
+ }
2248
+ catch (error) {
2249
+ console.error('❌ Error running project scenarios:', error);
2250
+ const errorMsg = error.message || String(error);
2251
+ return {
2252
+ success: false,
2253
+ exitCode: 1,
2254
+ stdout: '',
2255
+ stderr: errorMsg,
2256
+ error: errorMsg,
2257
+ };
2258
+ }
2259
+ }
1912
2260
  /**
1913
2261
  * Upload artifacts for a test suite run
1914
2262
  *
@@ -1929,6 +2277,59 @@ async function runCLI(options) {
1929
2277
  process.exit(1);
1930
2278
  }
1931
2279
  const apiUrl = options.apiEndpoint || process.env.PROBO_API_ENDPOINT || 'https://api.probolabs.ai';
2280
+ // Check if running project-only mode (--project without test suite)
2281
+ if (options.project && !options.testSuiteId && !options.testSuiteName) {
2282
+ // Run all scenarios in the project
2283
+ try {
2284
+ console.log(`🔍 Looking up project "${options.project}"...`);
2285
+ const projectId = await TestSuiteRunner.lookupProjectByName(options.project, apiToken, apiUrl);
2286
+ console.log(`✅ Found project ID: ${projectId}`);
2287
+ const result = await TestSuiteRunner.runProjectScenarios(projectId, apiToken, apiUrl, options.project, {
2288
+ outputDir: options.outputDir,
2289
+ includeReporter: false, // CI/CD mode - no custom reporter
2290
+ playwrightArgs: options.playwrightArgs,
2291
+ skipRunCreation: true, // CLI never creates run records
2292
+ // Stream stdout/stderr to console in real-time
2293
+ onStdout: (chunk) => {
2294
+ process.stdout.write(chunk);
2295
+ },
2296
+ onStderr: (chunk) => {
2297
+ process.stderr.write(chunk);
2298
+ },
2299
+ });
2300
+ if (result.success) {
2301
+ console.log('✅ Project scenarios completed successfully');
2302
+ process.exit(0);
2303
+ }
2304
+ else {
2305
+ console.error(`❌ Project scenarios failed with exit code ${result.exitCode}`);
2306
+ if (result.error) {
2307
+ console.error(`Error: ${result.error}`);
2308
+ }
2309
+ // Print stdout/stderr to help debug failures
2310
+ console.log(`\n--- STDOUT (${result.stdout.length} chars) ---`);
2311
+ if (result.stdout.trim()) {
2312
+ console.log(result.stdout);
2313
+ }
2314
+ else {
2315
+ console.log('(empty)');
2316
+ }
2317
+ console.error(`\n--- STDERR (${result.stderr.length} chars) ---`);
2318
+ if (result.stderr.trim()) {
2319
+ console.error(result.stderr);
2320
+ }
2321
+ else {
2322
+ console.error('(empty)');
2323
+ }
2324
+ process.exit(result.exitCode || 1);
2325
+ }
2326
+ }
2327
+ catch (error) {
2328
+ console.error(`❌ Error running project scenarios: ${error.message}`);
2329
+ process.exit(1);
2330
+ }
2331
+ return; // Exit early for project-only mode
2332
+ }
1932
2333
  let testSuiteId;
1933
2334
  // Resolve test suite ID
1934
2335
  if (options.testSuiteId) {
@@ -1950,16 +2351,17 @@ async function runCLI(options) {
1950
2351
  }
1951
2352
  }
1952
2353
  else {
1953
- console.error('❌ Error: Either --test-suite-id or --test-suite-name must be provided');
2354
+ console.error('❌ Error: Either --test-suite-id, --test-suite-name, or --project must be provided');
1954
2355
  process.exit(1);
1955
2356
  }
1956
2357
  // Run the test suite
1957
2358
  try {
1958
- const result = await TestSuiteRunner.runTestSuite(testSuiteId, apiToken, apiUrl, options.testSuiteName, undefined, // runId - will be created automatically
2359
+ const result = await TestSuiteRunner.runTestSuite(testSuiteId, apiToken, apiUrl, options.testSuiteName, undefined, // runId - will not be created (CLI mode)
1959
2360
  {
1960
2361
  outputDir: options.outputDir,
1961
2362
  includeReporter: false, // CI/CD mode - no custom reporter
1962
2363
  playwrightArgs: options.playwrightArgs,
2364
+ skipRunCreation: true, // CLI never creates run records
1963
2365
  // Stream stdout/stderr to console in real-time
1964
2366
  onStdout: (chunk) => {
1965
2367
  process.stdout.write(chunk);