testblocks-agent 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +76 -0
- package/dist/__tests__/unit/client.test.d.ts +1 -0
- package/dist/__tests__/unit/client.test.js +429 -0
- package/dist/__tests__/unit/client.test.js.map +1 -0
- package/dist/__tests__/unit/config.test.d.ts +1 -0
- package/dist/__tests__/unit/config.test.js +387 -0
- package/dist/__tests__/unit/config.test.js.map +1 -0
- package/dist/__tests__/unit/executor.test.d.ts +1 -0
- package/dist/__tests__/unit/executor.test.js +1017 -0
- package/dist/__tests__/unit/executor.test.js.map +1 -0
- package/dist/__tests__/unit/index.test.d.ts +1 -0
- package/dist/__tests__/unit/index.test.js +406 -0
- package/dist/__tests__/unit/index.test.js.map +1 -0
- package/dist/client.d.ts +71 -0
- package/dist/client.js +66 -0
- package/dist/client.js.map +1 -0
- package/dist/config.d.ts +9 -0
- package/dist/config.js +96 -0
- package/dist/config.js.map +1 -0
- package/dist/executor.d.ts +36 -0
- package/dist/executor.js +375 -0
- package/dist/executor.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +151 -0
- package/dist/index.js.map +1 -0
- package/package.json +44 -0
|
@@ -0,0 +1,1017 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
const vitest_1 = require("vitest");
|
|
40
|
+
const path_1 = __importDefault(require("path"));
|
|
41
|
+
const executor_1 = require("../../executor");
|
|
42
|
+
(0, vitest_1.describe)('browserToDevice', () => {
|
|
43
|
+
(0, vitest_1.it)('maps "chrome" to "Desktop Chrome"', () => {
|
|
44
|
+
(0, vitest_1.expect)((0, executor_1.browserToDevice)('chrome')).toBe('Desktop Chrome');
|
|
45
|
+
});
|
|
46
|
+
(0, vitest_1.it)('maps "firefox" to "Desktop Firefox"', () => {
|
|
47
|
+
(0, vitest_1.expect)((0, executor_1.browserToDevice)('firefox')).toBe('Desktop Firefox');
|
|
48
|
+
});
|
|
49
|
+
(0, vitest_1.it)('maps "webkit" to "Desktop Safari"', () => {
|
|
50
|
+
(0, vitest_1.expect)((0, executor_1.browserToDevice)('webkit')).toBe('Desktop Safari');
|
|
51
|
+
});
|
|
52
|
+
(0, vitest_1.it)('maps unknown browser to "Desktop Chrome" (default)', () => {
|
|
53
|
+
(0, vitest_1.expect)((0, executor_1.browserToDevice)('edge')).toBe('Desktop Chrome');
|
|
54
|
+
(0, vitest_1.expect)((0, executor_1.browserToDevice)('')).toBe('Desktop Chrome');
|
|
55
|
+
(0, vitest_1.expect)((0, executor_1.browserToDevice)('safari')).toBe('Desktop Chrome');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
(0, vitest_1.describe)('generateRunnerConfig', () => {
|
|
59
|
+
const baseConfig = {
|
|
60
|
+
browser: 'chrome',
|
|
61
|
+
headless: true,
|
|
62
|
+
viewportWidth: 1280,
|
|
63
|
+
viewportHeight: 720,
|
|
64
|
+
timeout: 30000,
|
|
65
|
+
retries: 0,
|
|
66
|
+
screenshots: 'off',
|
|
67
|
+
video: 'off',
|
|
68
|
+
trace: 'off',
|
|
69
|
+
};
|
|
70
|
+
(0, vitest_1.it)('generates valid Playwright config string', () => {
|
|
71
|
+
const result = (0, executor_1.generateRunnerConfig)(baseConfig);
|
|
72
|
+
(0, vitest_1.expect)(result).toContain("import { defineConfig, devices } from '@playwright/test'");
|
|
73
|
+
(0, vitest_1.expect)(result).toContain("import dotenv from 'dotenv'");
|
|
74
|
+
(0, vitest_1.expect)(result).toContain('dotenv.config()');
|
|
75
|
+
(0, vitest_1.expect)(result).toContain('export default defineConfig({');
|
|
76
|
+
(0, vitest_1.expect)(result).toContain("testDir: './'");
|
|
77
|
+
(0, vitest_1.expect)(result).toContain('timeout: 30000');
|
|
78
|
+
(0, vitest_1.expect)(result).toContain('retries: 0');
|
|
79
|
+
(0, vitest_1.expect)(result).toContain('fullyParallel: false');
|
|
80
|
+
(0, vitest_1.expect)(result).toContain('workers: 1');
|
|
81
|
+
(0, vitest_1.expect)(result).toContain('headless: true');
|
|
82
|
+
(0, vitest_1.expect)(result).toContain('viewport: { width: 1280, height: 720 }');
|
|
83
|
+
(0, vitest_1.expect)(result).toContain("screenshot: 'off'");
|
|
84
|
+
(0, vitest_1.expect)(result).toContain("video: 'off'");
|
|
85
|
+
(0, vitest_1.expect)(result).toContain("trace: 'off'");
|
|
86
|
+
(0, vitest_1.expect)(result).toContain("baseURL: process.env.BASE_URL || undefined");
|
|
87
|
+
});
|
|
88
|
+
(0, vitest_1.it)('includes correct browser project name', () => {
|
|
89
|
+
const result = (0, executor_1.generateRunnerConfig)(baseConfig);
|
|
90
|
+
(0, vitest_1.expect)(result).toContain("name: 'chrome'");
|
|
91
|
+
(0, vitest_1.expect)(result).toContain("...devices['Desktop Chrome']");
|
|
92
|
+
});
|
|
93
|
+
(0, vitest_1.it)('uses Desktop Firefox device for firefox browser', () => {
|
|
94
|
+
const config = { ...baseConfig, browser: 'firefox' };
|
|
95
|
+
const result = (0, executor_1.generateRunnerConfig)(config);
|
|
96
|
+
(0, vitest_1.expect)(result).toContain("name: 'firefox'");
|
|
97
|
+
(0, vitest_1.expect)(result).toContain("...devices['Desktop Firefox']");
|
|
98
|
+
});
|
|
99
|
+
(0, vitest_1.it)('uses Desktop Safari device for webkit browser', () => {
|
|
100
|
+
const config = { ...baseConfig, browser: 'webkit' };
|
|
101
|
+
const result = (0, executor_1.generateRunnerConfig)(config);
|
|
102
|
+
(0, vitest_1.expect)(result).toContain("name: 'webkit'");
|
|
103
|
+
(0, vitest_1.expect)(result).toContain("...devices['Desktop Safari']");
|
|
104
|
+
});
|
|
105
|
+
(0, vitest_1.it)('respects headless false', () => {
|
|
106
|
+
const config = { ...baseConfig, headless: false };
|
|
107
|
+
const result = (0, executor_1.generateRunnerConfig)(config);
|
|
108
|
+
(0, vitest_1.expect)(result).toContain('headless: false');
|
|
109
|
+
});
|
|
110
|
+
(0, vitest_1.it)('includes JSON reporter config', () => {
|
|
111
|
+
const result = (0, executor_1.generateRunnerConfig)(baseConfig);
|
|
112
|
+
(0, vitest_1.expect)(result).toContain("reporter: [['json', { outputFile: 'report.json' }], ['line']]");
|
|
113
|
+
});
|
|
114
|
+
(0, vitest_1.it)('uses custom timeout and retries', () => {
|
|
115
|
+
const config = { ...baseConfig, timeout: 60000, retries: 2 };
|
|
116
|
+
const result = (0, executor_1.generateRunnerConfig)(config);
|
|
117
|
+
(0, vitest_1.expect)(result).toContain('timeout: 60000');
|
|
118
|
+
(0, vitest_1.expect)(result).toContain('retries: 2');
|
|
119
|
+
});
|
|
120
|
+
(0, vitest_1.it)('uses custom viewport dimensions', () => {
|
|
121
|
+
const config = { ...baseConfig, viewportWidth: 1920, viewportHeight: 1080 };
|
|
122
|
+
const result = (0, executor_1.generateRunnerConfig)(config);
|
|
123
|
+
(0, vitest_1.expect)(result).toContain('viewport: { width: 1920, height: 1080 }');
|
|
124
|
+
});
|
|
125
|
+
(0, vitest_1.it)('configures screenshot, video, and trace modes', () => {
|
|
126
|
+
const config = {
|
|
127
|
+
...baseConfig,
|
|
128
|
+
screenshots: 'on',
|
|
129
|
+
video: 'retain-on-failure',
|
|
130
|
+
trace: 'on-first-retry',
|
|
131
|
+
};
|
|
132
|
+
const result = (0, executor_1.generateRunnerConfig)(config);
|
|
133
|
+
(0, vitest_1.expect)(result).toContain("screenshot: 'on'");
|
|
134
|
+
(0, vitest_1.expect)(result).toContain("video: 'retain-on-failure'");
|
|
135
|
+
(0, vitest_1.expect)(result).toContain("trace: 'on-first-retry'");
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
(0, vitest_1.describe)('sanitizeError', () => {
|
|
139
|
+
(0, vitest_1.it)('returns plain text unchanged (when under limit)', () => {
|
|
140
|
+
const msg = 'Something went wrong';
|
|
141
|
+
(0, vitest_1.expect)((0, executor_1.sanitizeError)(msg)).toBe('Something went wrong');
|
|
142
|
+
});
|
|
143
|
+
(0, vitest_1.it)('strips ANSI escape codes', () => {
|
|
144
|
+
const msg = '\x1b[31mError:\x1b[0m test failed \x1b[1m(critical)\x1b[0m';
|
|
145
|
+
(0, vitest_1.expect)((0, executor_1.sanitizeError)(msg)).toBe('Error: test failed (critical)');
|
|
146
|
+
});
|
|
147
|
+
(0, vitest_1.it)('truncates messages longer than 300 characters', () => {
|
|
148
|
+
const msg = 'A'.repeat(400);
|
|
149
|
+
const result = (0, executor_1.sanitizeError)(msg);
|
|
150
|
+
(0, vitest_1.expect)(result).toHaveLength(303); // 300 + '...'
|
|
151
|
+
(0, vitest_1.expect)(result.endsWith('...')).toBe(true);
|
|
152
|
+
(0, vitest_1.expect)(result).toBe('A'.repeat(300) + '...');
|
|
153
|
+
});
|
|
154
|
+
(0, vitest_1.it)('does not truncate messages at exactly 300 characters', () => {
|
|
155
|
+
const msg = 'B'.repeat(300);
|
|
156
|
+
const result = (0, executor_1.sanitizeError)(msg);
|
|
157
|
+
(0, vitest_1.expect)(result).toBe('B'.repeat(300));
|
|
158
|
+
(0, vitest_1.expect)(result).not.toContain('...');
|
|
159
|
+
});
|
|
160
|
+
(0, vitest_1.it)('strips ANSI codes before truncating', () => {
|
|
161
|
+
// ANSI codes take up space in the original string but not after stripping
|
|
162
|
+
const ansiPrefix = '\x1b[31m'; // 5 chars
|
|
163
|
+
const msg = ansiPrefix + 'X'.repeat(250);
|
|
164
|
+
const result = (0, executor_1.sanitizeError)(msg);
|
|
165
|
+
// After stripping ANSI, the clean string is 250 chars -- under limit
|
|
166
|
+
(0, vitest_1.expect)(result).toBe('X'.repeat(250));
|
|
167
|
+
});
|
|
168
|
+
(0, vitest_1.it)('handles empty string', () => {
|
|
169
|
+
(0, vitest_1.expect)((0, executor_1.sanitizeError)('')).toBe('');
|
|
170
|
+
});
|
|
171
|
+
(0, vitest_1.it)('handles multiple ANSI sequences', () => {
|
|
172
|
+
const msg = '\x1b[1m\x1b[31mBold Red\x1b[0m normal \x1b[32mgreen\x1b[0m';
|
|
173
|
+
(0, vitest_1.expect)((0, executor_1.sanitizeError)(msg)).toBe('Bold Red normal green');
|
|
174
|
+
});
|
|
175
|
+
(0, vitest_1.it)('strips complex ANSI codes with multiple parameters', () => {
|
|
176
|
+
const msg = '\x1b[38;5;196mcolored text\x1b[0m';
|
|
177
|
+
(0, vitest_1.expect)((0, executor_1.sanitizeError)(msg)).toBe('colored text');
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
(0, vitest_1.describe)('flattenSpecs', () => {
|
|
181
|
+
(0, vitest_1.it)('returns specs from a flat suite array', () => {
|
|
182
|
+
const suites = [
|
|
183
|
+
{ specs: [{ title: 'test1' }, { title: 'test2' }] },
|
|
184
|
+
{ specs: [{ title: 'test3' }] },
|
|
185
|
+
];
|
|
186
|
+
const result = (0, executor_1.flattenSpecs)(suites);
|
|
187
|
+
(0, vitest_1.expect)(result).toEqual([
|
|
188
|
+
{ title: 'test1' },
|
|
189
|
+
{ title: 'test2' },
|
|
190
|
+
{ title: 'test3' },
|
|
191
|
+
]);
|
|
192
|
+
});
|
|
193
|
+
(0, vitest_1.it)('flattens nested suites recursively', () => {
|
|
194
|
+
const suites = [
|
|
195
|
+
{
|
|
196
|
+
specs: [{ title: 'top-level' }],
|
|
197
|
+
suites: [
|
|
198
|
+
{
|
|
199
|
+
specs: [{ title: 'nested' }],
|
|
200
|
+
suites: [
|
|
201
|
+
{ specs: [{ title: 'deeply-nested' }] },
|
|
202
|
+
],
|
|
203
|
+
},
|
|
204
|
+
],
|
|
205
|
+
},
|
|
206
|
+
];
|
|
207
|
+
const result = (0, executor_1.flattenSpecs)(suites);
|
|
208
|
+
(0, vitest_1.expect)(result).toEqual([
|
|
209
|
+
{ title: 'top-level' },
|
|
210
|
+
{ title: 'nested' },
|
|
211
|
+
{ title: 'deeply-nested' },
|
|
212
|
+
]);
|
|
213
|
+
});
|
|
214
|
+
(0, vitest_1.it)('returns empty array for empty suites', () => {
|
|
215
|
+
(0, vitest_1.expect)((0, executor_1.flattenSpecs)([])).toEqual([]);
|
|
216
|
+
});
|
|
217
|
+
(0, vitest_1.it)('handles suites with no specs property', () => {
|
|
218
|
+
const suites = [
|
|
219
|
+
{ suites: [{ specs: [{ title: 'inner' }] }] },
|
|
220
|
+
];
|
|
221
|
+
const result = (0, executor_1.flattenSpecs)(suites);
|
|
222
|
+
(0, vitest_1.expect)(result).toEqual([{ title: 'inner' }]);
|
|
223
|
+
});
|
|
224
|
+
(0, vitest_1.it)('handles suites with no nested suites', () => {
|
|
225
|
+
const suites = [
|
|
226
|
+
{ specs: [{ title: 'only-specs' }] },
|
|
227
|
+
];
|
|
228
|
+
const result = (0, executor_1.flattenSpecs)(suites);
|
|
229
|
+
(0, vitest_1.expect)(result).toEqual([{ title: 'only-specs' }]);
|
|
230
|
+
});
|
|
231
|
+
(0, vitest_1.it)('handles mix of suites with and without specs', () => {
|
|
232
|
+
const suites = [
|
|
233
|
+
{ specs: [{ title: 'a' }] },
|
|
234
|
+
{ suites: [{ specs: [{ title: 'b' }] }] },
|
|
235
|
+
{ specs: [{ title: 'c' }], suites: [{ specs: [{ title: 'd' }] }] },
|
|
236
|
+
];
|
|
237
|
+
const result = (0, executor_1.flattenSpecs)(suites);
|
|
238
|
+
(0, vitest_1.expect)(result).toEqual([
|
|
239
|
+
{ title: 'a' },
|
|
240
|
+
{ title: 'b' },
|
|
241
|
+
{ title: 'c' },
|
|
242
|
+
{ title: 'd' },
|
|
243
|
+
]);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
(0, vitest_1.describe)('mapPlaywrightStatus', () => {
|
|
247
|
+
(0, vitest_1.it)('maps "passed" to "passed"', () => {
|
|
248
|
+
(0, vitest_1.expect)((0, executor_1.mapPlaywrightStatus)('passed')).toBe('passed');
|
|
249
|
+
});
|
|
250
|
+
(0, vitest_1.it)('maps "failed" to "failed"', () => {
|
|
251
|
+
(0, vitest_1.expect)((0, executor_1.mapPlaywrightStatus)('failed')).toBe('failed');
|
|
252
|
+
});
|
|
253
|
+
(0, vitest_1.it)('maps "timedOut" to "failed"', () => {
|
|
254
|
+
(0, vitest_1.expect)((0, executor_1.mapPlaywrightStatus)('timedOut')).toBe('failed');
|
|
255
|
+
});
|
|
256
|
+
(0, vitest_1.it)('maps "skipped" to "skipped"', () => {
|
|
257
|
+
(0, vitest_1.expect)((0, executor_1.mapPlaywrightStatus)('skipped')).toBe('skipped');
|
|
258
|
+
});
|
|
259
|
+
(0, vitest_1.it)('maps "interrupted" to "error" (default)', () => {
|
|
260
|
+
(0, vitest_1.expect)((0, executor_1.mapPlaywrightStatus)('interrupted')).toBe('error');
|
|
261
|
+
});
|
|
262
|
+
(0, vitest_1.it)('maps unknown status to "error"', () => {
|
|
263
|
+
(0, vitest_1.expect)((0, executor_1.mapPlaywrightStatus)('unknown')).toBe('error');
|
|
264
|
+
(0, vitest_1.expect)((0, executor_1.mapPlaywrightStatus)('')).toBe('error');
|
|
265
|
+
(0, vitest_1.expect)((0, executor_1.mapPlaywrightStatus)('cancelled')).toBe('error');
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
(0, vitest_1.describe)('collectArtifacts', () => {
|
|
269
|
+
const workDir = '/tmp/testblocks-agent/run-123';
|
|
270
|
+
(0, vitest_1.it)('returns empty result for empty attachments', () => {
|
|
271
|
+
const result = (0, executor_1.collectArtifacts)([], workDir);
|
|
272
|
+
(0, vitest_1.expect)(result).toEqual({
|
|
273
|
+
screenshots: [],
|
|
274
|
+
video: null,
|
|
275
|
+
trace: null,
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
(0, vitest_1.it)('collects image attachments as screenshots', () => {
|
|
279
|
+
const attachments = [
|
|
280
|
+
{ path: path_1.default.join(workDir, 'screenshots', 'test-1.png'), contentType: 'image/png' },
|
|
281
|
+
{ path: path_1.default.join(workDir, 'screenshots', 'test-2.jpg'), contentType: 'image/jpeg' },
|
|
282
|
+
];
|
|
283
|
+
const result = (0, executor_1.collectArtifacts)(attachments, workDir);
|
|
284
|
+
(0, vitest_1.expect)(result.screenshots).toEqual([
|
|
285
|
+
'screenshots/test-1.png',
|
|
286
|
+
'screenshots/test-2.jpg',
|
|
287
|
+
]);
|
|
288
|
+
});
|
|
289
|
+
(0, vitest_1.it)('collects video attachment', () => {
|
|
290
|
+
const attachments = [
|
|
291
|
+
{ path: path_1.default.join(workDir, 'videos', 'test.webm'), contentType: 'video/webm' },
|
|
292
|
+
];
|
|
293
|
+
const result = (0, executor_1.collectArtifacts)(attachments, workDir);
|
|
294
|
+
(0, vitest_1.expect)(result.video).toBe('videos/test.webm');
|
|
295
|
+
});
|
|
296
|
+
(0, vitest_1.it)('collects trace attachment by name', () => {
|
|
297
|
+
const attachments = [
|
|
298
|
+
{ path: path_1.default.join(workDir, 'trace.zip'), contentType: 'application/zip', name: 'trace' },
|
|
299
|
+
];
|
|
300
|
+
const result = (0, executor_1.collectArtifacts)(attachments, workDir);
|
|
301
|
+
(0, vitest_1.expect)(result.trace).toBe('trace.zip');
|
|
302
|
+
});
|
|
303
|
+
(0, vitest_1.it)('collects trace attachment by .zip extension', () => {
|
|
304
|
+
const attachments = [
|
|
305
|
+
{ path: path_1.default.join(workDir, 'data', 'trace-abc.zip'), contentType: 'application/octet-stream' },
|
|
306
|
+
];
|
|
307
|
+
const result = (0, executor_1.collectArtifacts)(attachments, workDir);
|
|
308
|
+
(0, vitest_1.expect)(result.trace).toBe('data/trace-abc.zip');
|
|
309
|
+
});
|
|
310
|
+
(0, vitest_1.it)('skips attachments without path', () => {
|
|
311
|
+
const attachments = [
|
|
312
|
+
{ contentType: 'image/png' },
|
|
313
|
+
{ path: null, contentType: 'image/png' },
|
|
314
|
+
{ path: path_1.default.join(workDir, 'real.png'), contentType: 'image/png' },
|
|
315
|
+
];
|
|
316
|
+
const result = (0, executor_1.collectArtifacts)(attachments, workDir);
|
|
317
|
+
(0, vitest_1.expect)(result.screenshots).toEqual(['real.png']);
|
|
318
|
+
});
|
|
319
|
+
(0, vitest_1.it)('handles mixed attachment types', () => {
|
|
320
|
+
const attachments = [
|
|
321
|
+
{ path: path_1.default.join(workDir, 'shot1.png'), contentType: 'image/png' },
|
|
322
|
+
{ path: path_1.default.join(workDir, 'video.webm'), contentType: 'video/webm' },
|
|
323
|
+
{ path: path_1.default.join(workDir, 'trace.zip'), contentType: 'application/zip', name: 'trace' },
|
|
324
|
+
{ path: path_1.default.join(workDir, 'shot2.png'), contentType: 'image/png' },
|
|
325
|
+
];
|
|
326
|
+
const result = (0, executor_1.collectArtifacts)(attachments, workDir);
|
|
327
|
+
(0, vitest_1.expect)(result.screenshots).toEqual(['shot1.png', 'shot2.png']);
|
|
328
|
+
(0, vitest_1.expect)(result.video).toBe('video.webm');
|
|
329
|
+
(0, vitest_1.expect)(result.trace).toBe('trace.zip');
|
|
330
|
+
});
|
|
331
|
+
(0, vitest_1.it)('uses forward slashes in relative paths', () => {
|
|
332
|
+
// Even on Windows, the function normalizes to forward slashes
|
|
333
|
+
const attachments = [
|
|
334
|
+
{ path: path_1.default.join(workDir, 'nested', 'dir', 'screenshot.png'), contentType: 'image/png' },
|
|
335
|
+
];
|
|
336
|
+
const result = (0, executor_1.collectArtifacts)(attachments, workDir);
|
|
337
|
+
(0, vitest_1.expect)(result.screenshots[0]).toBe('nested/dir/screenshot.png');
|
|
338
|
+
(0, vitest_1.expect)(result.screenshots[0]).not.toContain('\\');
|
|
339
|
+
});
|
|
340
|
+
(0, vitest_1.it)('last video wins when multiple videos present', () => {
|
|
341
|
+
const attachments = [
|
|
342
|
+
{ path: path_1.default.join(workDir, 'v1.webm'), contentType: 'video/webm' },
|
|
343
|
+
{ path: path_1.default.join(workDir, 'v2.webm'), contentType: 'video/mp4' },
|
|
344
|
+
];
|
|
345
|
+
const result = (0, executor_1.collectArtifacts)(attachments, workDir);
|
|
346
|
+
(0, vitest_1.expect)(result.video).toBe('v2.webm');
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
// ============================================================
|
|
350
|
+
// Integration tests for executeJob and uploadFile
|
|
351
|
+
// ============================================================
|
|
352
|
+
// We need dynamic imports since executeJob uses fs, child_process, etc. at module level
|
|
353
|
+
// We mock fs, child_process, and os at the module level for the integration tests
|
|
354
|
+
const events_1 = require("events");
|
|
355
|
+
// Helper to build a mock ChildProcess (EventEmitter with stdout/stderr)
|
|
356
|
+
function createMockChildProcess() {
|
|
357
|
+
const cp = new events_1.EventEmitter();
|
|
358
|
+
cp._stdout = new events_1.EventEmitter();
|
|
359
|
+
cp._stderr = new events_1.EventEmitter();
|
|
360
|
+
cp.stdout = cp._stdout;
|
|
361
|
+
cp.stderr = cp._stderr;
|
|
362
|
+
cp.stdin = null;
|
|
363
|
+
cp.stdio = [null, cp._stdout, cp._stderr];
|
|
364
|
+
cp.pid = 12345;
|
|
365
|
+
cp.killed = false;
|
|
366
|
+
cp.kill = vitest_1.vi.fn(() => {
|
|
367
|
+
cp.killed = true;
|
|
368
|
+
return true;
|
|
369
|
+
});
|
|
370
|
+
cp.ref = vitest_1.vi.fn();
|
|
371
|
+
cp.unref = vitest_1.vi.fn();
|
|
372
|
+
cp.disconnect = vitest_1.vi.fn();
|
|
373
|
+
cp.connected = false;
|
|
374
|
+
cp.exitCode = null;
|
|
375
|
+
cp.signalCode = null;
|
|
376
|
+
cp.spawnargs = [];
|
|
377
|
+
cp.spawnfile = '';
|
|
378
|
+
cp[Symbol.dispose] = vitest_1.vi.fn();
|
|
379
|
+
return cp;
|
|
380
|
+
}
|
|
381
|
+
// Helper job payload
|
|
382
|
+
function createTestJob(overrides) {
|
|
383
|
+
return {
|
|
384
|
+
jobId: 'job-test-1',
|
|
385
|
+
testRunId: 'run-test-1',
|
|
386
|
+
specs: [
|
|
387
|
+
{
|
|
388
|
+
testCaseId: 'tc-1',
|
|
389
|
+
filename: 'login-test',
|
|
390
|
+
specCode: 'test("login", async () => { /* ... */ });',
|
|
391
|
+
fixturesCode: null,
|
|
392
|
+
},
|
|
393
|
+
],
|
|
394
|
+
config: {
|
|
395
|
+
browser: 'chrome',
|
|
396
|
+
timeout: 30000,
|
|
397
|
+
retries: 0,
|
|
398
|
+
headless: true,
|
|
399
|
+
screenshots: 'off',
|
|
400
|
+
video: 'off',
|
|
401
|
+
trace: 'off',
|
|
402
|
+
viewportWidth: 1280,
|
|
403
|
+
viewportHeight: 720,
|
|
404
|
+
},
|
|
405
|
+
envVars: [],
|
|
406
|
+
...overrides,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
// Helper to create a mock AgentClient
|
|
410
|
+
function createMockClient() {
|
|
411
|
+
return {
|
|
412
|
+
reportProgress: vitest_1.vi.fn().mockResolvedValue({ cancelled: false }),
|
|
413
|
+
reportResults: vitest_1.vi.fn().mockResolvedValue(undefined),
|
|
414
|
+
uploadArtifact: vitest_1.vi.fn().mockResolvedValue(undefined),
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
(0, vitest_1.describe)('executeJob (integration)', () => {
|
|
418
|
+
let mockFs;
|
|
419
|
+
let mockSpawn;
|
|
420
|
+
let mockClient;
|
|
421
|
+
let executeJobFn;
|
|
422
|
+
(0, vitest_1.beforeEach)(async () => {
|
|
423
|
+
vitest_1.vi.resetModules();
|
|
424
|
+
// Mock fs module
|
|
425
|
+
vitest_1.vi.doMock('fs', () => ({
|
|
426
|
+
default: {
|
|
427
|
+
mkdirSync: vitest_1.vi.fn(),
|
|
428
|
+
writeFileSync: vitest_1.vi.fn(),
|
|
429
|
+
existsSync: vitest_1.vi.fn().mockReturnValue(false),
|
|
430
|
+
readFileSync: vitest_1.vi.fn().mockReturnValue(''),
|
|
431
|
+
rmSync: vitest_1.vi.fn(),
|
|
432
|
+
symlinkSync: vitest_1.vi.fn(),
|
|
433
|
+
},
|
|
434
|
+
mkdirSync: vitest_1.vi.fn(),
|
|
435
|
+
writeFileSync: vitest_1.vi.fn(),
|
|
436
|
+
existsSync: vitest_1.vi.fn().mockReturnValue(false),
|
|
437
|
+
readFileSync: vitest_1.vi.fn().mockReturnValue(''),
|
|
438
|
+
rmSync: vitest_1.vi.fn(),
|
|
439
|
+
symlinkSync: vitest_1.vi.fn(),
|
|
440
|
+
}));
|
|
441
|
+
// Mock child_process.spawn
|
|
442
|
+
mockSpawn = vitest_1.vi.fn();
|
|
443
|
+
vitest_1.vi.doMock('child_process', () => ({
|
|
444
|
+
spawn: mockSpawn,
|
|
445
|
+
}));
|
|
446
|
+
// Mock os.tmpdir
|
|
447
|
+
vitest_1.vi.doMock('os', () => ({
|
|
448
|
+
default: { tmpdir: () => '/tmp' },
|
|
449
|
+
tmpdir: () => '/tmp',
|
|
450
|
+
}));
|
|
451
|
+
// Silence console output during tests
|
|
452
|
+
vitest_1.vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
453
|
+
vitest_1.vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
454
|
+
vitest_1.vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
455
|
+
vitest_1.vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
456
|
+
// Load mocked fs reference
|
|
457
|
+
mockFs = (await Promise.resolve().then(() => __importStar(require('fs')))).default;
|
|
458
|
+
// Load the module with mocks applied
|
|
459
|
+
const executorModule = await Promise.resolve().then(() => __importStar(require('../../executor')));
|
|
460
|
+
executeJobFn = executorModule.executeJob;
|
|
461
|
+
mockClient = createMockClient();
|
|
462
|
+
});
|
|
463
|
+
(0, vitest_1.afterEach)(() => {
|
|
464
|
+
vitest_1.vi.restoreAllMocks();
|
|
465
|
+
vitest_1.vi.resetModules();
|
|
466
|
+
});
|
|
467
|
+
(0, vitest_1.it)('creates work directory with recursive flag', async () => {
|
|
468
|
+
const mockChild = createMockChildProcess();
|
|
469
|
+
mockSpawn.mockReturnValue(mockChild);
|
|
470
|
+
// existsSync returns false for report.json check and node_modules
|
|
471
|
+
mockFs.existsSync.mockReturnValue(false);
|
|
472
|
+
const job = createTestJob();
|
|
473
|
+
// Simulate process exit immediately
|
|
474
|
+
const promise = executeJobFn(job, mockClient);
|
|
475
|
+
// Let the spawn callback attach, then emit close
|
|
476
|
+
await new Promise(r => setTimeout(r, 10));
|
|
477
|
+
mockChild.emit('close', 0);
|
|
478
|
+
await promise;
|
|
479
|
+
(0, vitest_1.expect)(mockFs.mkdirSync).toHaveBeenCalledWith(vitest_1.expect.stringContaining('run-test-1'), { recursive: true });
|
|
480
|
+
});
|
|
481
|
+
(0, vitest_1.it)('writes spec files for each test in the job', async () => {
|
|
482
|
+
const mockChild = createMockChildProcess();
|
|
483
|
+
mockSpawn.mockReturnValue(mockChild);
|
|
484
|
+
mockFs.existsSync.mockReturnValue(false);
|
|
485
|
+
const job = createTestJob({
|
|
486
|
+
specs: [
|
|
487
|
+
{ testCaseId: 'tc-1', filename: 'login-test', specCode: 'code1', fixturesCode: null },
|
|
488
|
+
{ testCaseId: 'tc-2', filename: 'dashboard-test', specCode: 'code2', fixturesCode: null },
|
|
489
|
+
],
|
|
490
|
+
});
|
|
491
|
+
const promise = executeJobFn(job, mockClient);
|
|
492
|
+
await new Promise(r => setTimeout(r, 10));
|
|
493
|
+
mockChild.emit('close', 0);
|
|
494
|
+
await promise;
|
|
495
|
+
// Should write both spec files
|
|
496
|
+
const writeFileCalls = mockFs.writeFileSync.mock.calls;
|
|
497
|
+
const specWrites = writeFileCalls.filter((call) => call[0].endsWith('.spec.ts'));
|
|
498
|
+
(0, vitest_1.expect)(specWrites).toHaveLength(2);
|
|
499
|
+
(0, vitest_1.expect)(specWrites[0][0]).toContain('login-test.spec.ts');
|
|
500
|
+
(0, vitest_1.expect)(specWrites[0][1]).toBe('code1');
|
|
501
|
+
(0, vitest_1.expect)(specWrites[1][0]).toContain('dashboard-test.spec.ts');
|
|
502
|
+
(0, vitest_1.expect)(specWrites[1][1]).toBe('code2');
|
|
503
|
+
});
|
|
504
|
+
(0, vitest_1.it)('writes fixtures.ts when fixturesCode is provided', async () => {
|
|
505
|
+
const mockChild = createMockChildProcess();
|
|
506
|
+
mockSpawn.mockReturnValue(mockChild);
|
|
507
|
+
mockFs.existsSync.mockReturnValue(false);
|
|
508
|
+
const job = createTestJob({
|
|
509
|
+
specs: [
|
|
510
|
+
{ testCaseId: 'tc-1', filename: 'test1', specCode: 'code1', fixturesCode: 'fixture code here' },
|
|
511
|
+
],
|
|
512
|
+
});
|
|
513
|
+
const promise = executeJobFn(job, mockClient);
|
|
514
|
+
await new Promise(r => setTimeout(r, 10));
|
|
515
|
+
mockChild.emit('close', 0);
|
|
516
|
+
await promise;
|
|
517
|
+
const writeFileCalls = mockFs.writeFileSync.mock.calls;
|
|
518
|
+
const fixtureWrite = writeFileCalls.find((call) => call[0].endsWith('fixtures.ts'));
|
|
519
|
+
(0, vitest_1.expect)(fixtureWrite).toBeDefined();
|
|
520
|
+
(0, vitest_1.expect)(fixtureWrite[1]).toBe('fixture code here');
|
|
521
|
+
});
|
|
522
|
+
(0, vitest_1.it)('writes playwright.config.ts', async () => {
|
|
523
|
+
const mockChild = createMockChildProcess();
|
|
524
|
+
mockSpawn.mockReturnValue(mockChild);
|
|
525
|
+
mockFs.existsSync.mockReturnValue(false);
|
|
526
|
+
const job = createTestJob();
|
|
527
|
+
const promise = executeJobFn(job, mockClient);
|
|
528
|
+
await new Promise(r => setTimeout(r, 10));
|
|
529
|
+
mockChild.emit('close', 0);
|
|
530
|
+
await promise;
|
|
531
|
+
const writeFileCalls = mockFs.writeFileSync.mock.calls;
|
|
532
|
+
const configWrite = writeFileCalls.find((call) => call[0].endsWith('playwright.config.ts'));
|
|
533
|
+
(0, vitest_1.expect)(configWrite).toBeDefined();
|
|
534
|
+
(0, vitest_1.expect)(configWrite[1]).toContain('defineConfig');
|
|
535
|
+
});
|
|
536
|
+
(0, vitest_1.it)('writes .env file when envVars are provided', async () => {
|
|
537
|
+
const mockChild = createMockChildProcess();
|
|
538
|
+
mockSpawn.mockReturnValue(mockChild);
|
|
539
|
+
mockFs.existsSync.mockReturnValue(false);
|
|
540
|
+
const job = createTestJob({
|
|
541
|
+
envVars: [
|
|
542
|
+
{ name: 'BASE_URL', value: 'http://localhost:3000' },
|
|
543
|
+
{ name: 'API_KEY', value: 'secret123' },
|
|
544
|
+
],
|
|
545
|
+
});
|
|
546
|
+
const promise = executeJobFn(job, mockClient);
|
|
547
|
+
await new Promise(r => setTimeout(r, 10));
|
|
548
|
+
mockChild.emit('close', 0);
|
|
549
|
+
await promise;
|
|
550
|
+
const writeFileCalls = mockFs.writeFileSync.mock.calls;
|
|
551
|
+
const envWrite = writeFileCalls.find((call) => call[0].endsWith('.env'));
|
|
552
|
+
(0, vitest_1.expect)(envWrite).toBeDefined();
|
|
553
|
+
(0, vitest_1.expect)(envWrite[1]).toContain('BASE_URL=http://localhost:3000');
|
|
554
|
+
(0, vitest_1.expect)(envWrite[1]).toContain('API_KEY=secret123');
|
|
555
|
+
});
|
|
556
|
+
(0, vitest_1.it)('does not write .env file when envVars is empty', async () => {
|
|
557
|
+
const mockChild = createMockChildProcess();
|
|
558
|
+
mockSpawn.mockReturnValue(mockChild);
|
|
559
|
+
mockFs.existsSync.mockReturnValue(false);
|
|
560
|
+
const job = createTestJob({ envVars: [] });
|
|
561
|
+
const promise = executeJobFn(job, mockClient);
|
|
562
|
+
await new Promise(r => setTimeout(r, 10));
|
|
563
|
+
mockChild.emit('close', 0);
|
|
564
|
+
await promise;
|
|
565
|
+
const writeFileCalls = mockFs.writeFileSync.mock.calls;
|
|
566
|
+
const envWrite = writeFileCalls.find((call) => call[0].endsWith('.env'));
|
|
567
|
+
(0, vitest_1.expect)(envWrite).toBeUndefined();
|
|
568
|
+
});
|
|
569
|
+
(0, vitest_1.it)('spawns npx playwright test with correct args', async () => {
|
|
570
|
+
const mockChild = createMockChildProcess();
|
|
571
|
+
mockSpawn.mockReturnValue(mockChild);
|
|
572
|
+
mockFs.existsSync.mockReturnValue(false);
|
|
573
|
+
const job = createTestJob();
|
|
574
|
+
const promise = executeJobFn(job, mockClient);
|
|
575
|
+
await new Promise(r => setTimeout(r, 10));
|
|
576
|
+
mockChild.emit('close', 0);
|
|
577
|
+
await promise;
|
|
578
|
+
(0, vitest_1.expect)(mockSpawn).toHaveBeenCalledWith('npx', ['playwright', 'test', '--config=playwright.config.ts'], vitest_1.expect.objectContaining({
|
|
579
|
+
cwd: vitest_1.expect.stringContaining('run-test-1'),
|
|
580
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
581
|
+
shell: true,
|
|
582
|
+
}));
|
|
583
|
+
});
|
|
584
|
+
(0, vitest_1.it)('reports progress when stdout contains progress pattern', async () => {
|
|
585
|
+
const mockChild = createMockChildProcess();
|
|
586
|
+
mockSpawn.mockReturnValue(mockChild);
|
|
587
|
+
mockFs.existsSync.mockReturnValue(false);
|
|
588
|
+
const job = createTestJob();
|
|
589
|
+
const promise = executeJobFn(job, mockClient);
|
|
590
|
+
await new Promise(r => setTimeout(r, 10));
|
|
591
|
+
// Simulate progress output from Playwright
|
|
592
|
+
mockChild._stdout.emit('data', Buffer.from(' [1/2] Running test...\n'));
|
|
593
|
+
await new Promise(r => setTimeout(r, 10));
|
|
594
|
+
mockChild.emit('close', 0);
|
|
595
|
+
await promise;
|
|
596
|
+
(0, vitest_1.expect)(mockClient.reportProgress).toHaveBeenCalledWith(vitest_1.expect.objectContaining({
|
|
597
|
+
jobId: 'job-test-1',
|
|
598
|
+
completedTests: 0, // current - 1 = 1 - 1 = 0
|
|
599
|
+
}));
|
|
600
|
+
});
|
|
601
|
+
(0, vitest_1.it)('reads and parses report.json when it exists', async () => {
|
|
602
|
+
const mockChild = createMockChildProcess();
|
|
603
|
+
mockSpawn.mockReturnValue(mockChild);
|
|
604
|
+
// existsSync: false for node_modules, true for report.json
|
|
605
|
+
mockFs.existsSync.mockImplementation((p) => {
|
|
606
|
+
if (p.endsWith('report.json'))
|
|
607
|
+
return true;
|
|
608
|
+
return false;
|
|
609
|
+
});
|
|
610
|
+
const report = {
|
|
611
|
+
suites: [{
|
|
612
|
+
specs: [{
|
|
613
|
+
file: 'login-test.spec.ts',
|
|
614
|
+
tests: [{
|
|
615
|
+
results: [{
|
|
616
|
+
status: 'passed',
|
|
617
|
+
duration: 1234,
|
|
618
|
+
attachments: [],
|
|
619
|
+
}],
|
|
620
|
+
}],
|
|
621
|
+
}],
|
|
622
|
+
}],
|
|
623
|
+
};
|
|
624
|
+
mockFs.readFileSync.mockImplementation((p) => {
|
|
625
|
+
if (typeof p === 'string' && p.endsWith('report.json')) {
|
|
626
|
+
return JSON.stringify(report);
|
|
627
|
+
}
|
|
628
|
+
return '';
|
|
629
|
+
});
|
|
630
|
+
const job = createTestJob();
|
|
631
|
+
const promise = executeJobFn(job, mockClient);
|
|
632
|
+
await new Promise(r => setTimeout(r, 10));
|
|
633
|
+
mockChild.emit('close', 0);
|
|
634
|
+
await promise;
|
|
635
|
+
(0, vitest_1.expect)(mockClient.reportResults).toHaveBeenCalledWith(vitest_1.expect.objectContaining({
|
|
636
|
+
jobId: 'job-test-1',
|
|
637
|
+
testRunId: 'run-test-1',
|
|
638
|
+
results: vitest_1.expect.arrayContaining([
|
|
639
|
+
vitest_1.expect.objectContaining({
|
|
640
|
+
testCaseId: 'tc-1',
|
|
641
|
+
status: 'passed',
|
|
642
|
+
durationMs: 1234,
|
|
643
|
+
}),
|
|
644
|
+
]),
|
|
645
|
+
}));
|
|
646
|
+
});
|
|
647
|
+
(0, vitest_1.it)('reports error results when report.json is missing', async () => {
|
|
648
|
+
const mockChild = createMockChildProcess();
|
|
649
|
+
mockSpawn.mockReturnValue(mockChild);
|
|
650
|
+
mockFs.existsSync.mockReturnValue(false);
|
|
651
|
+
const job = createTestJob();
|
|
652
|
+
const promise = executeJobFn(job, mockClient);
|
|
653
|
+
await new Promise(r => setTimeout(r, 10));
|
|
654
|
+
mockChild.emit('close', 1);
|
|
655
|
+
await promise;
|
|
656
|
+
(0, vitest_1.expect)(mockClient.reportResults).toHaveBeenCalledWith(vitest_1.expect.objectContaining({
|
|
657
|
+
results: vitest_1.expect.arrayContaining([
|
|
658
|
+
vitest_1.expect.objectContaining({
|
|
659
|
+
testCaseId: 'tc-1',
|
|
660
|
+
status: 'error',
|
|
661
|
+
errorMessage: vitest_1.expect.any(String),
|
|
662
|
+
}),
|
|
663
|
+
]),
|
|
664
|
+
}));
|
|
665
|
+
});
|
|
666
|
+
(0, vitest_1.it)('handles process failure (non-zero exit code) with stderr', async () => {
|
|
667
|
+
const mockChild = createMockChildProcess();
|
|
668
|
+
mockSpawn.mockReturnValue(mockChild);
|
|
669
|
+
mockFs.existsSync.mockReturnValue(false);
|
|
670
|
+
const job = createTestJob();
|
|
671
|
+
const promise = executeJobFn(job, mockClient);
|
|
672
|
+
await new Promise(r => setTimeout(r, 10));
|
|
673
|
+
// Emit stderr
|
|
674
|
+
mockChild._stderr.emit('data', Buffer.from('Error: Cannot find module'));
|
|
675
|
+
await new Promise(r => setTimeout(r, 10));
|
|
676
|
+
mockChild.emit('close', 1);
|
|
677
|
+
await promise;
|
|
678
|
+
(0, vitest_1.expect)(mockClient.reportResults).toHaveBeenCalledWith(vitest_1.expect.objectContaining({
|
|
679
|
+
results: vitest_1.expect.arrayContaining([
|
|
680
|
+
vitest_1.expect.objectContaining({
|
|
681
|
+
status: 'error',
|
|
682
|
+
errorMessage: vitest_1.expect.stringContaining('Cannot find module'),
|
|
683
|
+
}),
|
|
684
|
+
]),
|
|
685
|
+
}));
|
|
686
|
+
});
|
|
687
|
+
(0, vitest_1.it)('cleans up work directory in finally block', async () => {
|
|
688
|
+
const mockChild = createMockChildProcess();
|
|
689
|
+
mockSpawn.mockReturnValue(mockChild);
|
|
690
|
+
mockFs.existsSync.mockReturnValue(false);
|
|
691
|
+
const job = createTestJob();
|
|
692
|
+
const promise = executeJobFn(job, mockClient);
|
|
693
|
+
await new Promise(r => setTimeout(r, 10));
|
|
694
|
+
mockChild.emit('close', 0);
|
|
695
|
+
await promise;
|
|
696
|
+
(0, vitest_1.expect)(mockFs.rmSync).toHaveBeenCalledWith(vitest_1.expect.stringContaining('run-test-1'), { recursive: true, force: true });
|
|
697
|
+
});
|
|
698
|
+
(0, vitest_1.it)('handles cancellation from server', async () => {
|
|
699
|
+
const mockChild = createMockChildProcess();
|
|
700
|
+
mockSpawn.mockReturnValue(mockChild);
|
|
701
|
+
mockFs.existsSync.mockReturnValue(false);
|
|
702
|
+
// Return cancelled: true on progress report
|
|
703
|
+
mockClient.reportProgress.mockResolvedValue({ cancelled: true });
|
|
704
|
+
const job = createTestJob();
|
|
705
|
+
const promise = executeJobFn(job, mockClient);
|
|
706
|
+
await new Promise(r => setTimeout(r, 10));
|
|
707
|
+
// Emit progress pattern to trigger reportProgress
|
|
708
|
+
mockChild._stdout.emit('data', Buffer.from('[1/1] test\n'));
|
|
709
|
+
await new Promise(r => setTimeout(r, 50));
|
|
710
|
+
mockChild.emit('close', 0);
|
|
711
|
+
await promise;
|
|
712
|
+
// Should have called kill on the child process
|
|
713
|
+
(0, vitest_1.expect)(mockChild.kill).toHaveBeenCalledWith('SIGTERM');
|
|
714
|
+
});
|
|
715
|
+
(0, vitest_1.it)('uploads artifacts from report results', async () => {
|
|
716
|
+
const mockChild = createMockChildProcess();
|
|
717
|
+
mockSpawn.mockReturnValue(mockChild);
|
|
718
|
+
const workDir = path_1.default.join('/tmp', 'testblocks-agent', 'run-test-1');
|
|
719
|
+
const screenshotPath = path_1.default.join(workDir, 'screenshots', 'test.png');
|
|
720
|
+
// existsSync: true for report.json and screenshot file, false for others
|
|
721
|
+
mockFs.existsSync.mockImplementation((p) => {
|
|
722
|
+
if (typeof p === 'string' && p.endsWith('report.json'))
|
|
723
|
+
return true;
|
|
724
|
+
if (typeof p === 'string' && p.endsWith('test.png'))
|
|
725
|
+
return true;
|
|
726
|
+
return false;
|
|
727
|
+
});
|
|
728
|
+
const report = {
|
|
729
|
+
suites: [{
|
|
730
|
+
specs: [{
|
|
731
|
+
file: 'login-test.spec.ts',
|
|
732
|
+
tests: [{
|
|
733
|
+
results: [{
|
|
734
|
+
status: 'passed',
|
|
735
|
+
duration: 500,
|
|
736
|
+
attachments: [
|
|
737
|
+
{ path: screenshotPath, contentType: 'image/png', name: 'screenshot' },
|
|
738
|
+
],
|
|
739
|
+
}],
|
|
740
|
+
}],
|
|
741
|
+
}],
|
|
742
|
+
}],
|
|
743
|
+
};
|
|
744
|
+
mockFs.readFileSync.mockImplementation((p) => {
|
|
745
|
+
if (typeof p === 'string' && p.endsWith('report.json')) {
|
|
746
|
+
return JSON.stringify(report);
|
|
747
|
+
}
|
|
748
|
+
return Buffer.from('fake image');
|
|
749
|
+
});
|
|
750
|
+
const job = createTestJob();
|
|
751
|
+
const promise = executeJobFn(job, mockClient);
|
|
752
|
+
await new Promise(r => setTimeout(r, 10));
|
|
753
|
+
mockChild.emit('close', 0);
|
|
754
|
+
await promise;
|
|
755
|
+
(0, vitest_1.expect)(mockClient.uploadArtifact).toHaveBeenCalledWith('run-test-1', vitest_1.expect.stringContaining('test.png'), vitest_1.expect.any(Buffer));
|
|
756
|
+
});
|
|
757
|
+
(0, vitest_1.it)('reports error to server when execution throws', async () => {
|
|
758
|
+
// Make spawn throw an error
|
|
759
|
+
mockSpawn.mockImplementation(() => {
|
|
760
|
+
throw new Error('spawn ENOENT');
|
|
761
|
+
});
|
|
762
|
+
const job = createTestJob();
|
|
763
|
+
await executeJobFn(job, mockClient);
|
|
764
|
+
(0, vitest_1.expect)(mockClient.reportResults).toHaveBeenCalledWith(vitest_1.expect.objectContaining({
|
|
765
|
+
results: vitest_1.expect.arrayContaining([
|
|
766
|
+
vitest_1.expect.objectContaining({
|
|
767
|
+
status: 'error',
|
|
768
|
+
errorMessage: vitest_1.expect.stringContaining('spawn ENOENT'),
|
|
769
|
+
}),
|
|
770
|
+
]),
|
|
771
|
+
}));
|
|
772
|
+
});
|
|
773
|
+
(0, vitest_1.it)('writes package.json to work directory', async () => {
|
|
774
|
+
const mockChild = createMockChildProcess();
|
|
775
|
+
mockSpawn.mockReturnValue(mockChild);
|
|
776
|
+
mockFs.existsSync.mockReturnValue(false);
|
|
777
|
+
const job = createTestJob();
|
|
778
|
+
const promise = executeJobFn(job, mockClient);
|
|
779
|
+
await new Promise(r => setTimeout(r, 10));
|
|
780
|
+
mockChild.emit('close', 0);
|
|
781
|
+
await promise;
|
|
782
|
+
const writeFileCalls = mockFs.writeFileSync.mock.calls;
|
|
783
|
+
const pkgWrite = writeFileCalls.find((call) => call[0].endsWith('package.json'));
|
|
784
|
+
(0, vitest_1.expect)(pkgWrite).toBeDefined();
|
|
785
|
+
const pkg = JSON.parse(pkgWrite[1]);
|
|
786
|
+
(0, vitest_1.expect)(pkg.name).toBe('testblocks-agent-run');
|
|
787
|
+
(0, vitest_1.expect)(pkg.private).toBe(true);
|
|
788
|
+
});
|
|
789
|
+
(0, vitest_1.it)('attempts to symlink node_modules when not present', async () => {
|
|
790
|
+
const mockChild = createMockChildProcess();
|
|
791
|
+
mockSpawn.mockReturnValue(mockChild);
|
|
792
|
+
// existsSync returns false (no existing node_modules, no report.json)
|
|
793
|
+
mockFs.existsSync.mockReturnValue(false);
|
|
794
|
+
const job = createTestJob();
|
|
795
|
+
const promise = executeJobFn(job, mockClient);
|
|
796
|
+
await new Promise(r => setTimeout(r, 10));
|
|
797
|
+
mockChild.emit('close', 0);
|
|
798
|
+
await promise;
|
|
799
|
+
// Since existsSync returns false for all paths including node_modules sources,
|
|
800
|
+
// symlinkSync should NOT be called (no source node_modules found)
|
|
801
|
+
(0, vitest_1.expect)(mockFs.symlinkSync).not.toHaveBeenCalled();
|
|
802
|
+
});
|
|
803
|
+
(0, vitest_1.it)('only writes fixtures.ts once even with multiple specs', async () => {
|
|
804
|
+
const mockChild = createMockChildProcess();
|
|
805
|
+
mockSpawn.mockReturnValue(mockChild);
|
|
806
|
+
mockFs.existsSync.mockReturnValue(false);
|
|
807
|
+
const job = createTestJob({
|
|
808
|
+
specs: [
|
|
809
|
+
{ testCaseId: 'tc-1', filename: 'test1', specCode: 'c1', fixturesCode: 'shared fixtures' },
|
|
810
|
+
{ testCaseId: 'tc-2', filename: 'test2', specCode: 'c2', fixturesCode: 'shared fixtures' },
|
|
811
|
+
],
|
|
812
|
+
});
|
|
813
|
+
const promise = executeJobFn(job, mockClient);
|
|
814
|
+
await new Promise(r => setTimeout(r, 10));
|
|
815
|
+
mockChild.emit('close', 0);
|
|
816
|
+
await promise;
|
|
817
|
+
const writeFileCalls = mockFs.writeFileSync.mock.calls;
|
|
818
|
+
const fixtureWrites = writeFileCalls.filter((call) => call[0].endsWith('fixtures.ts'));
|
|
819
|
+
(0, vitest_1.expect)(fixtureWrites).toHaveLength(1);
|
|
820
|
+
});
|
|
821
|
+
(0, vitest_1.it)('marks unprocessed tests as error when report has no matching spec', async () => {
|
|
822
|
+
const mockChild = createMockChildProcess();
|
|
823
|
+
mockSpawn.mockReturnValue(mockChild);
|
|
824
|
+
// Report has empty suites
|
|
825
|
+
mockFs.existsSync.mockImplementation((p) => {
|
|
826
|
+
if (typeof p === 'string' && p.endsWith('report.json'))
|
|
827
|
+
return true;
|
|
828
|
+
return false;
|
|
829
|
+
});
|
|
830
|
+
mockFs.readFileSync.mockImplementation((p) => {
|
|
831
|
+
if (typeof p === 'string' && p.endsWith('report.json')) {
|
|
832
|
+
return JSON.stringify({ suites: [] });
|
|
833
|
+
}
|
|
834
|
+
return '';
|
|
835
|
+
});
|
|
836
|
+
const job = createTestJob({
|
|
837
|
+
specs: [
|
|
838
|
+
{ testCaseId: 'tc-1', filename: 'test1', specCode: 'c1', fixturesCode: null },
|
|
839
|
+
{ testCaseId: 'tc-2', filename: 'test2', specCode: 'c2', fixturesCode: null },
|
|
840
|
+
],
|
|
841
|
+
});
|
|
842
|
+
const promise = executeJobFn(job, mockClient);
|
|
843
|
+
await new Promise(r => setTimeout(r, 10));
|
|
844
|
+
mockChild.emit('close', 1);
|
|
845
|
+
await promise;
|
|
846
|
+
const results = mockClient.reportResults.mock.calls[0][0].results;
|
|
847
|
+
(0, vitest_1.expect)(results).toHaveLength(2);
|
|
848
|
+
(0, vitest_1.expect)(results[0].status).toBe('error');
|
|
849
|
+
(0, vitest_1.expect)(results[1].status).toBe('error');
|
|
850
|
+
});
|
|
851
|
+
});
|
|
852
|
+
(0, vitest_1.describe)('uploadFile (integration)', () => {
|
|
853
|
+
let mockFs;
|
|
854
|
+
let mockClient;
|
|
855
|
+
let executeJobFn;
|
|
856
|
+
(0, vitest_1.beforeEach)(async () => {
|
|
857
|
+
vitest_1.vi.resetModules();
|
|
858
|
+
vitest_1.vi.doMock('fs', () => ({
|
|
859
|
+
default: {
|
|
860
|
+
mkdirSync: vitest_1.vi.fn(),
|
|
861
|
+
writeFileSync: vitest_1.vi.fn(),
|
|
862
|
+
existsSync: vitest_1.vi.fn().mockReturnValue(false),
|
|
863
|
+
readFileSync: vitest_1.vi.fn().mockReturnValue(Buffer.from('file-content')),
|
|
864
|
+
rmSync: vitest_1.vi.fn(),
|
|
865
|
+
symlinkSync: vitest_1.vi.fn(),
|
|
866
|
+
},
|
|
867
|
+
mkdirSync: vitest_1.vi.fn(),
|
|
868
|
+
writeFileSync: vitest_1.vi.fn(),
|
|
869
|
+
existsSync: vitest_1.vi.fn().mockReturnValue(false),
|
|
870
|
+
readFileSync: vitest_1.vi.fn().mockReturnValue(Buffer.from('file-content')),
|
|
871
|
+
rmSync: vitest_1.vi.fn(),
|
|
872
|
+
symlinkSync: vitest_1.vi.fn(),
|
|
873
|
+
}));
|
|
874
|
+
vitest_1.vi.doMock('child_process', () => ({
|
|
875
|
+
spawn: vitest_1.vi.fn(),
|
|
876
|
+
}));
|
|
877
|
+
vitest_1.vi.doMock('os', () => ({
|
|
878
|
+
default: { tmpdir: () => '/tmp' },
|
|
879
|
+
tmpdir: () => '/tmp',
|
|
880
|
+
}));
|
|
881
|
+
vitest_1.vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
882
|
+
vitest_1.vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
883
|
+
vitest_1.vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
884
|
+
vitest_1.vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
885
|
+
mockFs = (await Promise.resolve().then(() => __importStar(require('fs')))).default;
|
|
886
|
+
const cp = await Promise.resolve().then(() => __importStar(require('child_process')));
|
|
887
|
+
const executorModule = await Promise.resolve().then(() => __importStar(require('../../executor')));
|
|
888
|
+
executeJobFn = executorModule.executeJob;
|
|
889
|
+
mockClient = createMockClient();
|
|
890
|
+
// Setup spawn to return a mock child
|
|
891
|
+
const mockChild = createMockChildProcess();
|
|
892
|
+
cp.spawn.mockReturnValue(mockChild);
|
|
893
|
+
// Schedule the mock child to close
|
|
894
|
+
setTimeout(() => mockChild.emit('close', 0), 20);
|
|
895
|
+
});
|
|
896
|
+
(0, vitest_1.afterEach)(() => {
|
|
897
|
+
vitest_1.vi.restoreAllMocks();
|
|
898
|
+
vitest_1.vi.resetModules();
|
|
899
|
+
});
|
|
900
|
+
(0, vitest_1.it)('reads file from disk and uploads via client', async () => {
|
|
901
|
+
const workDir = path_1.default.join('/tmp', 'testblocks-agent', 'run-test-1');
|
|
902
|
+
const screenshotPath = path_1.default.join(workDir, 'screenshots', 'shot.png');
|
|
903
|
+
mockFs.existsSync.mockImplementation((p) => {
|
|
904
|
+
if (typeof p === 'string' && p.endsWith('report.json'))
|
|
905
|
+
return true;
|
|
906
|
+
if (typeof p === 'string' && p.endsWith('shot.png'))
|
|
907
|
+
return true;
|
|
908
|
+
return false;
|
|
909
|
+
});
|
|
910
|
+
const report = {
|
|
911
|
+
suites: [{
|
|
912
|
+
specs: [{
|
|
913
|
+
file: 'login-test.spec.ts',
|
|
914
|
+
tests: [{
|
|
915
|
+
results: [{
|
|
916
|
+
status: 'passed',
|
|
917
|
+
duration: 100,
|
|
918
|
+
attachments: [
|
|
919
|
+
{ path: screenshotPath, contentType: 'image/png', name: 'screenshot' },
|
|
920
|
+
],
|
|
921
|
+
}],
|
|
922
|
+
}],
|
|
923
|
+
}],
|
|
924
|
+
}],
|
|
925
|
+
};
|
|
926
|
+
mockFs.readFileSync.mockImplementation((p) => {
|
|
927
|
+
if (typeof p === 'string' && p.endsWith('report.json')) {
|
|
928
|
+
return JSON.stringify(report);
|
|
929
|
+
}
|
|
930
|
+
return Buffer.from('png-file-data');
|
|
931
|
+
});
|
|
932
|
+
const job = createTestJob();
|
|
933
|
+
await executeJobFn(job, mockClient);
|
|
934
|
+
(0, vitest_1.expect)(mockClient.uploadArtifact).toHaveBeenCalled();
|
|
935
|
+
(0, vitest_1.expect)(mockClient.uploadArtifact).toHaveBeenCalledWith('run-test-1', vitest_1.expect.stringContaining('shot.png'), vitest_1.expect.any(Buffer));
|
|
936
|
+
});
|
|
937
|
+
(0, vitest_1.it)('handles upload failure gracefully (does not throw)', async () => {
|
|
938
|
+
const workDir = path_1.default.join('/tmp', 'testblocks-agent', 'run-test-1');
|
|
939
|
+
const screenshotPath = path_1.default.join(workDir, 'shot.png');
|
|
940
|
+
mockFs.existsSync.mockImplementation((p) => {
|
|
941
|
+
if (typeof p === 'string' && p.endsWith('report.json'))
|
|
942
|
+
return true;
|
|
943
|
+
if (typeof p === 'string' && p.endsWith('shot.png'))
|
|
944
|
+
return true;
|
|
945
|
+
return false;
|
|
946
|
+
});
|
|
947
|
+
const report = {
|
|
948
|
+
suites: [{
|
|
949
|
+
specs: [{
|
|
950
|
+
file: 'login-test.spec.ts',
|
|
951
|
+
tests: [{
|
|
952
|
+
results: [{
|
|
953
|
+
status: 'passed',
|
|
954
|
+
duration: 100,
|
|
955
|
+
attachments: [
|
|
956
|
+
{ path: screenshotPath, contentType: 'image/png', name: 'screenshot' },
|
|
957
|
+
],
|
|
958
|
+
}],
|
|
959
|
+
}],
|
|
960
|
+
}],
|
|
961
|
+
}],
|
|
962
|
+
};
|
|
963
|
+
mockFs.readFileSync.mockImplementation((p) => {
|
|
964
|
+
if (typeof p === 'string' && p.endsWith('report.json')) {
|
|
965
|
+
return JSON.stringify(report);
|
|
966
|
+
}
|
|
967
|
+
return Buffer.from('img-data');
|
|
968
|
+
});
|
|
969
|
+
// Make upload fail
|
|
970
|
+
mockClient.uploadArtifact.mockRejectedValue(new Error('Upload failed: 500'));
|
|
971
|
+
const job = createTestJob();
|
|
972
|
+
// Should not throw
|
|
973
|
+
await (0, vitest_1.expect)(executeJobFn(job, mockClient)).resolves.toBeUndefined();
|
|
974
|
+
// uploadArtifact was called but failed; the job still completes
|
|
975
|
+
(0, vitest_1.expect)(mockClient.uploadArtifact).toHaveBeenCalled();
|
|
976
|
+
(0, vitest_1.expect)(mockClient.reportResults).toHaveBeenCalled();
|
|
977
|
+
});
|
|
978
|
+
(0, vitest_1.it)('skips upload when file does not exist on disk', async () => {
|
|
979
|
+
const workDir = path_1.default.join('/tmp', 'testblocks-agent', 'run-test-1');
|
|
980
|
+
const screenshotPath = path_1.default.join(workDir, 'missing.png');
|
|
981
|
+
mockFs.existsSync.mockImplementation((p) => {
|
|
982
|
+
if (typeof p === 'string' && p.endsWith('report.json'))
|
|
983
|
+
return true;
|
|
984
|
+
// The actual screenshot file does NOT exist
|
|
985
|
+
if (typeof p === 'string' && p.endsWith('missing.png'))
|
|
986
|
+
return false;
|
|
987
|
+
return false;
|
|
988
|
+
});
|
|
989
|
+
const report = {
|
|
990
|
+
suites: [{
|
|
991
|
+
specs: [{
|
|
992
|
+
file: 'login-test.spec.ts',
|
|
993
|
+
tests: [{
|
|
994
|
+
results: [{
|
|
995
|
+
status: 'passed',
|
|
996
|
+
duration: 100,
|
|
997
|
+
attachments: [
|
|
998
|
+
{ path: screenshotPath, contentType: 'image/png', name: 'screenshot' },
|
|
999
|
+
],
|
|
1000
|
+
}],
|
|
1001
|
+
}],
|
|
1002
|
+
}],
|
|
1003
|
+
}],
|
|
1004
|
+
};
|
|
1005
|
+
mockFs.readFileSync.mockImplementation((p) => {
|
|
1006
|
+
if (typeof p === 'string' && p.endsWith('report.json')) {
|
|
1007
|
+
return JSON.stringify(report);
|
|
1008
|
+
}
|
|
1009
|
+
return '';
|
|
1010
|
+
});
|
|
1011
|
+
const job = createTestJob();
|
|
1012
|
+
await executeJobFn(job, mockClient);
|
|
1013
|
+
// uploadArtifact should NOT be called since file doesn't exist
|
|
1014
|
+
(0, vitest_1.expect)(mockClient.uploadArtifact).not.toHaveBeenCalled();
|
|
1015
|
+
});
|
|
1016
|
+
});
|
|
1017
|
+
//# sourceMappingURL=executor.test.js.map
|