diff --git a/docs/cli.md b/docs/cli.md index 9dfa4bae..d889b6aa 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -234,20 +234,23 @@ npx @testomatio/reporter xml "pytest-results.xml" --timelimit 300 --env-file .en ### 5. upload-artifacts -Testomat.io reporter automatically uploads artifacts during run. However, either some artifacts failed to upload or you intentioanlly disabled file upload during tests to speed up reporting. In this case you can use this command to upload artifacts after the run. - -It is important to have the `TESTOMATIO_RUN` environment variable set to the run ID. +Testomat.io reporter automatically uploads artifacts during run. However, either some artifacts failed to upload or you intentionally disabled file upload during tests to speed up reporting. In this case you can use this command to upload artifacts after the run. **Usage:** ```bash -npx @testomatio/reporter upload-artifacts [options] +npx @testomatio/reporter upload-artifacts [jsonl-file] [options] ``` +**Arguments:** + +- `jsonl-file` (optional) - Path to JSONL debug file to upload artifacts from. When provided, artifacts are loaded from the debug file instead of local temp storage. + **Environment Variables:** - `TESTOMATIO`: Your Testomat.io API key (required). - `TESTOMATIO_RUN`: The previous run ID you want to upload artifacts (optional). If none set, latest run will be used. +- `TESTOMATIO_URL`: Testomat.io server URL (optional, defaults to `https://app.testomat.io`). **Options:** @@ -256,7 +259,7 @@ npx @testomatio/reporter upload-artifacts [options] You still need [S3 artifacts configuration](./artifacts.md) to be set to upload artifacts to storage. In order to disable artifacts upload during tests you can use `TESTOMATIO_DISABLE_ARTIFACTS=1` while running tests. -**Examples:** +**Upload from local temp storage (default mode):** ```bash npx @testomatio/reporter upload-artifacts @@ -287,6 +290,28 @@ TESTOMATIO=tstmt_* npx @testomatio/reporter upload-artifacts However, `upload-artifacts` command will upload all files after the run, without blocking the final result. +**Upload from JSONL debug file:** + +You can upload artifacts from a JSONL debug file generated during test execution with `TESTOMATIO_DEBUG=1`. This mode reads artifact paths from the debug file and uploads them to the correct tests. + +```bash +# Run tests with debug mode enabled +TESTOMATIO=tstmt_* TESTOMATIO_DEBUG=1 npx playwright test + +# Upload artifacts from the debug file +TESTOMATIO=tstmt_* npx @testomatio/reporter upload-artifacts ./testomatio.debug.json +``` + +This approach is useful when: +- You need to re-upload artifacts after a failed upload +- You want to upload artifacts to a different run +- The original upload was skipped due to size limits + +**Notes:** +- The JSONL file must contain a valid `runId` and tests with `test_id` set +- **CodeceptJS only**: trace.zip and video files are automatically recorded when using `TESTOMATIO_DEBUG=1` +- Artifacts are uploaded to the test level (Artifacts tab), not within individual steps + ### 6. replay The `replay` command allows you to re-send test data from debug files to Testomat.io. This is useful when your original test run failed to upload results properly. @@ -329,7 +354,7 @@ npx @testomatio/reporter replay --env-file .env.staging **How it works:** -The replay command uses the `ReplayService` class (located in `src/replay.js`) to: +The replay command uses the `Replay` class (located in `src/replay.js`) to: 1. Parse the debug file line by line 2. Extract environment variables, run parameters, test data, and finish parameters diff --git a/src/adapter/codecept.js b/src/adapter/codecept.js index c11dd452..90ac950b 100644 --- a/src/adapter/codecept.js +++ b/src/adapter/codecept.js @@ -278,6 +278,13 @@ async function uploadAttachments(client, attachments, messagePrefix, attachmentT log.info(`Attachments: ${messagePrefix} ${attachments.length} ${attachmentType} ...`); } + if (client.pipes?.length) { + const debugPipe = client.pipes.find(p => p.constructor.name === 'DebugPipe'); + if (debugPipe?.isEnabled) { + debugPipe.addArtifacts(attachments); + } + } + const promises = attachments.map(async attachment => { const { rid, title, path, type } = attachment; const file = { path, type, title }; diff --git a/src/bin/cli.js b/src/bin/cli.js index d7bf861c..5cbe7a75 100755 --- a/src/bin/cli.js +++ b/src/bin/cli.js @@ -16,6 +16,10 @@ import dotenv from 'dotenv'; import Replay from '../replay.js'; import { log } from '../utils/log.js'; import { formatFilterListIds } from '../utils/pipe_utils.js'; +import fs from 'fs'; +import path from 'path'; +import { Gaxios } from 'gaxios'; +import { generateShortFilename } from '../adapter/utils/step-formatter.js'; const debug = createDebugMessages('@testomatio/reporter:cli'); const version = getPackageVersion(); @@ -354,10 +358,101 @@ program program .command('upload-artifacts') .description('Upload artifacts to Testomat.io') + .argument('[jsonl-file]', 'Path to JSONL debug file (optional)') .option('--force', 'Re-upload artifacts even if they were uploaded before') - .action(async opts => { + .action(async (jsonlFile, opts) => { const apiKey = config.TESTOMATIO; + // JSONL file mode: upload artifacts from debug file + if (jsonlFile) { + if (!fs.existsSync(jsonlFile)) { + log.error(`JSONL file not found: ${jsonlFile}`); + return process.exit(1); + } + + const replay = new Replay({ apiKey }); + const { tests, runId } = replay.parseDebugFile(jsonlFile); + + if (!runId) { + log.error('runId not found in JSONL file'); + return process.exit(1); + } + + log.info(`Processing ${tests.length} tests from ${jsonlFile}`); + + const client = new TestomatClient({ + apiKey, + runId, + batchMode: BATCH_MODE.DISABLED, + }); + + await client.createRun(); + client.uploader.checkEnabled(); + client.uploader.disableLogStorage(); + + const apiUrl = process.env.TESTOMATIO_URL || 'https://app.testomat.io'; + const http = new Gaxios(); + let uploadedCount = 0; + let failedCount = 0; + + for (const test of tests) { + const testId = test.test_id; + if (!testId) continue; + + const artifacts = []; + + const collect = (items) => { + for (const item of items || []) { + const p = typeof item === 'object' ? item?.path : item; + if (p && fs.existsSync(p)) artifacts.push(p); + } + }; + collect(test.files); + + const walkSteps = (steps) => { + for (const step of steps || []) { + collect(step.artifacts); + walkSteps(step.steps); + } + }; + walkSteps(test.steps); + + if (artifacts.length === 0) continue; + + const urls = []; + for (const artifact of artifacts) { + try { + const s3Id = test.rid || testId.replace('@', ''); + const filename = generateShortFilename(artifact); + const result = await client.uploader.uploadFileByPath(artifact, [runId, s3Id, filename]); + if (result) urls.push(typeof result === 'string' ? result : result.link); + } catch (e) { + debug(`Failed to upload ${artifact}:`, e.message); + } + } + + if (urls.length === 0) continue; + + try { + await http.request({ + method: 'POST', + url: `${apiUrl}/api/reporter/${runId}/testrun?api_key=${apiKey}`, + data: { test_id: testId, artifacts: urls }, + }); + uploadedCount++; + } catch (e) { + log.error(`Failed ${testId}: ${e.message}`); + failedCount++; + } + } + + log.info(`🗄️ ${uploadedCount} tests with artifacts uploaded`); + if (failedCount > 0) { + log.warn(`⚠️ ${failedCount} tests failed to upload artifacts`); + } + return; + } + process.env.TESTOMATIO_DISABLE_ARTIFACTS = ''; const runId = process.env.TESTOMATIO_RUN || process.env.runId || readLatestRunId(); diff --git a/src/pipe/debug.js b/src/pipe/debug.js index ffcc74b7..5a91d948 100644 --- a/src/pipe/debug.js +++ b/src/pipe/debug.js @@ -111,6 +111,17 @@ export class DebugPipe { log.info(`History: ${this.historyDir}`); } + /** + * Logs artifacts data to the debug file. + * Used for trace.zip, video files and other artifacts uploaded after tests finish. + * @param {Array} artifacts - Array of artifacts with { rid, title, path, type } + */ + addArtifacts(artifacts) { + if (!this.isEnabled || !artifacts?.length) return; + const logData = { action: 'addArtifacts', artifacts, runId: this.store.runId }; + this.logToFile(logData); + } + async sync() { this.flushBufferedTests(); } diff --git a/src/replay.js b/src/replay.js index a3225ca8..f3329b7a 100644 --- a/src/replay.js +++ b/src/replay.js @@ -49,6 +49,7 @@ export class Replay { const testsWithoutRid = []; // For tests without rid (backward compatibility) const envVars = {}; let runId = null; + const artifactsToAdd = []; // Store artifacts to add after all tests are loaded // Parse debug file line by line for (const [lineIndex, line] of lines.entries()) { @@ -119,6 +120,11 @@ export class Replay { // Handle tests without rid (no deduplication) testsWithoutRid.push({ ...test }); } + } else if (logEntry.action === 'addArtifacts' && logEntry.artifacts) { + if (logEntry.runId && !runId) { + runId = logEntry.runId; + } + artifactsToAdd.push(...logEntry.artifacts); } else if (logEntry.action === 'finishRun') { finishParams = logEntry.params || {}; if (logEntry.runId && !runId) { @@ -138,6 +144,21 @@ export class Replay { this.onError(`${parseErrors - 3} more parse errors occurred`); } + for (const artifact of artifactsToAdd) { + if (artifact.rid) { + const ridToFind = artifact.rid; + const fullRid = runId ? `${runId}-${ridToFind}` : ridToFind; + + const test = testsMap.get(fullRid) || testsMap.get(ridToFind); + if (test && artifact.path) { + if (!test.files) test.files = []; + if (!test.files.includes(artifact.path)) { + test.files.push(artifact.path); + } + } + } + } + // Combine tests with rid and tests without rid const allTests = [...Array.from(testsMap.values()), ...testsWithoutRid];