testdriverai 7.8.0-test.71 → 7.8.0-test.72
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/agent/lib/provision-commands.js +176 -0
- package/agent/lib/sandbox.js +69 -6
- package/lib/core/Dashcam.js +1 -17
- package/lib/vitest/hooks.mjs +54 -17
- package/package.json +1 -1
- package/vitest.config.mjs +4 -0
- package/docs/v7/_drafts/core.mdx +0 -458
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// sdk/agent/lib/provision-commands.js
|
|
2
|
+
// Canonical source of truth for sandbox-agent provisioning commands.
|
|
3
|
+
//
|
|
4
|
+
// These pure functions generate platform-specific command arrays for
|
|
5
|
+
// installing/updating the runner, writing agent config, and starting
|
|
6
|
+
// the sandbox agent. They are used by:
|
|
7
|
+
// - API: _provisionAgentCredentials (Windows SSM)
|
|
8
|
+
// - API: _createLinuxSandbox (E2B bash)
|
|
9
|
+
// - API: direct connection handler (returns commands for SDK to execute)
|
|
10
|
+
// - SDK: _sendSSMCommands (direct connection, client-side SSM)
|
|
11
|
+
//
|
|
12
|
+
// Published as part of the testdriverai npm package.
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Build the agent config object written to the sandbox.
|
|
18
|
+
*
|
|
19
|
+
* @param {Object} opts
|
|
20
|
+
* @param {string} opts.sandboxId
|
|
21
|
+
* @param {string} opts.apiRoot
|
|
22
|
+
* @param {string} [opts.apiKey]
|
|
23
|
+
* @param {string} [opts.sentryDsn]
|
|
24
|
+
* @param {string} [opts.sentryEnvironment]
|
|
25
|
+
* @param {string} [opts.sentryChannel]
|
|
26
|
+
* @param {Object} opts.ablyToken - Ably token object
|
|
27
|
+
* @param {string} opts.channelName - Ably channel name
|
|
28
|
+
* @returns {Object} Agent config to serialize as JSON
|
|
29
|
+
*/
|
|
30
|
+
function buildAgentConfig({ sandboxId, apiRoot, apiKey, sentryDsn, sentryEnvironment, sentryChannel, ablyToken, channelName }) {
|
|
31
|
+
return {
|
|
32
|
+
sandboxId,
|
|
33
|
+
apiRoot,
|
|
34
|
+
apiKey: apiKey || undefined,
|
|
35
|
+
sentryDsn: sentryDsn || undefined,
|
|
36
|
+
sentryEnvironment: sentryEnvironment || 'production',
|
|
37
|
+
sentryChannel: sentryChannel || undefined,
|
|
38
|
+
ably: {
|
|
39
|
+
token: ablyToken,
|
|
40
|
+
channel: channelName,
|
|
41
|
+
// Backward compat for old runners (<=7.5.x) that expect multi-channel format
|
|
42
|
+
channels: { commands: channelName, responses: channelName, control: channelName, files: channelName },
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Generate PowerShell commands to provision the sandbox agent on Windows.
|
|
49
|
+
*
|
|
50
|
+
* The returned array is suitable for SSM AWS-RunPowerShellScript Parameters.commands.
|
|
51
|
+
*
|
|
52
|
+
* @param {Object} opts
|
|
53
|
+
* @param {string} opts.channel - Release channel (dev|test|canary|stable)
|
|
54
|
+
* @param {string} opts.configJson - JSON.stringify'd agent config
|
|
55
|
+
* @param {string} opts.sandboxId - For logging
|
|
56
|
+
* @param {string} [opts.s3DownloadUrl] - S3 pre-signed URL for dev/test (omit for npm install)
|
|
57
|
+
* @returns {string[]} Array of PowerShell command strings
|
|
58
|
+
*/
|
|
59
|
+
function windowsProvisionCommands({ channel, configJson, sandboxId, s3DownloadUrl }) {
|
|
60
|
+
var useS3 = (channel === 'dev' || channel === 'test') && s3DownloadUrl;
|
|
61
|
+
var commands = [];
|
|
62
|
+
|
|
63
|
+
// ── 1. Stop old runner ────────────────────────────────────────────
|
|
64
|
+
commands.push(
|
|
65
|
+
"Write-Host 'Stopping old runner...'",
|
|
66
|
+
'Stop-ScheduledTask -TaskName RunTestDriverAgent -ErrorAction SilentlyContinue',
|
|
67
|
+
'Stop-Process -Name node -Force -ErrorAction SilentlyContinue',
|
|
68
|
+
"Remove-Item 'C:\\Windows\\Temp\\testdriver-agent.json' -Force -ErrorAction SilentlyContinue"
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
// ── 2. Install / update runner ────────────────────────────────────
|
|
72
|
+
commands.push("Set-Location 'C:\\testdriver\\sandbox-agent'");
|
|
73
|
+
|
|
74
|
+
var agentScript;
|
|
75
|
+
|
|
76
|
+
if (useS3) {
|
|
77
|
+
// Dev/test: download tarball from S3, extract, npm install deps
|
|
78
|
+
agentScript = 'sandbox-agent.js';
|
|
79
|
+
commands.push(
|
|
80
|
+
"Write-Host 'Downloading runner from S3 (" + channel + ")...'",
|
|
81
|
+
"$tarball = 'C:\\Windows\\Temp\\runner-dev.tgz'",
|
|
82
|
+
"Invoke-WebRequest -Uri '" + s3DownloadUrl + "' -OutFile $tarball",
|
|
83
|
+
"Write-Host 'Extracting runner...'",
|
|
84
|
+
"tar -xzf $tarball -C 'C:\\Windows\\Temp'",
|
|
85
|
+
"xcopy 'C:\\Windows\\Temp\\package\\*' 'C:\\testdriver\\sandbox-agent\\' /E /Y /I",
|
|
86
|
+
"Remove-Item 'C:\\Windows\\Temp\\package' -Recurse -Force -ErrorAction SilentlyContinue",
|
|
87
|
+
'Remove-Item $tarball -Force -ErrorAction SilentlyContinue',
|
|
88
|
+
'npm install --omit=dev 2>&1 | Write-Host',
|
|
89
|
+
"Write-Host 'Runner install complete (s3)'"
|
|
90
|
+
);
|
|
91
|
+
} else {
|
|
92
|
+
// Canary/stable (or dev/test without S3 URL): npm install by dist-tag
|
|
93
|
+
agentScript = 'node_modules/@testdriverai/runner/sandbox-agent.js';
|
|
94
|
+
var runnerTag = channel === 'stable' ? 'latest' : channel;
|
|
95
|
+
commands.push(
|
|
96
|
+
"Write-Host 'Installing @testdriverai/runner@" + runnerTag + "...'",
|
|
97
|
+
'npm install @testdriverai/runner@' + runnerTag + ' --omit=dev 2>&1 | Write-Host',
|
|
98
|
+
"Write-Host 'Runner install complete'"
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── 3. Regenerate run_testdriver.ps1 ──────────────────────────────
|
|
103
|
+
// Overwrites the baked-in script so the entry point matches the install layout.
|
|
104
|
+
// Uses [IO.File]::WriteAllText to avoid PowerShell variable expansion issues.
|
|
105
|
+
var scriptContent = [
|
|
106
|
+
"Write-Output 'Starting sandbox agent...'",
|
|
107
|
+
"Set-Location 'C:\\testdriver\\sandbox-agent'",
|
|
108
|
+
'while ($true) {',
|
|
109
|
+
' & node ' + agentScript + ' 2>&1 | Tee-Object -Append -FilePath C:\\testdriver\\logs\\sandbox-agent.log',
|
|
110
|
+
" Write-Output 'Agent exited, restarting in 2 seconds...'",
|
|
111
|
+
' Start-Sleep -Seconds 2',
|
|
112
|
+
'}',
|
|
113
|
+
].join('\r\n');
|
|
114
|
+
|
|
115
|
+
commands.push(
|
|
116
|
+
"Write-Host 'Regenerating run_testdriver.ps1...'",
|
|
117
|
+
"[IO.File]::WriteAllText('C:\\testdriver\\run_testdriver.ps1', '" + scriptContent.replace(/'/g, "''") + "')"
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
// ── 4. Write agent config ─────────────────────────────────────────
|
|
121
|
+
commands.push(
|
|
122
|
+
"Write-Host '=== Writing config ==='",
|
|
123
|
+
"$config = '" + configJson.replace(/'/g, "''") + "'",
|
|
124
|
+
"[System.IO.File]::WriteAllText('C:\\Windows\\Temp\\testdriver-agent.json', $config)",
|
|
125
|
+
"Write-Host 'Config written for sandbox " + sandboxId + "'"
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
// ── 5. Start runner ───────────────────────────────────────────────
|
|
129
|
+
commands.push(
|
|
130
|
+
'Start-Sleep -Seconds 1',
|
|
131
|
+
'Start-ScheduledTask -TaskName RunTestDriverAgent',
|
|
132
|
+
"Write-Host 'Runner started'"
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
return commands;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Generate the bash command to install/update the runner on Linux (E2B).
|
|
140
|
+
*
|
|
141
|
+
* @param {Object} opts
|
|
142
|
+
* @param {string} opts.channel - Release channel
|
|
143
|
+
* @param {string} [opts.s3DownloadUrl] - S3 pre-signed URL for dev/test
|
|
144
|
+
* @param {string} [opts.runnerPath] - Default '/opt/testdriver-runner'
|
|
145
|
+
* @returns {string} Single bash command (steps joined with &&)
|
|
146
|
+
*/
|
|
147
|
+
function linuxRunnerInstallCommand({ channel, s3DownloadUrl, runnerPath }) {
|
|
148
|
+
var rp = runnerPath || '/opt/testdriver-runner';
|
|
149
|
+
var useS3 = (channel === 'dev' || channel === 'test') && s3DownloadUrl;
|
|
150
|
+
var runnerTag = channel === 'stable' ? 'latest' : channel;
|
|
151
|
+
|
|
152
|
+
if (useS3) {
|
|
153
|
+
return [
|
|
154
|
+
'sudo rm -rf ' + rp,
|
|
155
|
+
'sudo mkdir -p ' + rp,
|
|
156
|
+
'sudo chown -R user:user ' + rp,
|
|
157
|
+
"curl -sL '" + s3DownloadUrl + "' -o /tmp/runner.tgz",
|
|
158
|
+
'tar -xzf /tmp/runner.tgz -C /tmp',
|
|
159
|
+
'cp -r /tmp/package/* ' + rp + '/',
|
|
160
|
+
'rm -rf /tmp/runner.tgz /tmp/package',
|
|
161
|
+
'cd ' + rp + ' && npm install --omit=dev --no-audit --no-fund --loglevel=error',
|
|
162
|
+
].join(' && ');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return [
|
|
166
|
+
'sudo npm install -g @testdriverai/runner@' + runnerTag + ' --omit=dev --no-audit --no-fund --loglevel=error',
|
|
167
|
+
'sudo rm -rf ' + rp,
|
|
168
|
+
'sudo ln -sf $(npm root -g)/@testdriverai/runner ' + rp,
|
|
169
|
+
].join(' && ');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
module.exports = {
|
|
173
|
+
buildAgentConfig,
|
|
174
|
+
windowsProvisionCommands,
|
|
175
|
+
linuxRunnerInstallCommand,
|
|
176
|
+
};
|
package/agent/lib/sandbox.js
CHANGED
|
@@ -712,19 +712,25 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
712
712
|
}
|
|
713
713
|
|
|
714
714
|
if (message.type === "direct") {
|
|
715
|
-
// If the API returned
|
|
716
|
-
//
|
|
715
|
+
// If the API returned provisioning commands and we have an instanceId,
|
|
716
|
+
// send them to the instance via SSM (client-side).
|
|
717
717
|
// This runs from the user's infrastructure where AWS permissions exist,
|
|
718
718
|
// rather than from the API server.
|
|
719
719
|
// NOTE: For direct connections, the user MUST provide the AWS instanceId
|
|
720
720
|
// because the API only knows the sandboxId, not the actual EC2 instance ID.
|
|
721
721
|
var instanceId = message.instanceId;
|
|
722
|
-
if (
|
|
723
|
-
|
|
722
|
+
if (instanceId && reply.provisionCommands) {
|
|
723
|
+
// New path: API generated full provisioning commands (runner install + config + start)
|
|
724
|
+
logger.log('Provisioning instance ' + instanceId + ' via SSM (API-generated commands)...');
|
|
725
|
+
await this._sendSSMCommands(instanceId, reply.provisionCommands);
|
|
726
|
+
logger.log('Instance provisioned successfully.');
|
|
727
|
+
} else if (reply.agentConfig && instanceId) {
|
|
728
|
+
// Fallback: older API that only returns agentConfig (config-only, no runner install)
|
|
729
|
+
logger.log('Provisioning agent config to instance ' + instanceId + ' via SSM (legacy)...');
|
|
724
730
|
await this._provisionAgentConfig(instanceId, reply.agentConfig);
|
|
725
731
|
logger.log('Agent config provisioned successfully.');
|
|
726
|
-
} else if (reply.agentConfig && !instanceId) {
|
|
727
|
-
logger.log('Warning: agentConfig returned but no instanceId provided - cannot provision via SSM');
|
|
732
|
+
} else if ((reply.agentConfig || reply.provisionCommands) && !instanceId) {
|
|
733
|
+
logger.log('Warning: agentConfig/provisionCommands returned but no instanceId provided - cannot provision via SSM');
|
|
728
734
|
}
|
|
729
735
|
|
|
730
736
|
// If the API returned agent credentials (reply.agent present),
|
|
@@ -1233,9 +1239,66 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
1233
1239
|
this.ps = {};
|
|
1234
1240
|
}
|
|
1235
1241
|
|
|
1242
|
+
/**
|
|
1243
|
+
* Send pre-generated PowerShell commands to an EC2 instance via AWS SSM.
|
|
1244
|
+
* The commands are generated by the API (sdk/agent/lib/provision-commands.js)
|
|
1245
|
+
* so provisioning logic lives in one place.
|
|
1246
|
+
*/
|
|
1247
|
+
async _sendSSMCommands(instanceId, commands) {
|
|
1248
|
+
const { execSync } = require('child_process');
|
|
1249
|
+
const { writeFileSync, unlinkSync } = require('fs');
|
|
1250
|
+
const { join } = require('path');
|
|
1251
|
+
const { tmpdir } = require('os');
|
|
1252
|
+
const { randomUUID } = require('crypto');
|
|
1253
|
+
|
|
1254
|
+
const region = process.env.AWS_REGION || 'us-east-2';
|
|
1255
|
+
const paramsJson = JSON.stringify({ commands: commands });
|
|
1256
|
+
const tmpFile = join(tmpdir(), 'td-provision-' + randomUUID() + '.json');
|
|
1257
|
+
writeFileSync(tmpFile, paramsJson);
|
|
1258
|
+
|
|
1259
|
+
try {
|
|
1260
|
+
const output = execSync(
|
|
1261
|
+
'aws ssm send-command --region "' + region + '" --instance-ids "' + instanceId + '" ' +
|
|
1262
|
+
'--document-name "AWS-RunPowerShellScript" ' +
|
|
1263
|
+
'--parameters file://' + tmpFile + ' --output json',
|
|
1264
|
+
{ encoding: 'utf-8', timeout: 30000 }
|
|
1265
|
+
);
|
|
1266
|
+
var cmdId = JSON.parse(output).Command.CommandId;
|
|
1267
|
+
logger.log('SSM command sent: ' + cmdId);
|
|
1268
|
+
|
|
1269
|
+
// Wait for the command to complete
|
|
1270
|
+
execSync(
|
|
1271
|
+
'aws ssm wait command-executed --region "' + region + '" ' +
|
|
1272
|
+
'--command-id "' + cmdId + '" --instance-id "' + instanceId + '"',
|
|
1273
|
+
{ encoding: 'utf-8', timeout: 300000 } // 5 min — runner install can take a while
|
|
1274
|
+
);
|
|
1275
|
+
|
|
1276
|
+
// Get the command output for debugging
|
|
1277
|
+
try {
|
|
1278
|
+
var invocationOutput = execSync(
|
|
1279
|
+
'aws ssm get-command-invocation --region "' + region + '" ' +
|
|
1280
|
+
'--command-id "' + cmdId + '" --instance-id "' + instanceId + '" --output json',
|
|
1281
|
+
{ encoding: 'utf-8', timeout: 30000 }
|
|
1282
|
+
);
|
|
1283
|
+
var invocation = JSON.parse(invocationOutput);
|
|
1284
|
+
if (invocation.StandardOutputContent) {
|
|
1285
|
+
logger.log('SSM output:\n' + invocation.StandardOutputContent);
|
|
1286
|
+
}
|
|
1287
|
+
if (invocation.StandardErrorContent) {
|
|
1288
|
+
logger.warn('SSM errors:\n' + invocation.StandardErrorContent);
|
|
1289
|
+
}
|
|
1290
|
+
} catch (e) {
|
|
1291
|
+
logger.warn('Could not retrieve SSM command output: ' + e.message);
|
|
1292
|
+
}
|
|
1293
|
+
} finally {
|
|
1294
|
+
try { unlinkSync(tmpFile); } catch (e) { /* ignore */ }
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1236
1298
|
/**
|
|
1237
1299
|
* Write the agent config JSON to an EC2 instance via AWS SSM.
|
|
1238
1300
|
* Runs client-side so the API doesn't need AWS permissions on user infra.
|
|
1301
|
+
* LEGACY: Used when connecting to an API that doesn't return provisionCommands.
|
|
1239
1302
|
*/
|
|
1240
1303
|
async _provisionAgentConfig(instanceId, agentConfig) {
|
|
1241
1304
|
const { execSync } = require('child_process');
|
package/lib/core/Dashcam.js
CHANGED
|
@@ -31,8 +31,7 @@ class Dashcam {
|
|
|
31
31
|
this.apiKey =
|
|
32
32
|
options.apiKey ||
|
|
33
33
|
client.apiKey ||
|
|
34
|
-
client.config?.TD_API_KEY
|
|
35
|
-
"4e93d8bf-3886-4d26-a144-116c4063522d";
|
|
34
|
+
client.config?.TD_API_KEY;
|
|
36
35
|
this.autoStart = options.autoStart ?? false;
|
|
37
36
|
this.logs = options.logs || [];
|
|
38
37
|
this.recording = false;
|
|
@@ -132,21 +131,6 @@ class Dashcam {
|
|
|
132
131
|
return `https://${prefix}-web.fly.dev`;
|
|
133
132
|
}
|
|
134
133
|
|
|
135
|
-
// Render PR previews: map API service to Web service
|
|
136
|
-
// canary-api-pr-123.onrender.com -> canary-web-pr-123.onrender.com
|
|
137
|
-
// testdriver-api-i4m4-pr-123.onrender.com -> web-i4m4-pr-123.onrender.com
|
|
138
|
-
const renderPrMatch = apiRoot.match(/https:\/\/([\w-]+)-api(-[\w]+)?(-pr-\d+)?\.onrender\.com/);
|
|
139
|
-
if (renderPrMatch) {
|
|
140
|
-
const [, prefix, suffix, prSuffix] = renderPrMatch;
|
|
141
|
-
let webPrefix;
|
|
142
|
-
if (prefix === 'testdriver' && suffix) {
|
|
143
|
-
webPrefix = 'web' + suffix;
|
|
144
|
-
} else {
|
|
145
|
-
webPrefix = prefix + '-web';
|
|
146
|
-
}
|
|
147
|
-
return `https://${webPrefix}${prSuffix || ''}.onrender.com`;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
134
|
// Cloudflare tunnels, custom domains, etc.: the web console is served
|
|
151
135
|
// from the same origin as the API, so return apiRoot as-is.
|
|
152
136
|
return apiRoot;
|
package/lib/vitest/hooks.mjs
CHANGED
|
@@ -181,18 +181,33 @@ function setupConsoleSpy(client, taskId) {
|
|
|
181
181
|
|
|
182
182
|
/**
|
|
183
183
|
* Unregister a client so its sandbox no longer receives forwarded logs.
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
* re-install
|
|
184
|
+
*
|
|
185
|
+
* Between sequential `it()` blocks we intentionally keep the spies installed.
|
|
186
|
+
* The `bufferConsoleToClients` function is a no-op when `activeClients` is
|
|
187
|
+
* empty, so leaving the spy in place is harmless and avoids a non-atomic
|
|
188
|
+
* restore/re-install race that can corrupt console method references.
|
|
189
|
+
*
|
|
190
|
+
* Spies are torn down once at process exit so the Vitest worker fork can
|
|
191
|
+
* shut down cleanly (unreleased vi.spyOn mocks prevent exit).
|
|
192
|
+
*
|
|
189
193
|
* @param {import('../../sdk.js').default} client - TestDriver client instance
|
|
190
194
|
*/
|
|
191
195
|
function cleanupConsoleSpy(client) {
|
|
192
196
|
_consoleSpy.activeClients.delete(client);
|
|
193
197
|
|
|
194
|
-
|
|
195
|
-
|
|
198
|
+
if (debugConsoleSpy) {
|
|
199
|
+
process.stdout.write(
|
|
200
|
+
`[DEBUG cleanupConsoleSpy] clients remaining: ${_consoleSpy.activeClients.size}\n`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Tear down the singleton console spy completely.
|
|
207
|
+
* Called once on process exit so the Vitest worker can shut down cleanly.
|
|
208
|
+
*/
|
|
209
|
+
function teardownConsoleSpy() {
|
|
210
|
+
if (_consoleSpy.spies) {
|
|
196
211
|
_consoleSpy.spies.log.mockRestore();
|
|
197
212
|
_consoleSpy.spies.error.mockRestore();
|
|
198
213
|
_consoleSpy.spies.warn.mockRestore();
|
|
@@ -202,21 +217,26 @@ function cleanupConsoleSpy(client) {
|
|
|
202
217
|
_consoleSpy.installed = false;
|
|
203
218
|
|
|
204
219
|
if (debugConsoleSpy) {
|
|
205
|
-
process.stdout.write("[DEBUG
|
|
220
|
+
process.stdout.write("[DEBUG teardownConsoleSpy] All spies restored\n");
|
|
206
221
|
}
|
|
207
222
|
}
|
|
208
|
-
|
|
209
|
-
if (debugConsoleSpy) {
|
|
210
|
-
process.stdout.write(
|
|
211
|
-
`[DEBUG cleanupConsoleSpy] clients remaining: ${_consoleSpy.activeClients.size}\n`,
|
|
212
|
-
);
|
|
213
|
-
}
|
|
214
223
|
}
|
|
215
224
|
|
|
225
|
+
// Restore console spies on process exit so the Vitest worker can exit cleanly
|
|
226
|
+
process.on("exit", teardownConsoleSpy);
|
|
227
|
+
|
|
216
228
|
// Weak maps to store instances per test context
|
|
217
229
|
const testDriverInstances = new WeakMap();
|
|
218
230
|
const lifecycleHandlers = new WeakMap();
|
|
219
231
|
|
|
232
|
+
/**
|
|
233
|
+
* Module-level promise tracking the most recent test's disconnect.
|
|
234
|
+
* When sequential `it()` blocks run, the next test awaits this promise
|
|
235
|
+
* before connecting — ensuring the previous sandbox is fully torn down
|
|
236
|
+
* even if the cleanup's disconnect timeout fired early.
|
|
237
|
+
*/
|
|
238
|
+
let _pendingDisconnect = null;
|
|
239
|
+
|
|
220
240
|
/**
|
|
221
241
|
* Upload buffered SDK + console logs directly to S3 via the existing Log system.
|
|
222
242
|
* Extracts the replayId from the dashcam URL, calls POST /api/v1/logs to create
|
|
@@ -436,6 +456,14 @@ export function TestDriver(context, options = {}) {
|
|
|
436
456
|
const debugConsoleSpy = process.env.TD_DEBUG_CONSOLE_SPY === "true";
|
|
437
457
|
|
|
438
458
|
testdriver.__connectionPromise = (async () => {
|
|
459
|
+
// Wait for any previous test's disconnect to fully complete.
|
|
460
|
+
// This prevents the new sandbox connection from racing with a
|
|
461
|
+
// lingering disconnect when sequential `it()` blocks run.
|
|
462
|
+
if (_pendingDisconnect) {
|
|
463
|
+
await _pendingDisconnect.catch(() => {});
|
|
464
|
+
_pendingDisconnect = null;
|
|
465
|
+
}
|
|
466
|
+
|
|
439
467
|
if (debugConsoleSpy) {
|
|
440
468
|
console.log(
|
|
441
469
|
"[DEBUG] Before auth - sandbox.instanceSocketConnected:",
|
|
@@ -709,10 +737,19 @@ export function TestDriver(context, options = {}) {
|
|
|
709
737
|
await currentInstance.__connectionPromise.catch(() => { }); // Ignore connection errors during cleanup
|
|
710
738
|
}
|
|
711
739
|
|
|
712
|
-
// Disconnect
|
|
740
|
+
// Disconnect — track the promise at module level so the *next* test
|
|
741
|
+
// can await it before connecting, even if the timeout fires first.
|
|
742
|
+
const disconnectPromise = currentInstance.disconnect().catch((err) => {
|
|
743
|
+
console.error("Error during disconnect:", err);
|
|
744
|
+
});
|
|
745
|
+
_pendingDisconnect = disconnectPromise;
|
|
746
|
+
|
|
747
|
+
// Allow up to 30 s for Ably presence leave / channel detach.
|
|
748
|
+
// If it takes longer, cleanup resolves but _pendingDisconnect
|
|
749
|
+
// keeps the reference so the next test still waits.
|
|
713
750
|
await Promise.race([
|
|
714
|
-
|
|
715
|
-
new Promise((resolve) => setTimeout(resolve,
|
|
751
|
+
disconnectPromise,
|
|
752
|
+
new Promise((resolve) => setTimeout(resolve, 30000)),
|
|
716
753
|
]);
|
|
717
754
|
} catch (error) {
|
|
718
755
|
console.error("Error disconnecting client:", error);
|
package/package.json
CHANGED
package/vitest.config.mjs
CHANGED
|
@@ -19,6 +19,10 @@ const sharedTestConfig = {
|
|
|
19
19
|
maxConcurrency: 100,
|
|
20
20
|
disableConsoleIntercept: false,
|
|
21
21
|
silent: false,
|
|
22
|
+
// Use child-process forks so each test FILE gets a completely clean
|
|
23
|
+
// Node.js process — no shared Ably connections, module-level singletons,
|
|
24
|
+
// or Sentry globals bleeding between files.
|
|
25
|
+
pool: "forks",
|
|
22
26
|
reporters: [
|
|
23
27
|
"verbose",
|
|
24
28
|
TestDriver()
|
package/docs/v7/_drafts/core.mdx
DELETED
|
@@ -1,458 +0,0 @@
|
|
|
1
|
-
# Core Classes
|
|
2
|
-
|
|
3
|
-
Direct access to TestDriver and Dashcam classes for full manual control.
|
|
4
|
-
|
|
5
|
-
## Overview
|
|
6
|
-
|
|
7
|
-
The core module provides the fundamental building blocks without any automatic lifecycle management. Use this when you need complete control or when working outside of Vitest.
|
|
8
|
-
|
|
9
|
-
```javascript
|
|
10
|
-
import { TestDriver, Dashcam } from 'testdriverai/core';
|
|
11
|
-
|
|
12
|
-
const client = new TestDriver(apiKey, { os: 'linux' });
|
|
13
|
-
await client.auth();
|
|
14
|
-
await client.connect();
|
|
15
|
-
|
|
16
|
-
const dashcam = new Dashcam(client);
|
|
17
|
-
await dashcam.start();
|
|
18
|
-
|
|
19
|
-
// Your test code
|
|
20
|
-
|
|
21
|
-
await dashcam.stop();
|
|
22
|
-
await client.disconnect();
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
## TestDriver Class
|
|
26
|
-
|
|
27
|
-
### Constructor
|
|
28
|
-
|
|
29
|
-
```javascript
|
|
30
|
-
import { TestDriver } from 'testdriverai/core';
|
|
31
|
-
|
|
32
|
-
const client = new TestDriver(apiKey, options);
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
**Parameters:**
|
|
36
|
-
- `apiKey` - Your TestDriver API key (string)
|
|
37
|
-
- `options` - Configuration object
|
|
38
|
-
|
|
39
|
-
**Options:**
|
|
40
|
-
```javascript
|
|
41
|
-
{
|
|
42
|
-
os: 'linux', // Target OS: 'linux', 'mac', 'windows'
|
|
43
|
-
apiRoot: 'https://...', // API endpoint
|
|
44
|
-
resolution: '1366x768', // Screen resolution
|
|
45
|
-
newSandbox: true, // Create new sandbox
|
|
46
|
-
analytics: true, // Enable analytics
|
|
47
|
-
cacheThresholds: {
|
|
48
|
-
find: 0.05,
|
|
49
|
-
findAll: 0.05
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
### Methods
|
|
55
|
-
|
|
56
|
-
#### auth()
|
|
57
|
-
Authenticate with TestDriver API.
|
|
58
|
-
|
|
59
|
-
```javascript
|
|
60
|
-
await client.auth();
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
#### connect(options)
|
|
64
|
-
Connect to a sandbox instance.
|
|
65
|
-
|
|
66
|
-
```javascript
|
|
67
|
-
await client.connect({
|
|
68
|
-
new: true // Create new sandbox (default: true)
|
|
69
|
-
});
|
|
70
|
-
```
|
|
71
|
-
|
|
72
|
-
#### disconnect()
|
|
73
|
-
Disconnect from sandbox and clean up.
|
|
74
|
-
|
|
75
|
-
```javascript
|
|
76
|
-
await client.disconnect();
|
|
77
|
-
```
|
|
78
|
-
|
|
79
|
-
#### find(query)
|
|
80
|
-
Find an element on screen.
|
|
81
|
-
|
|
82
|
-
```javascript
|
|
83
|
-
const element = await client.find('Login button');
|
|
84
|
-
await element.click();
|
|
85
|
-
|
|
86
|
-
// Or chain:
|
|
87
|
-
await client.find('Login button').then(el => el.click());
|
|
88
|
-
```
|
|
89
|
-
|
|
90
|
-
#### findAll(query)
|
|
91
|
-
Find all matching elements.
|
|
92
|
-
|
|
93
|
-
```javascript
|
|
94
|
-
const buttons = await client.findAll('button');
|
|
95
|
-
console.log('Found', buttons.length, 'buttons');
|
|
96
|
-
```
|
|
97
|
-
|
|
98
|
-
#### click(target)
|
|
99
|
-
Click an element.
|
|
100
|
-
|
|
101
|
-
```javascript
|
|
102
|
-
await client.click('Submit button');
|
|
103
|
-
```
|
|
104
|
-
|
|
105
|
-
#### type(text)
|
|
106
|
-
Type text (at current cursor position).
|
|
107
|
-
|
|
108
|
-
```javascript
|
|
109
|
-
await client.type('username@example.com');
|
|
110
|
-
```
|
|
111
|
-
|
|
112
|
-
#### pressKeys(keys)
|
|
113
|
-
Press keyboard keys or shortcuts.
|
|
114
|
-
|
|
115
|
-
```javascript
|
|
116
|
-
await client.pressKeys(['ctrl', 'a']);
|
|
117
|
-
await client.pressKeys(['enter']);
|
|
118
|
-
```
|
|
119
|
-
|
|
120
|
-
#### exec(shell, command, timeout, ignoreError)
|
|
121
|
-
Execute shell command.
|
|
122
|
-
|
|
123
|
-
```javascript
|
|
124
|
-
const output = await client.exec(
|
|
125
|
-
'sh', // Shell: 'sh' or 'pwsh'
|
|
126
|
-
'google-chrome "https://..." &', // Command
|
|
127
|
-
30000, // Timeout (ms)
|
|
128
|
-
false // Ignore errors
|
|
129
|
-
);
|
|
130
|
-
```
|
|
131
|
-
|
|
132
|
-
#### focusApplication(appName)
|
|
133
|
-
Focus/activate an application window.
|
|
134
|
-
|
|
135
|
-
```javascript
|
|
136
|
-
await client.focusApplication('Google Chrome');
|
|
137
|
-
await client.focusApplication('Visual Studio Code');
|
|
138
|
-
```
|
|
139
|
-
|
|
140
|
-
#### scroll(direction, amount)
|
|
141
|
-
Scroll the page.
|
|
142
|
-
|
|
143
|
-
```javascript
|
|
144
|
-
await client.scroll('down', 500);
|
|
145
|
-
await client.scroll('up', 200);
|
|
146
|
-
```
|
|
147
|
-
|
|
148
|
-
#### assert(query)
|
|
149
|
-
Assert that something is true on screen.
|
|
150
|
-
|
|
151
|
-
```javascript
|
|
152
|
-
const result = await client.assert('Login successful message appears');
|
|
153
|
-
// Returns boolean
|
|
154
|
-
```
|
|
155
|
-
|
|
156
|
-
## Dashcam Class
|
|
157
|
-
|
|
158
|
-
### Constructor
|
|
159
|
-
|
|
160
|
-
```javascript
|
|
161
|
-
import { Dashcam } from 'testdriverai/core';
|
|
162
|
-
|
|
163
|
-
const dashcam = new Dashcam(client, options);
|
|
164
|
-
```
|
|
165
|
-
|
|
166
|
-
**Parameters:**
|
|
167
|
-
- `client` - TestDriver instance (required)
|
|
168
|
-
- `options` - Configuration object (optional)
|
|
169
|
-
|
|
170
|
-
**Options:**
|
|
171
|
-
```javascript
|
|
172
|
-
{
|
|
173
|
-
apiKey: process.env.TD_API_KEY // API key (same as TestDriver)
|
|
174
|
-
}
|
|
175
|
-
```
|
|
176
|
-
|
|
177
|
-
### Methods
|
|
178
|
-
|
|
179
|
-
#### auth(apiKey)
|
|
180
|
-
Authenticate with Dashcam CLI.
|
|
181
|
-
|
|
182
|
-
```javascript
|
|
183
|
-
await dashcam.auth(); // Uses TD_API_KEY env var (same as TestDriver)
|
|
184
|
-
// Or:
|
|
185
|
-
await dashcam.auth('your-api-key');
|
|
186
|
-
```
|
|
187
|
-
|
|
188
|
-
#### start()
|
|
189
|
-
Start recording.
|
|
190
|
-
|
|
191
|
-
```javascript
|
|
192
|
-
await dashcam.start();
|
|
193
|
-
```
|
|
194
|
-
|
|
195
|
-
#### stop()
|
|
196
|
-
Stop recording and get replay URL.
|
|
197
|
-
|
|
198
|
-
```javascript
|
|
199
|
-
const url = await dashcam.stop();
|
|
200
|
-
console.log('Replay:', url);
|
|
201
|
-
// Returns: https://app.dashcam.io/replay/...
|
|
202
|
-
```
|
|
203
|
-
|
|
204
|
-
#### isRecording()
|
|
205
|
-
Check if currently recording.
|
|
206
|
-
|
|
207
|
-
```javascript
|
|
208
|
-
if (dashcam.isRecording()) {
|
|
209
|
-
console.log('Recording in progress');
|
|
210
|
-
}
|
|
211
|
-
```
|
|
212
|
-
|
|
213
|
-
#### addFileLog(path, name)
|
|
214
|
-
Add a file to Dashcam logs.
|
|
215
|
-
|
|
216
|
-
```javascript
|
|
217
|
-
await dashcam.addFileLog('/var/log/app.log', 'Application Log');
|
|
218
|
-
```
|
|
219
|
-
|
|
220
|
-
#### addApplicationLog(application, name)
|
|
221
|
-
Track an application in Dashcam.
|
|
222
|
-
|
|
223
|
-
```javascript
|
|
224
|
-
await dashcam.addApplicationLog('Google Chrome', 'Browser');
|
|
225
|
-
```
|
|
226
|
-
|
|
227
|
-
## Complete Examples
|
|
228
|
-
|
|
229
|
-
### Basic Test
|
|
230
|
-
|
|
231
|
-
```javascript
|
|
232
|
-
import { TestDriver } from 'testdriverai/core';
|
|
233
|
-
|
|
234
|
-
async function runTest() {
|
|
235
|
-
const client = new TestDriver(process.env.TD_API_KEY, {
|
|
236
|
-
os: 'linux',
|
|
237
|
-
resolution: '1920x1080'
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
try {
|
|
241
|
-
// Connect
|
|
242
|
-
await client.auth();
|
|
243
|
-
await client.connect({ new: true });
|
|
244
|
-
|
|
245
|
-
// Focus browser
|
|
246
|
-
await client.focusApplication('Google Chrome');
|
|
247
|
-
|
|
248
|
-
// Navigate
|
|
249
|
-
const urlBar = await client.find('URL bar');
|
|
250
|
-
await urlBar.click();
|
|
251
|
-
await client.type('https://example.com');
|
|
252
|
-
await client.pressKeys(['enter']);
|
|
253
|
-
|
|
254
|
-
// Test
|
|
255
|
-
await client.find('heading').click();
|
|
256
|
-
const result = await client.assert('page loaded successfully');
|
|
257
|
-
|
|
258
|
-
console.log('Test passed:', result);
|
|
259
|
-
} finally {
|
|
260
|
-
// Always disconnect
|
|
261
|
-
await client.disconnect();
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
runTest();
|
|
266
|
-
```
|
|
267
|
-
|
|
268
|
-
### With Dashcam
|
|
269
|
-
|
|
270
|
-
```javascript
|
|
271
|
-
import { TestDriver, Dashcam } from 'testdriverai/core';
|
|
272
|
-
|
|
273
|
-
async function runRecordedTest() {
|
|
274
|
-
const client = new TestDriver(process.env.TD_API_KEY, { os: 'linux' });
|
|
275
|
-
const dashcam = new Dashcam(client);
|
|
276
|
-
|
|
277
|
-
try {
|
|
278
|
-
// Setup
|
|
279
|
-
await client.auth();
|
|
280
|
-
await client.connect();
|
|
281
|
-
|
|
282
|
-
// Start recording
|
|
283
|
-
await dashcam.auth();
|
|
284
|
-
await dashcam.start();
|
|
285
|
-
|
|
286
|
-
// Run test
|
|
287
|
-
await client.focusApplication('Google Chrome');
|
|
288
|
-
await client.find('button').click();
|
|
289
|
-
|
|
290
|
-
// Stop recording
|
|
291
|
-
const url = await dashcam.stop();
|
|
292
|
-
console.log('Replay URL:', url);
|
|
293
|
-
|
|
294
|
-
} finally {
|
|
295
|
-
await client.disconnect();
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
runRecordedTest();
|
|
300
|
-
```
|
|
301
|
-
|
|
302
|
-
### Multiple Operations
|
|
303
|
-
|
|
304
|
-
```javascript
|
|
305
|
-
import { TestDriver } from 'testdriverai/core';
|
|
306
|
-
|
|
307
|
-
async function complexTest() {
|
|
308
|
-
const client = new TestDriver(process.env.TD_API_KEY, { os: 'linux' });
|
|
309
|
-
|
|
310
|
-
await client.auth();
|
|
311
|
-
await client.connect();
|
|
312
|
-
|
|
313
|
-
try {
|
|
314
|
-
// Launch application
|
|
315
|
-
await client.exec(
|
|
316
|
-
'sh',
|
|
317
|
-
'google-chrome --start-maximized "https://example.com" &',
|
|
318
|
-
30000
|
|
319
|
-
);
|
|
320
|
-
|
|
321
|
-
await client.focusApplication('Google Chrome');
|
|
322
|
-
|
|
323
|
-
// Fill form
|
|
324
|
-
await client.find('username field').type('user@example.com');
|
|
325
|
-
await client.find('password field').type('password123');
|
|
326
|
-
await client.find('submit button').click();
|
|
327
|
-
|
|
328
|
-
// Navigate
|
|
329
|
-
await client.find('dashboard link').click();
|
|
330
|
-
|
|
331
|
-
// Scroll and interact
|
|
332
|
-
await client.scroll('down', 500);
|
|
333
|
-
await client.find('settings button').click();
|
|
334
|
-
|
|
335
|
-
// Verify
|
|
336
|
-
const result = await client.assert('settings page is visible');
|
|
337
|
-
console.log('Test result:', result);
|
|
338
|
-
|
|
339
|
-
} finally {
|
|
340
|
-
await client.disconnect();
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
complexTest();
|
|
345
|
-
```
|
|
346
|
-
|
|
347
|
-
### Error Handling
|
|
348
|
-
|
|
349
|
-
```javascript
|
|
350
|
-
import { TestDriver } from 'testdriverai/core';
|
|
351
|
-
|
|
352
|
-
async function testWithErrorHandling() {
|
|
353
|
-
const client = new TestDriver(process.env.TD_API_KEY, { os: 'linux' });
|
|
354
|
-
|
|
355
|
-
try {
|
|
356
|
-
await client.auth();
|
|
357
|
-
await client.connect();
|
|
358
|
-
|
|
359
|
-
await client.focusApplication('Google Chrome');
|
|
360
|
-
|
|
361
|
-
// Try to find element
|
|
362
|
-
const element = await client.find('optional button');
|
|
363
|
-
|
|
364
|
-
if (element.found()) {
|
|
365
|
-
await element.click();
|
|
366
|
-
} else {
|
|
367
|
-
console.log('Button not found, continuing...');
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
} catch (error) {
|
|
371
|
-
console.error('Test failed:', error);
|
|
372
|
-
throw error;
|
|
373
|
-
} finally {
|
|
374
|
-
// Always cleanup
|
|
375
|
-
try {
|
|
376
|
-
await client.disconnect();
|
|
377
|
-
} catch (cleanupError) {
|
|
378
|
-
console.error('Cleanup error:', cleanupError);
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
```
|
|
383
|
-
|
|
384
|
-
## TypeScript Support
|
|
385
|
-
|
|
386
|
-
```typescript
|
|
387
|
-
import { TestDriver, Dashcam, TestDriverOptions, DashcamOptions } from 'testdriverai/core';
|
|
388
|
-
|
|
389
|
-
const options: TestDriverOptions = {
|
|
390
|
-
os: 'linux',
|
|
391
|
-
resolution: '1366x768',
|
|
392
|
-
analytics: true
|
|
393
|
-
};
|
|
394
|
-
|
|
395
|
-
const client = new TestDriver(process.env.TD_API_KEY!, options);
|
|
396
|
-
|
|
397
|
-
const dashcamOptions: DashcamOptions = {
|
|
398
|
-
apiKey: process.env.TD_API_KEY // Same as TestDriver
|
|
399
|
-
};
|
|
400
|
-
|
|
401
|
-
const dashcam = new Dashcam(client, dashcamOptions);
|
|
402
|
-
```
|
|
403
|
-
|
|
404
|
-
## When to Use Core Classes
|
|
405
|
-
|
|
406
|
-
**Use core classes when:**
|
|
407
|
-
- You need full manual control
|
|
408
|
-
- You're not using Vitest
|
|
409
|
-
- You're integrating with another test framework
|
|
410
|
-
- You're building custom abstractions
|
|
411
|
-
- You're debugging lifecycle issues
|
|
412
|
-
- You're writing scripts, not tests
|
|
413
|
-
|
|
414
|
-
**Use hooks or provision() instead when:**
|
|
415
|
-
- You're using Vitest
|
|
416
|
-
- You want automatic cleanup
|
|
417
|
-
- You prefer simpler APIs
|
|
418
|
-
- You're testing common applications
|
|
419
|
-
|
|
420
|
-
## Best Practices
|
|
421
|
-
|
|
422
|
-
1. **Always disconnect** - Use try/finally to ensure cleanup
|
|
423
|
-
2. **Check element.found()** - Before using optional elements
|
|
424
|
-
3. **Handle errors gracefully** - Log and re-throw when appropriate
|
|
425
|
-
4. **Use TypeScript** - Get type safety and autocomplete
|
|
426
|
-
5. **Set reasonable timeouts** - Default is 30 seconds for exec()
|
|
427
|
-
6. **Focus applications** - Before interacting with them
|
|
428
|
-
|
|
429
|
-
## Platform Differences
|
|
430
|
-
|
|
431
|
-
### Shell Commands
|
|
432
|
-
|
|
433
|
-
**Linux/Mac:**
|
|
434
|
-
```javascript
|
|
435
|
-
await client.exec('sh', 'google-chrome "https://example.com" &', 30000);
|
|
436
|
-
```
|
|
437
|
-
|
|
438
|
-
**Windows:**
|
|
439
|
-
```javascript
|
|
440
|
-
await client.exec(
|
|
441
|
-
'pwsh',
|
|
442
|
-
'Start-Process "C:/Program Files/Google/Chrome/Application/chrome.exe" -ArgumentList "https://example.com"',
|
|
443
|
-
30000
|
|
444
|
-
);
|
|
445
|
-
```
|
|
446
|
-
|
|
447
|
-
### Application Names
|
|
448
|
-
|
|
449
|
-
- Linux: `'Google Chrome'`, `'Firefox'`, `'Visual Studio Code'`
|
|
450
|
-
- Mac: `'Google Chrome'`, `'Firefox'`, `'Visual Studio Code'`
|
|
451
|
-
- Windows: `'Google Chrome'`, `'Firefox'`, `'Visual Studio Code'`
|
|
452
|
-
|
|
453
|
-
## See Also
|
|
454
|
-
|
|
455
|
-
- [Provision API](./PROVISION.md) - Simplified API for common apps
|
|
456
|
-
- [Hooks API](./HOOKS.md) - Automatic lifecycle management
|
|
457
|
-
- [Migration Guide](../MIGRATION.md) - Upgrading from v6
|
|
458
|
-
- [API Reference](../../sdk.js) - Full source code
|