testdriverai 7.8.0-test.60 → 7.8.0-test.62

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.
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  const useStderr = process.env.TD_STDIO === 'stderr';
10
- const isDebug = process.env.DEBUG === 'true';
10
+ const isDebug = process.env.TD_DEBUG === 'true' || process.env.VERBOSE === 'true';
11
11
 
12
12
  /**
13
13
  * Log a message - uses stdout by default, stderr if TD_STDIO=stderr
@@ -49,6 +49,10 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
49
49
  );
50
50
  }
51
51
 
52
+ getPublishCount() {
53
+ return this._publishCount;
54
+ }
55
+
52
56
  async _initAbly(ablyToken, channelName) {
53
57
  if (this._ably) {
54
58
  try {
@@ -177,7 +181,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
177
181
  return rid + '(' + (e && e.message ? e.message.type : '?') + ')';
178
182
  }).join(', ')
179
183
  : 'none';
180
- logger.warn(
184
+ logger.debug(
181
185
  '[realtime] No pending promise for requestId=' + (message.requestId || 'null') +
182
186
  ' | response type=' + (message.type || 'unknown') +
183
187
  ' | error=' + (message.error ? (message.errorMessage || 'true') : 'false') +
@@ -192,7 +196,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
192
196
  var pendingAge = pendingEntry && pendingEntry.startTime
193
197
  ? ((Date.now() - pendingEntry.startTime) / 1000).toFixed(1) + 's'
194
198
  : '?';
195
- logger.warn(
199
+ logger.debug(
196
200
  '[realtime] Promise REJECTED: requestId=' + message.requestId +
197
201
  ' | type=' + (pendingMessage ? pendingMessage.type : 'unknown') +
198
202
  ' | age=' + pendingAge +
@@ -305,11 +309,11 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
305
309
  });
306
310
 
307
311
  this._ably.connection.on("suspended", function () {
308
- logger.warn("[realtime] Connection: suspended - connection lost for extended period, will keep retrying");
312
+ logger.debug("[realtime] Connection: suspended - connection lost for extended period, will keep retrying");
309
313
  });
310
314
 
311
315
  this._ably.connection.on("failed", function () {
312
- logger.error("[realtime] Connection: failed");
316
+ logger.debug("[realtime] Connection: failed");
313
317
  self.apiSocketConnected = false;
314
318
  self.instanceSocketConnected = false;
315
319
  emitter.emit(events.error.sandbox, "Realtime connection failed");
@@ -326,7 +330,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
326
330
  var reasonMsg = reason ? (reason.message || reason.code || String(reason)) : '';
327
331
 
328
332
  if (current === 'attached' && stateChange.resumed === false && previous === 'attached') {
329
- logger.warn('[realtime] Channel DISCONTINUITY detected (resumed=false)' + (reasonMsg ? ' — ' + reasonMsg : ''));
333
+ logger.debug('[realtime] Channel DISCONTINUITY detected (resumed=false)' + (reasonMsg ? ' — ' + reasonMsg : ''));
330
334
  emitter.emit(events.sandbox.progress, {
331
335
  step: 'discontinuity',
332
336
  message: 'Recovering missed messages after connection interruption...',
@@ -367,7 +371,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
367
371
  entry.handler(page.items[j]);
368
372
  }
369
373
  } catch (replayErr) {
370
- logger.error('[realtime] Error replaying recovered message: ' + (replayErr.message || replayErr));
374
+ logger.debug('[realtime] Error replaying recovered message: ' + (replayErr.message || replayErr));
371
375
  }
372
376
  }
373
377
  page = page.hasNext() ? await page.next() : null;
@@ -375,11 +379,11 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
375
379
  totalRecovered += recovered;
376
380
  logger.debug('[realtime] Discontinuity recovery: replayed ' + recovered + ' ' + entry.name + ' message(s) from gap');
377
381
  } catch (err) {
378
- logger.error('[realtime] Discontinuity recovery failed for ' + entry.name + ': ' + (err.message || err));
382
+ logger.debug('[realtime] Discontinuity recovery failed for ' + entry.name + ': ' + (err.message || err));
379
383
  }
380
384
  }
381
385
  if (totalRecovered > 0) {
382
- logger.warn('[realtime] Recovered and replayed ' + totalRecovered + ' message(s) that were missed during connection interruption');
386
+ logger.debug('[realtime] Recovered and replayed ' + totalRecovered + ' message(s) that were missed during connection interruption');
383
387
  } else {
384
388
  logger.debug('[realtime] Discontinuity recovery: no missed messages found');
385
389
  }
@@ -579,6 +583,10 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
579
583
  if (data && data.os && reply.runner) reply.runner.os = data.os;
580
584
  if (data && data.ip && reply.runner) reply.runner.ip = data.ip;
581
585
  if (data && data.runnerVersion && reply.runner) reply.runner.version = data.runnerVersion;
586
+ // Persist version metadata for test result reporting
587
+ self._runnerVersionBefore = reply.imageVersion || null;
588
+ self._runnerVersionAfter = (data && data.runnerVersion) || reply.imageVersion || null;
589
+ self._wasUpdated = !!(data && data.runnerVersion && reply.imageVersion && data.runnerVersion !== reply.imageVersion);
582
590
  logger.log('Runner agent ready (os=' + ((data && data.os) || 'unknown') + ', runner v' + ((data && data.runnerVersion) || 'unknown') + ')');
583
591
  // Show upgrade info: if the runner's npm version differs from the baked image version,
584
592
  // the runner was upgraded during provisioning.
@@ -679,6 +687,15 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
679
687
  url: url,
680
688
  vncPort: noVncPort || undefined,
681
689
  runner: reply.runner,
690
+ // Extra metadata for test result reporting
691
+ amiId: reply.amiId || null,
692
+ e2bTemplateId: reply.e2bTemplateId || null,
693
+ imageVersion: reply.imageVersion || null,
694
+ runnerVersionBefore: this._runnerVersionBefore || reply.imageVersion || null,
695
+ runnerVersionAfter: this._runnerVersionAfter || reply.runner?.version || null,
696
+ wasUpdated: this._wasUpdated || false,
697
+ vncUrl: url || null,
698
+ channelName: this._channelName || null,
682
699
  },
683
700
  };
684
701
  }
@@ -1149,7 +1166,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
1149
1166
  // Send end-session control message to runner before disconnecting
1150
1167
  if (this._sessionChannel && this._ably?.connection?.state === 'connected') {
1151
1168
  try {
1152
- logger.log('[realtime] Publishing control: type=end-session');
1169
+ logger.debug('[realtime] Publishing control: type=end-session');
1153
1170
  await this._sessionChannel.publish('control', { type: 'end-session' });
1154
1171
  } catch (e) {
1155
1172
  // Ignore - best effort
@@ -1159,7 +1176,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
1159
1176
  // Leave presence on session channel
1160
1177
  if (this._sessionChannel) {
1161
1178
  try {
1162
- logger.log('[realtime] Leaving presence on session channel');
1179
+ logger.debug('[realtime] Leaving presence on session channel');
1163
1180
  await this._sessionChannel.presence.leave();
1164
1181
  } catch (e) {
1165
1182
  // ignore - best effort, Ably will auto-leave on disconnect
@@ -1167,7 +1184,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
1167
1184
  }
1168
1185
 
1169
1186
  try {
1170
- logger.log('[realtime] Detaching session channel');
1187
+ logger.debug('[realtime] Detaching session channel');
1171
1188
  if (this._sessionChannel) {
1172
1189
  await this._sessionChannel.detach();
1173
1190
  }
@@ -1177,7 +1194,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
1177
1194
 
1178
1195
  if (this._ably) {
1179
1196
  try {
1180
- logger.log('[realtime] Closing Realtime connection');
1197
+ logger.debug('[realtime] Closing Realtime connection');
1181
1198
  this._ably.close();
1182
1199
  } catch (e) {
1183
1200
  /* ignore */
@@ -1,39 +1,33 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * Extract Example URLs from CI Logs
5
- *
6
- * Parses vitest output to extract TESTDRIVER_EXAMPLE_URL lines
7
- * and updates the examples-manifest.json file.
8
- *
4
+ * Extract Example URLs from Test Result JSON Files
5
+ *
6
+ * Reads per-test-case JSON result files written by the vitest plugin
7
+ * to .testdriver/results/ and updates examples-manifest.json.
8
+ *
9
9
  * Usage:
10
- * cat ci-log.txt | node extract-example-urls.js
11
- * node extract-example-urls.js < ci-log.txt
12
- * node extract-example-urls.js --file=ci-log.txt
10
+ * node extract-example-urls.js --results-dir=.testdriver/results
13
11
  */
14
12
 
15
13
  const fs = require("fs");
16
14
  const path = require("path");
17
- const readline = require("readline");
18
15
 
19
16
  const MANIFEST_PATH = path.join(__dirname, "../_data/examples-manifest.json");
20
17
 
21
- // Regex to match TESTDRIVER_EXAMPLE_URL::filename::url (handles optional timestamp prefix from CI logs)
22
- const URL_PATTERN = /TESTDRIVER_EXAMPLE_URL::([^:]+)::(.+)$/;
23
-
24
18
  // Parse command line arguments
25
19
  function parseArgs() {
26
20
  const args = process.argv.slice(2);
27
21
  const options = {
28
- file: null,
22
+ resultsDir: null,
29
23
  help: false,
30
24
  };
31
25
 
32
26
  for (const arg of args) {
33
27
  if (arg === "--help" || arg === "-h") {
34
28
  options.help = true;
35
- } else if (arg.startsWith("--file=")) {
36
- options.file = arg.slice(7);
29
+ } else if (arg.startsWith("--results-dir=")) {
30
+ options.resultsDir = arg.slice(14);
37
31
  }
38
32
  }
39
33
 
@@ -62,40 +56,51 @@ function saveManifest(manifest) {
62
56
  fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + "\n", "utf-8");
63
57
  }
64
58
 
65
- // Process a single line and extract URL if present
66
- function processLine(line, manifest, stats) {
67
- const match = line.match(URL_PATTERN);
68
- if (match) {
69
- const [, filename, url] = match;
70
- const isNew = !manifest.examples[filename];
71
-
72
- manifest.examples[filename] = {
73
- url: url.trim(),
74
- lastUpdated: new Date().toISOString(),
75
- };
76
-
77
- if (isNew) {
78
- stats.added++;
79
- } else {
80
- stats.updated++;
81
- }
82
-
83
- console.log(`${isNew ? "➕" : "🔄"} ${filename}: ${url}`);
84
- }
85
- }
86
-
87
- // Process input stream
88
- async function processInput(inputStream) {
59
+ // Process JSON result files from .testdriver/results/
60
+ function processResultsDir(resultsDir) {
89
61
  const manifest = loadManifest();
90
62
  const stats = { added: 0, updated: 0 };
91
63
 
92
- const rl = readline.createInterface({
93
- input: inputStream,
94
- crlfDelay: Infinity,
95
- });
64
+ // Look for JSON files under examples/ subdirectories
65
+ const examplesDir = path.join(resultsDir, "examples");
66
+ if (!fs.existsSync(examplesDir)) {
67
+ console.log(`\n⚠️ No examples results found in ${examplesDir}`);
68
+ return stats;
69
+ }
96
70
 
97
- for await (const line of rl) {
98
- processLine(line, manifest, stats);
71
+ // Walk example test directories (e.g., examples/assert.test.mjs/)
72
+ const testDirs = fs.readdirSync(examplesDir, { withFileTypes: true });
73
+ for (const entry of testDirs) {
74
+ if (!entry.isDirectory()) continue;
75
+ const testDir = path.join(examplesDir, entry.name);
76
+ const jsonFiles = fs.readdirSync(testDir).filter(f => f.endsWith(".json"));
77
+
78
+ for (const jsonFile of jsonFiles) {
79
+ try {
80
+ const content = fs.readFileSync(path.join(testDir, jsonFile), "utf-8");
81
+ const result = JSON.parse(content);
82
+ const testFileName = path.basename(result.test?.file || result.testFile || entry.name);
83
+ const url = result.urls?.testRun || result.testRunLink;
84
+
85
+ if (!url) continue;
86
+
87
+ const isNew = !manifest.examples[testFileName];
88
+ manifest.examples[testFileName] = {
89
+ url: url,
90
+ lastUpdated: result.date || new Date().toISOString(),
91
+ };
92
+
93
+ if (isNew) {
94
+ stats.added++;
95
+ } else {
96
+ stats.updated++;
97
+ }
98
+
99
+ console.log(`${isNew ? "➕" : "🔄"} ${testFileName}: ${url}`);
100
+ } catch (err) {
101
+ console.warn(`⚠️ Failed to read ${jsonFile}: ${err.message}`);
102
+ }
103
+ }
99
104
  }
100
105
 
101
106
  if (stats.added > 0 || stats.updated > 0) {
@@ -103,7 +108,7 @@ async function processInput(inputStream) {
103
108
  console.log(`\n✨ Manifest updated: ${stats.added} added, ${stats.updated} updated`);
104
109
  console.log(`📂 Written to: ${MANIFEST_PATH}`);
105
110
  } else {
106
- console.log("\n⚠️ No TESTDRIVER_EXAMPLE_URL entries found in input");
111
+ console.log("\n⚠️ No example URLs found in result files");
107
112
  }
108
113
 
109
114
  return stats;
@@ -112,28 +117,24 @@ async function processInput(inputStream) {
112
117
  // Show help
113
118
  function showHelp() {
114
119
  console.log(`
115
- Extract Example URLs from CI Logs
120
+ Extract Example URLs from Test Result JSON Files
116
121
 
117
122
  Usage:
118
- cat ci-log.txt | node extract-example-urls.js
119
- node extract-example-urls.js < ci-log.txt
120
- node extract-example-urls.js --file=ci-log.txt
123
+ node extract-example-urls.js --results-dir=.testdriver/results
121
124
 
122
125
  Options:
123
- --file=<path> Read from file instead of stdin
124
- --help, -h Show this help message
126
+ --results-dir=<path> Path to .testdriver/results directory (required)
127
+ --help, -h Show this help message
125
128
 
126
129
  Description:
127
- Parses CI log output looking for lines matching:
128
- TESTDRIVER_EXAMPLE_URL::<filename>::<url>
129
-
130
- Updates docs/_data/examples-manifest.json with the extracted URLs.
130
+ Reads per-test-case JSON result files from .testdriver/results/examples/
131
+ and updates docs/_data/examples-manifest.json with the extracted URLs.
131
132
  Existing entries are updated, new entries are added.
132
133
  `);
133
134
  }
134
135
 
135
136
  // Main function
136
- async function main() {
137
+ function main() {
137
138
  const options = parseArgs();
138
139
 
139
140
  if (options.help) {
@@ -141,25 +142,19 @@ async function main() {
141
142
  process.exit(0);
142
143
  }
143
144
 
144
- console.log("🔍 Extracting example URLs from input...\n");
145
-
146
- let inputStream;
147
- if (options.file) {
148
- if (!fs.existsSync(options.file)) {
149
- console.error(`❌ File not found: ${options.file}`);
150
- process.exit(1);
151
- }
152
- inputStream = fs.createReadStream(options.file);
153
- } else {
154
- inputStream = process.stdin;
145
+ if (!options.resultsDir) {
146
+ console.error("❌ --results-dir is required. Example: --results-dir=.testdriver/results");
147
+ process.exit(1);
155
148
  }
156
149
 
157
- try {
158
- await processInput(inputStream);
159
- } catch (error) {
160
- console.error(`❌ Error: ${error.message}`);
150
+ console.log("🔍 Reading example URLs from JSON result files...\n");
151
+
152
+ if (!fs.existsSync(options.resultsDir)) {
153
+ console.error(`❌ Results directory not found: ${options.resultsDir}`);
161
154
  process.exit(1);
162
155
  }
156
+
157
+ processResultsDir(options.resultsDir);
163
158
  }
164
159
 
165
160
  main();
package/docs/docs.json CHANGED
@@ -87,7 +87,8 @@
87
87
  "pages": [
88
88
  "/v7/running-tests",
89
89
  "/v7/caching",
90
- "/v7/ci-cd"
90
+ "/v7/ci-cd",
91
+ "/v7/test-results-json"
91
92
  ]
92
93
  },
93
94
  {
@@ -0,0 +1,258 @@
1
+ ---
2
+ title: "Test Result JSON"
3
+ sidebarTitle: "Test Result JSON"
4
+ description: "Per-test JSON result files with metadata, versions, and infrastructure details"
5
+ icon: "file-code"
6
+ ---
7
+
8
+ ## Overview
9
+
10
+ TestDriver automatically writes a JSON result file for each test case after it finishes. These files contain comprehensive metadata about the test run, including SDK and runner versions, infrastructure details, interaction statistics, and links to recordings.
11
+
12
+ Result files are written to:
13
+
14
+ ```
15
+ .testdriver/results/<testFile>/<testName>.json
16
+ ```
17
+
18
+ For example, a test file `tests/login.test.mjs` with a test named `"should log in"` produces:
19
+
20
+ ```
21
+ .testdriver/results/tests/login.test.mjs/should_log_in.json
22
+ ```
23
+
24
+ <Note>
25
+ Test names are sanitized for filesystem use — special characters are replaced with underscores and names are truncated to 200 characters.
26
+ </Note>
27
+
28
+ ## Enabling
29
+
30
+ No configuration is required. The JSON files are written automatically by the TestDriver Vitest reporter plugin whenever tests run.
31
+
32
+ ## JSON Schema
33
+
34
+ Each result file is organized into logical groups:
35
+
36
+ ### `versions`
37
+
38
+ | Field | Type | Description |
39
+ |---|---|---|
40
+ | `versions.sdk` | `string \| null` | TestDriver SDK version (e.g. `"7.8.0"`) |
41
+ | `versions.vitest` | `string \| null` | Vitest version used to run the test |
42
+ | `versions.api` | `string \| null` | TestDriver API server version |
43
+ | `versions.runnerBefore` | `string \| null` | Runner version at sandbox start |
44
+ | `versions.runnerAfter` | `string \| null` | Runner version after auto-update |
45
+ | `versions.runnerWasUpdated` | `boolean` | Whether the runner was auto-updated during provisioning |
46
+
47
+ ### `test`
48
+
49
+ | Field | Type | Description |
50
+ |---|---|---|
51
+ | `test.file` | `string \| null` | Relative path to the test file |
52
+ | `test.name` | `string \| null` | Name of the test case |
53
+ | `test.suite` | `string \| null` | Name of the parent `describe` block |
54
+ | `test.passed` | `boolean` | Whether the test passed |
55
+ | `test.caseId` | `string \| null` | Database ID for this test case |
56
+ | `test.runId` | `string \| null` | Database ID for the overall test run |
57
+ | `test.error` | `string \| null` | Error message if the test failed |
58
+ | `test.errorStack` | `string \| null` | Error stack trace if the test failed |
59
+
60
+ ### `urls`
61
+
62
+ | Field | Type | Description |
63
+ |---|---|---|
64
+ | `urls.api` | `string \| null` | API root URL used for this test |
65
+ | `urls.console` | `string \| null` | TestDriver console base URL |
66
+ | `urls.vnc` | `string \| null` | VNC URL for the sandbox |
67
+ | `urls.testRun` | `string \| null` | Direct link to this test case in the console |
68
+
69
+ ### `replay`
70
+
71
+ The `replay` object contains the recording replay URL and derived embed links. The `gifUrl` and `embedUrl` are generated automatically from the replay URL.
72
+
73
+ | Field | Type | Description |
74
+ |---|---|---|
75
+ | `replay.url` | `string \| null` | Recording replay URL |
76
+ | `replay.gifUrl` | `string \| null` | Animated GIF thumbnail of the recording |
77
+ | `replay.embedUrl` | `string \| null` | Embeddable replay URL (appends `&embed=true`) |
78
+ | `replay.markdown` | `string \| null` | Ready-to-use Markdown embed with GIF linking to the replay |
79
+
80
+ The `replay.markdown` field produces a clickable GIF badge you can paste directly into PR comments, README files, or issue descriptions:
81
+
82
+ ```markdown
83
+ [![Test Recording](https://api.testdriver.ai/replay/abc123/gif?shareKey=xyz)](https://console.testdriver.ai/replay/abc123?share=xyz)
84
+ ```
85
+
86
+ ### `date`
87
+
88
+ | Field | Type | Description |
89
+ |---|---|---|
90
+ | `date` | `string` | ISO 8601 timestamp when the test finished |
91
+
92
+ ### `team`
93
+
94
+ | Field | Type | Description |
95
+ |---|---|---|
96
+ | `team.id` | `string \| null` | Team ID from the sandbox |
97
+ | `team.sessionId` | `string \| null` | SDK session ID |
98
+
99
+ ### `infrastructure`
100
+
101
+ | Field | Type | Description |
102
+ |---|---|---|
103
+ | `infrastructure.sandboxId` | `string \| null` | Sandbox instance ID |
104
+ | `infrastructure.instanceId` | `string \| null` | Instance ID |
105
+ | `infrastructure.os` | `string \| null` | Operating system of the sandbox (`"linux"` or `"windows"`) |
106
+ | `infrastructure.amiId` | `string \| null` | AWS AMI ID used for provisioning |
107
+ | `infrastructure.e2bTemplateId` | `string \| null` | E2B template ID used for provisioning |
108
+ | `infrastructure.imageVersion` | `string \| null` | Sandbox image version |
109
+
110
+ ### `realtime`
111
+
112
+ | Field | Type | Description |
113
+ |---|---|---|
114
+ | `realtime.channel` | `string \| null` | Ably channel name used for communication |
115
+ | `realtime.messageCount` | `number` | Number of messages published to the realtime channel |
116
+
117
+ ### `interactions`
118
+
119
+ | Field | Type | Description |
120
+ |---|---|---|
121
+ | `interactions.total` | `number` | Total number of interactions recorded |
122
+ | `interactions.cached` | `number` | Number of interactions served from cache |
123
+ | `interactions.byType` | `object` | Breakdown of interactions by type (e.g. `find`, `click`, `assert`) |
124
+
125
+ ## Example Output
126
+
127
+ ```json
128
+ {
129
+ "sdkVersion": "7.8.0",
130
+ "vitestVersion": "4.0.0",
131
+ "apiVersion": "1.45.0",
132
+ "runnerVersionBefore": "2.1.0",
133
+ "runnerVersionAfter": "2.1.1",
134
+ "wasUpdated": true,
135
+ "apiUrl": "https://api.testdriver.ai",
136
+ "consoleUrl": "https://console.testdriver.ai",
137
+ "testRunLink": "https://console.testdriver.ai/runs/abc123/def456",
138
+ "dashcamUrl": "https://app.dashcam.io/replay/abc123",
139
+ "vncUrl": "wss://sandbox-123.testdriver.ai/vnc",
140
+ "date": "2025-01-15T14:30:00.000Z",
141
+ "team": {
142
+ "id": "team_abc123",
143
+ "sessionId": "sess_xyz789"
144
+ },
145
+ "infrastructure": {
146
+ "sandboxId": "sandbox-123",
147
+ "instanceId": "i-abc123",
148
+ "os": "linux",
149
+ "amiId": "ami-0abc123",
150
+ "e2bTemplateId": null,
151
+ "imageVersion": "v2.1.0"
152
+ },
153
+ "realtime": {
154
+ "channel": "sandbox:sandbox-123",
155
+ "messageCount": 42
156
+ },
157
+ "interactions": {
158
+ "total": 15,
159
+ "cached": 3,
160
+ "byType": {
161
+ "find": 8,
162
+ "click": 5,
163
+ "assert": 2
164
+ }
165
+ }
166
+ }
167
+ ```
168
+
169
+ ## Using Result Files in CI
170
+
171
+ Result files are useful for extracting test metadata in CI pipelines without parsing log output.
172
+
173
+ ### GitHub Actions Example
174
+
175
+ Use `fromJSON` to parse a result file into a GitHub Actions expression you can reference in subsequent steps:
176
+
177
+ ```yaml
178
+ - name: Run tests
179
+ run: npx vitest run tests/login.test.mjs
180
+
181
+ - name: Parse result
182
+ id: result
183
+ run: |
184
+ # Read the first JSON result file
185
+ FILE=$(find .testdriver/results -name '*.json' | head -n 1)
186
+ echo "json=$(cat "$FILE")" >> "$GITHUB_OUTPUT"
187
+
188
+ - name: Comment on PR
189
+ if: fromJSON(steps.result.outputs.json).test.passed == false
190
+ uses: actions/github-script@v7
191
+ with:
192
+ script: |
193
+ const result = ${{ steps.result.outputs.json }};
194
+ await github.rest.issues.createComment({
195
+ owner: context.repo.owner,
196
+ repo: context.repo.repo,
197
+ issue_number: context.issue.number,
198
+ body: [
199
+ `❌ **${result.test.name}** failed`,
200
+ ``,
201
+ `Error: ${result.test.error}`,
202
+ ``,
203
+ result.replay.markdown,
204
+ ``,
205
+ `[View full recording](${result.urls.testRun})`
206
+ ].join('\n')
207
+ });
208
+ ```
209
+
210
+ You can also load all results into a matrix or iterate over them:
211
+
212
+ ```yaml
213
+ - name: Run tests
214
+ run: npx vitest run tests/*.test.mjs
215
+
216
+ - name: Collect results
217
+ id: results
218
+ run: |
219
+ # Merge all result files into a JSON array
220
+ echo "json=$(find .testdriver/results -name '*.json' -exec cat {} + | jq -s '.')" >> "$GITHUB_OUTPUT"
221
+
222
+ - name: Summary
223
+ run: |
224
+ echo '## Test Results' >> $GITHUB_STEP_SUMMARY
225
+ RESULTS='${{ steps.results.outputs.json }}'
226
+ echo "$RESULTS" | jq -r '.[] | "| \(.test.name) | \(if .test.passed then "✅" else "❌" end) | \(.urls.testRun) |"' >> $GITHUB_STEP_SUMMARY
227
+ ```
228
+
229
+ ### Reading Results Programmatically
230
+
231
+ ```javascript
232
+ import fs from "fs";
233
+ import path from "path";
234
+
235
+ const resultsDir = ".testdriver/results";
236
+
237
+ function readResults(dir) {
238
+ const results = [];
239
+ for (const testDir of fs.readdirSync(dir, { recursive: true })) {
240
+ const fullPath = path.join(dir, testDir);
241
+ if (fullPath.endsWith(".json") && fs.statSync(fullPath).isFile()) {
242
+ results.push(JSON.parse(fs.readFileSync(fullPath, "utf-8")));
243
+ }
244
+ }
245
+ return results;
246
+ }
247
+
248
+ const results = readResults(resultsDir);
249
+ const passed = results.filter(r => r.test.passed);
250
+ const failed = results.filter(r => !r.test.passed);
251
+
252
+ console.log(`${passed.length} passed, ${failed.length} failed`);
253
+ for (const r of failed) {
254
+ console.log(` FAIL: ${r.test.name} — ${r.test.error}`);
255
+ console.log(` Recording: ${r.urls.testRun}`);
256
+ console.log(` Embed: ${r.replay.markdown}`);
257
+ }
258
+ ```
@@ -1218,6 +1218,122 @@ class TestDriverReporter {
1218
1218
  );
1219
1219
  console.log("");
1220
1220
 
1221
+ // Write per-test-case JSON result file
1222
+ {
1223
+ const testResult = meta.testResult || {};
1224
+
1225
+ // Parse replay URL to extract replayId and shareKey for embed links
1226
+ let replayUrl = dashcamUrl || null;
1227
+ let replayGifUrl = null;
1228
+ let replayEmbedUrl = null;
1229
+ let replayMarkdown = null;
1230
+ const replayMatch = dashcamUrl && dashcamUrl.match(/\/replay\/([a-f0-9]+)\?share=([^&\s]+)/);
1231
+ if (replayMatch) {
1232
+ const [, replayId, shareKey] = replayMatch;
1233
+ const apiRoot = pluginState.apiRoot;
1234
+ replayGifUrl = `${apiRoot}/replay/${replayId}/gif?shareKey=${shareKey}`;
1235
+ replayEmbedUrl = `${consoleUrl}/replay/${replayId}?share=${shareKey}&embed=true`;
1236
+ replayMarkdown = `[![Test Recording](${replayGifUrl})](${replayUrl})`;
1237
+ }
1238
+
1239
+ const resultData = {
1240
+ // Versions
1241
+ versions: {
1242
+ sdk: testResult.sdkVersion || null,
1243
+ vitest: resolveVitestVersion() || null,
1244
+ api: testResult.apiVersion || null,
1245
+ runnerBefore: testResult.runnerVersionBefore || null,
1246
+ runnerAfter: testResult.runnerVersionAfter || null,
1247
+ runnerWasUpdated: testResult.wasUpdated || false,
1248
+ },
1249
+
1250
+ // Test info
1251
+ test: {
1252
+ file: testResult.testFile || null,
1253
+ name: testResult.testName || null,
1254
+ suite: testResult.suiteName || null,
1255
+ passed: status === "passed",
1256
+ caseId: testCaseDbId || null,
1257
+ runId: testRunDbId || null,
1258
+ error: errorMessage || testResult.error || null,
1259
+ errorStack: errorStack || testResult.errorStack || null,
1260
+ },
1261
+
1262
+ // URLs
1263
+ urls: {
1264
+ api: testResult.apiUrl || null,
1265
+ console: consoleUrl || null,
1266
+ vnc: testResult.vncUrl || null,
1267
+ testRun: testCaseDbId ? `${consoleUrl}/runs/${testRunDbId}/${testCaseDbId}` : null,
1268
+ },
1269
+
1270
+ // Recording replay
1271
+ replay: {
1272
+ url: replayUrl,
1273
+ gifUrl: replayGifUrl,
1274
+ embedUrl: replayEmbedUrl,
1275
+ markdown: replayMarkdown,
1276
+ },
1277
+
1278
+ // Timing
1279
+ date: testResult.date || new Date().toISOString(),
1280
+
1281
+ // Team & session
1282
+ team: {
1283
+ id: testResult.teamId || null,
1284
+ sessionId: testResult.sessionId || null,
1285
+ },
1286
+
1287
+ // Infrastructure
1288
+ infrastructure: {
1289
+ sandboxId: testResult.sandboxId || null,
1290
+ instanceId: testResult.instanceId || null,
1291
+ os: testResult.os || null,
1292
+ amiId: testResult.amiId || null,
1293
+ e2bTemplateId: testResult.e2bTemplateId || null,
1294
+ imageVersion: testResult.imageVersion || null,
1295
+ },
1296
+
1297
+ // Realtime
1298
+ realtime: {
1299
+ channel: testResult.realtimeChannel || null,
1300
+ messageCount: testResult.realtimeMessageCount || 0,
1301
+ },
1302
+
1303
+ // Interactions
1304
+ interactions: testResult.interactions || { total: 0, cached: 0, byType: {} },
1305
+ };
1306
+
1307
+ // Sanitize testName for filesystem use
1308
+ const safeName = (test.name || "unknown").replace(/[^a-zA-Z0-9_.-]/g, "_").substring(0, 200);
1309
+ const resultDir = path.join(process.cwd(), ".testdriver", "results", testFile);
1310
+ fs.mkdirSync(resultDir, { recursive: true });
1311
+
1312
+ // Include a stable unique suffix in the filename to avoid collisions
1313
+ // when multiple tests in the same file share the same name.
1314
+ const hashSourceParts = [];
1315
+ if (test.id) {
1316
+ hashSourceParts.push(String(test.id));
1317
+ }
1318
+ if (Array.isArray(test.suitePath)) {
1319
+ hashSourceParts.push(test.suitePath.join(" > "));
1320
+ }
1321
+ if (test.file && (test.file.name || test.file.path)) {
1322
+ hashSourceParts.push(test.file.name || test.file.path);
1323
+ }
1324
+ // Fallback to the test name if no other identifiers are available.
1325
+ if (hashSourceParts.length === 0) {
1326
+ hashSourceParts.push(test.name || "unknown");
1327
+ }
1328
+ const hashSource = hashSourceParts.join(" | ");
1329
+ const uniqueHash = crypto.createHash("sha256").update(hashSource).digest("hex").slice(0, 8);
1330
+
1331
+ fs.writeFileSync(
1332
+ path.join(resultDir, `${safeName}-${uniqueHash}.json`),
1333
+ JSON.stringify(resultData, null, 2),
1334
+ );
1335
+ }
1336
+
1221
1337
  // If there were retries, list all per-attempt dashcam URLs for debugging
1222
1338
  if (hasRetries) {
1223
1339
  const validAttempts = dashcamUrls.filter(a => a.url);
@@ -1229,13 +1345,6 @@ class TestDriverReporter {
1229
1345
  }
1230
1346
  }
1231
1347
 
1232
- // Output parseable format for docs generation (examples only)
1233
- if (testFile.startsWith("examples/")) {
1234
- const testFileName = path.basename(testFile);
1235
- console.log(
1236
- `TESTDRIVER_EXAMPLE_URL::${testFileName}::${consoleUrl}/runs/${testRunDbId}/${testCaseDbId}`,
1237
- );
1238
- }
1239
1348
  } catch (error) {
1240
1349
  logger.error("Failed to report test case:", error.message);
1241
1350
  }
@@ -649,6 +649,61 @@ export function TestDriver(context, options = {}) {
649
649
  // Clean up console spies
650
650
  cleanupConsoleSpy(currentInstance);
651
651
 
652
+ // Build test result metadata for JSON report output
653
+ {
654
+ const sdkPkg = require("../../package.json");
655
+ const inst = currentInstance.getInstance?.() || {};
656
+ const sbx = currentInstance.sandbox || {};
657
+ const apiRoot = currentInstance.config?.TD_API_ROOT || null;
658
+
659
+ context.task.meta.testResult = {
660
+ // Versions
661
+ sdkVersion: sdkPkg.version || null,
662
+ apiVersion: currentInstance._apiVersion || null,
663
+ runnerVersionBefore: inst.runnerVersionBefore || null,
664
+ runnerVersionAfter: inst.runnerVersionAfter || null,
665
+ wasUpdated: inst.wasUpdated || false,
666
+
667
+ // URLs
668
+ apiUrl: apiRoot,
669
+ vncUrl: inst.vncUrl || inst.url || null,
670
+
671
+ // Dates
672
+ date: new Date().toISOString(),
673
+
674
+ // Team / session
675
+ teamId: sbx._teamId || null,
676
+ sessionId: currentInstance.getSessionId?.() || null,
677
+
678
+ // Test info
679
+ testFile: context.task.meta.testFile || null,
680
+ testName: context.task.name || null,
681
+ suiteName: context.task.suite?.name || null,
682
+
683
+ // Test result
684
+ testPassed: context.task.result?.state === "pass",
685
+ error: context.task.result?.errors?.[0]?.message || null,
686
+ errorStack: context.task.result?.errors?.[0]?.stack || null,
687
+
688
+ // Infrastructure
689
+ sandboxId: inst.sandboxId || inst.instanceId || null,
690
+ instanceId: inst.instanceId || null,
691
+ os: currentInstance.os || inst.os || null,
692
+ amiId: inst.amiId || null,
693
+ e2bTemplateId: inst.e2bTemplateId || null,
694
+ imageVersion: inst.imageVersion || null,
695
+
696
+ // Realtime
697
+ realtimeChannel: inst.channelName || sbx._channelName || null,
698
+ realtimeMessageCount: typeof sbx.getPublishCount === "function" ? sbx.getPublishCount() : 0,
699
+
700
+ // Interactions
701
+ interactions: currentInstance._interactionStats
702
+ ? { ...currentInstance._interactionStats, byType: { ...currentInstance._interactionStats.byType } }
703
+ : { total: 0, cached: 0, byType: {} },
704
+ };
705
+ }
706
+
652
707
  // Wait for connection to finish if it was initiated
653
708
  if (currentInstance.__connectionPromise) {
654
709
  await currentInstance.__connectionPromise.catch(() => { }); // Ignore connection errors during cleanup
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testdriverai",
3
- "version": "7.8.0-test.60",
3
+ "version": "7.8.0-test.62",
4
4
  "description": "Next generation autonomous AI agent for end-to-end testing of web & desktop",
5
5
  "main": "sdk.js",
6
6
  "types": "sdk.d.ts",
package/sdk.js CHANGED
@@ -623,6 +623,11 @@ class Element {
623
623
 
624
624
  // Track find interaction once at the end (fire-and-forget, don't block)
625
625
  const sessionId = this.sdk.getSessionId();
626
+ const findCacheHit = response?.cacheHit || response?.cache_hit || response?.cached || false;
627
+ // Increment local interaction counters
628
+ this.sdk._interactionStats.total++;
629
+ this.sdk._interactionStats.byType.find = (this.sdk._interactionStats.byType.find || 0) + 1;
630
+ if (findCacheHit) this.sdk._interactionStats.cached++;
626
631
  if (sessionId && this.sdk.apiClient) {
627
632
  this.sdk.apiClient
628
633
  .req("interaction/track", {
@@ -632,11 +637,7 @@ class Element {
632
637
  timestamp: absoluteTimestamp, // Absolute epoch timestamp - frontend calculates relative using clientStartDate
633
638
  success: this._found,
634
639
  error: findError,
635
- cacheHit:
636
- response?.cacheHit ||
637
- response?.cache_hit ||
638
- response?.cached ||
639
- false,
640
+ cacheHit: findCacheHit,
640
641
  selector: response?.selector,
641
642
  selectorUsed: !!response?.selector,
642
643
  confidence: response?.confidence ?? null,
@@ -1617,6 +1618,12 @@ class TestDriverSDK {
1617
1618
  // Uploaded to S3 at cleanup so they can be displayed alongside dashcam replays.
1618
1619
  this._logBuffer = [];
1619
1620
 
1621
+ // API version discovered by _logEnvironmentInfo()
1622
+ this._apiVersion = null;
1623
+
1624
+ // Local interaction counters — incremented at each interaction/track call site
1625
+ this._interactionStats = { total: 0, cached: 0, byType: {} };
1626
+
1620
1627
  // Set up event listeners once (they live for the lifetime of the SDK instance)
1621
1628
  this._setupLogging();
1622
1629
 
@@ -3201,6 +3208,11 @@ CAPTCHA_SOLVER_EOF`,
3201
3208
 
3202
3209
  // Track successful findAll interaction (fire-and-forget, don't block)
3203
3210
  const sessionId = this.getSessionId();
3211
+ const findAllCacheHit = response.cached || false;
3212
+ // Increment local interaction counters
3213
+ this._interactionStats.total++;
3214
+ this._interactionStats.byType.findAll = (this._interactionStats.byType.findAll || 0) + 1;
3215
+ if (findAllCacheHit) this._interactionStats.cached++;
3204
3216
  if (sessionId && this.apiClient) {
3205
3217
  this.apiClient
3206
3218
  .req("interaction/track", {
@@ -3210,7 +3222,7 @@ CAPTCHA_SOLVER_EOF`,
3210
3222
  timestamp: absoluteTimestamp, // Absolute epoch timestamp - frontend calculates relative using clientStartDate
3211
3223
  success: true,
3212
3224
  input: { count: elements.length },
3213
- cacheHit: response.cached || false,
3225
+ cacheHit: findAllCacheHit,
3214
3226
  selector: response.selector,
3215
3227
  selectorUsed: !!response.selector,
3216
3228
  screenshotUrl: response.screenshotKey ?? null,
@@ -3256,6 +3268,11 @@ CAPTCHA_SOLVER_EOF`,
3256
3268
 
3257
3269
  // No elements found - track interaction (fire-and-forget, don't block)
3258
3270
  const sessionId = this.getSessionId();
3271
+ const noResultCacheHit = response?.cached || false;
3272
+ // Increment local interaction counters
3273
+ this._interactionStats.total++;
3274
+ this._interactionStats.byType.findAll = (this._interactionStats.byType.findAll || 0) + 1;
3275
+ if (noResultCacheHit) this._interactionStats.cached++;
3259
3276
  if (sessionId && this.apiClient) {
3260
3277
  this.apiClient
3261
3278
  .req("interaction/track", {
@@ -3266,7 +3283,7 @@ CAPTCHA_SOLVER_EOF`,
3266
3283
  success: false,
3267
3284
  error: "No elements found",
3268
3285
  input: { count: 0 },
3269
- cacheHit: response?.cached || false,
3286
+ cacheHit: noResultCacheHit,
3270
3287
  selector: response?.selector,
3271
3288
  selectorUsed: !!response?.selector,
3272
3289
  screenshotUrl: response?.screenshotKey ?? null,
@@ -3300,6 +3317,9 @@ CAPTCHA_SOLVER_EOF`,
3300
3317
 
3301
3318
  // Track findAll error interaction (fire-and-forget, don't block)
3302
3319
  const sessionId = this.getSessionId();
3320
+ // Increment local interaction counters
3321
+ this._interactionStats.total++;
3322
+ this._interactionStats.byType.findAll = (this._interactionStats.byType.findAll || 0) + 1;
3303
3323
  if (sessionId && this.apiClient) {
3304
3324
  this.apiClient
3305
3325
  .req("interaction/track", {
@@ -3851,6 +3871,8 @@ CAPTCHA_SOLVER_EOF`,
3851
3871
  res.on('end', () => {
3852
3872
  try {
3853
3873
  const info = JSON.parse(data);
3874
+ // Persist API version for test result metadata
3875
+ this._apiVersion = info.version || null;
3854
3876
  const commit = info.commit || 'unknown';
3855
3877
  const shortCommit = commit.substring(0, 7);
3856
3878
  const commitUrl = commit !== 'unknown'