@samsara-dev/appwright 0.9.8 → 0.9.10
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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# appwright
|
|
2
2
|
|
|
3
|
+
## 0.9.10
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 851d551: Fix reporter error when persistent device worker has no session
|
|
8
|
+
|
|
9
|
+
Previously, the VideoDownloader reporter threw an error when a worker's info
|
|
10
|
+
file existed but had no `providerName` or `sessionId` (e.g., when a test was
|
|
11
|
+
skipped before establishing a BrowserStack session). This caused misleading
|
|
12
|
+
"Failed to get worker end time" error logs.
|
|
13
|
+
|
|
14
|
+
Now gracefully skips video download for workers without sessions instead of
|
|
15
|
+
throwing.
|
|
16
|
+
|
|
17
|
+
## 0.9.9
|
|
18
|
+
|
|
19
|
+
### Patch Changes
|
|
20
|
+
|
|
21
|
+
- bfa24e5: Fix corrupt video trim when BrowserStack video download is incomplete
|
|
22
|
+
- Recreate write stream on each download retry to avoid appending corrupt data from previous failed attempts
|
|
23
|
+
- Add MP4 moov atom validation before attempting ffmpeg trim to fail fast with a clear error
|
|
24
|
+
|
|
3
25
|
## 0.9.8
|
|
4
26
|
|
|
5
27
|
### Patch Changes
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/providers/browserstack/index.ts"],"names":[],"mappings":"AAIA,OAAO,EACL,eAAe,EACf,cAAc,EAGf,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAC/C,OAAO,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AA0DtC,qBAAa,0BAA2B,YAAW,cAAc;IAC/D,OAAO,CAAC,cAAc,CAAC,CAA6B;IACpD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,OAAO,CAA+B;gBAG5C,OAAO,EAAE,WAAW,CAAC,eAAe,CAAC,EACrC,WAAW,EAAE,MAAM,GAAG,SAAS;IAU3B,WAAW;IA0EX,SAAS,IAAI,OAAO,CAAC,MAAM,CAAC;IAMlC,OAAO,CAAC,cAAc;YASR,YAAY;YAkCZ,iBAAiB;YAKjB,yBAAyB;WAK1B,aAAa,CACxB,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/providers/browserstack/index.ts"],"names":[],"mappings":"AAIA,OAAO,EACL,eAAe,EACf,cAAc,EAGf,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAC/C,OAAO,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AA0DtC,qBAAa,0BAA2B,YAAW,cAAc;IAC/D,OAAO,CAAC,cAAc,CAAC,CAA6B;IACpD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,OAAO,CAA+B;gBAG5C,OAAO,EAAE,WAAW,CAAC,eAAe,CAAC,EACrC,WAAW,EAAE,MAAM,GAAG,SAAS;IAU3B,WAAW;IA0EX,SAAS,IAAI,OAAO,CAAC,MAAM,CAAC;IAMlC,OAAO,CAAC,cAAc;YASR,YAAY;YAkCZ,iBAAiB;YAKjB,yBAAyB;WAK1B,aAAa,CACxB,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IAmGlD,eAAe,CAAC,OAAO,EAAE;QAC7B,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,IAAI,CAAC,EAAE,MAAM,CAAC;KACf;IA2BD,OAAO,CAAC,YAAY;CA4KrB"}
|
|
@@ -171,7 +171,6 @@ class BrowserStackDeviceProvider {
|
|
|
171
171
|
* of 10 retries, whichever comes first.
|
|
172
172
|
*/
|
|
173
173
|
await new Promise((resolve) => setTimeout(resolve, 10_000));
|
|
174
|
-
const fileStream = fs_1.default.createWriteStream(tempPathForWriting);
|
|
175
174
|
//To catch the browserstack error in case all retries fails
|
|
176
175
|
try {
|
|
177
176
|
if (videoURL) {
|
|
@@ -188,16 +187,32 @@ class BrowserStackDeviceProvider {
|
|
|
188
187
|
if (!reader) {
|
|
189
188
|
throw new Error("Failed to get reader from response body.");
|
|
190
189
|
}
|
|
191
|
-
|
|
190
|
+
// Create a fresh write stream on each attempt to avoid
|
|
191
|
+
// appending corrupt data from previous failed retries
|
|
192
|
+
const fileStream = fs_1.default.createWriteStream(tempPathForWriting);
|
|
193
|
+
// Register finish/error listeners before any writes so errors
|
|
194
|
+
// during the loop are captured and finish always resolves.
|
|
195
|
+
const streamDone = new Promise((resolve, reject) => {
|
|
196
|
+
fileStream.on("finish", resolve);
|
|
197
|
+
fileStream.on("error", reject);
|
|
198
|
+
});
|
|
199
|
+
try {
|
|
192
200
|
while (true) {
|
|
193
201
|
const { done, value } = await reader.read();
|
|
194
202
|
if (done)
|
|
195
203
|
break;
|
|
196
|
-
fileStream.write(value)
|
|
204
|
+
if (!fileStream.write(value)) {
|
|
205
|
+
// Back-pressure: wait for drain before continuing
|
|
206
|
+
await new Promise((r) => fileStream.once("drain", r));
|
|
207
|
+
}
|
|
197
208
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
209
|
+
fileStream.end();
|
|
210
|
+
await streamDone;
|
|
211
|
+
}
|
|
212
|
+
catch (err) {
|
|
213
|
+
fileStream.destroy();
|
|
214
|
+
throw err;
|
|
215
|
+
}
|
|
201
216
|
}, {
|
|
202
217
|
retries: 10,
|
|
203
218
|
minTimeout: 3_000,
|
|
@@ -207,26 +222,9 @@ class BrowserStackDeviceProvider {
|
|
|
207
222
|
logger_1.logger.warn(`[${new Date().toISOString()}] Video download retry ${i}/10 failed: ${message}`);
|
|
208
223
|
},
|
|
209
224
|
});
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
try {
|
|
214
|
-
fs_1.default.renameSync(tempPathForWriting, pathToTestVideo);
|
|
215
|
-
logger_1.logger.log(`[${new Date().toISOString()}] Video download completed: ${pathToTestVideo}`);
|
|
216
|
-
resolve({ path: pathToTestVideo, contentType: "video/mp4" });
|
|
217
|
-
}
|
|
218
|
-
catch (err) {
|
|
219
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
220
|
-
logger_1.logger.error(`Failed to rename file: ${message}`, err);
|
|
221
|
-
reject(err instanceof Error ? err : new Error(message));
|
|
222
|
-
}
|
|
223
|
-
});
|
|
224
|
-
fileStream.on("error", (err) => {
|
|
225
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
226
|
-
logger_1.logger.error(`Failed to write file: ${message}`, err);
|
|
227
|
-
reject(err instanceof Error ? err : new Error(message));
|
|
228
|
-
});
|
|
229
|
-
});
|
|
225
|
+
fs_1.default.renameSync(tempPathForWriting, pathToTestVideo);
|
|
226
|
+
logger_1.logger.log(`[${new Date().toISOString()}] Video download completed: ${pathToTestVideo}`);
|
|
227
|
+
return { path: pathToTestVideo, contentType: "video/mp4" };
|
|
230
228
|
}
|
|
231
229
|
else {
|
|
232
230
|
return null;
|
package/dist/reporter.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"reporter.d.ts","sourceRoot":"","sources":["../src/reporter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAC;AAUhF,cAAM,eAAgB,YAAW,QAAQ;IACvC,OAAO,CAAC,gBAAgB,CAAsB;IAE9C,OAAO;IAQP,WAAW,CAAC,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,UAAU;IAU9C,SAAS,CAAC,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,UAAU;
|
|
1
|
+
{"version":3,"file":"reporter.d.ts","sourceRoot":"","sources":["../src/reporter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAC;AAUhF,cAAM,eAAgB,YAAW,QAAQ;IACvC,OAAO,CAAC,gBAAgB,CAAsB;IAE9C,OAAO;IAQP,WAAW,CAAC,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,UAAU;IAU9C,SAAS,CAAC,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,UAAU;IAuGtC,KAAK;YAKG,kCAAkC;IA6ChD,OAAO,CAAC,4BAA4B;IAyBpC,OAAO,CAAC,qBAAqB;CAU9B;AAwHD,eAAe,eAAe,CAAC"}
|
package/dist/reporter.js
CHANGED
|
@@ -70,7 +70,8 @@ class VideoDownloader {
|
|
|
70
70
|
}
|
|
71
71
|
const { providerName, sessionId, endTime } = workerInfo;
|
|
72
72
|
if (!providerName || !sessionId) {
|
|
73
|
-
|
|
73
|
+
logger_1.logger.log(`No provider/session for worker ${workerIndex}, skipping video`);
|
|
74
|
+
return;
|
|
74
75
|
}
|
|
75
76
|
if (!this.providerSupportsVideo(providerName)) {
|
|
76
77
|
return; // Nothing to do here
|
|
@@ -189,8 +190,43 @@ function waitFor(condition, timeout = 60 * 60 * 1000) {
|
|
|
189
190
|
}, 500);
|
|
190
191
|
});
|
|
191
192
|
}
|
|
193
|
+
/**
|
|
194
|
+
* Validate that an MP4 file has a valid moov atom (metadata header).
|
|
195
|
+
* BrowserStack videos can be incomplete if downloaded before finalization,
|
|
196
|
+
* resulting in a missing moov atom that causes ffmpeg to fail.
|
|
197
|
+
*/
|
|
198
|
+
function validateMp4(filePath) {
|
|
199
|
+
try {
|
|
200
|
+
const fd = fs_1.default.openSync(filePath, "r");
|
|
201
|
+
const stat = fs_1.default.fstatSync(fd);
|
|
202
|
+
const moovMarker = Buffer.from("moov");
|
|
203
|
+
const chunkSize = Math.min(stat.size, 128 * 1024);
|
|
204
|
+
// Check the beginning (fast-start MP4s place moov before mdat)
|
|
205
|
+
const head = Buffer.alloc(chunkSize);
|
|
206
|
+
fs_1.default.readSync(fd, head, 0, chunkSize, 0);
|
|
207
|
+
if (head.includes(moovMarker)) {
|
|
208
|
+
fs_1.default.closeSync(fd);
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
// Check the end (standard MP4s place moov after mdat)
|
|
212
|
+
if (stat.size > chunkSize) {
|
|
213
|
+
const tail = Buffer.alloc(chunkSize);
|
|
214
|
+
fs_1.default.readSync(fd, tail, 0, chunkSize, stat.size - chunkSize);
|
|
215
|
+
fs_1.default.closeSync(fd);
|
|
216
|
+
return tail.includes(moovMarker);
|
|
217
|
+
}
|
|
218
|
+
fs_1.default.closeSync(fd);
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
192
225
|
function trimVideo({ originalVideoPath, startSecs, durationSecs, outputPath, }) {
|
|
193
226
|
logger_1.logger.log(`Attemping to trim video: ${originalVideoPath} at start: ${startSecs} and duration: ${durationSecs} to ${outputPath}`);
|
|
227
|
+
if (!validateMp4(originalVideoPath)) {
|
|
228
|
+
throw new Error(`Video file is incomplete or corrupt (missing moov atom): ${originalVideoPath}`);
|
|
229
|
+
}
|
|
194
230
|
const copyName = `draft-for-${outputPath}`;
|
|
195
231
|
const dirPath = path_1.default.dirname(originalVideoPath);
|
|
196
232
|
const copyFullPath = path_1.default.join(dirPath, copyName);
|
|
@@ -135,7 +135,8 @@ let VideoDownloader;
|
|
|
135
135
|
tests: [],
|
|
136
136
|
}, null, 2));
|
|
137
137
|
const downloadedVideoPath = path_1.default.join(mockBasePath, `${workerVideoBaseName}.mp4`);
|
|
138
|
-
|
|
138
|
+
// Include a fake moov atom so validateMp4 passes
|
|
139
|
+
await promises_1.default.writeFile(downloadedVideoPath, "fake-video-moov-marker");
|
|
139
140
|
downloadVideoMock.mockResolvedValueOnce({
|
|
140
141
|
path: downloadedVideoPath,
|
|
141
142
|
contentType: "video/mp4",
|
|
@@ -165,6 +166,67 @@ let VideoDownloader;
|
|
|
165
166
|
},
|
|
166
167
|
]);
|
|
167
168
|
});
|
|
169
|
+
(0, vitest_1.test)("falls back to full video when downloaded MP4 is corrupt (missing moov atom)", async () => {
|
|
170
|
+
vitest_1.vi.useFakeTimers();
|
|
171
|
+
mockBasePath = await promises_1.default.mkdtemp(path_1.default.join(os_1.default.tmpdir(), "appwright-videos-"));
|
|
172
|
+
const workerIndex = 0;
|
|
173
|
+
const sessionId = "session-corrupt";
|
|
174
|
+
const providerName = "browserstack";
|
|
175
|
+
const workerVideoBaseName = `worker-${workerIndex}-${sessionId}-video`;
|
|
176
|
+
const workerStart = new Date("2025-01-01T00:00:00.000Z");
|
|
177
|
+
const testStart = new Date("2025-01-01T00:00:10.000Z");
|
|
178
|
+
await promises_1.default.writeFile(path_1.default.join(mockBasePath, `worker-info-${workerIndex}.json`), JSON.stringify({
|
|
179
|
+
idx: workerIndex,
|
|
180
|
+
sessionId,
|
|
181
|
+
providerName,
|
|
182
|
+
startTime: {
|
|
183
|
+
beforeAppiumSession: workerStart.toISOString(),
|
|
184
|
+
afterAppiumSession: workerStart.toISOString(),
|
|
185
|
+
},
|
|
186
|
+
endTime: new Date("2025-01-01T00:00:20.000Z").toISOString(),
|
|
187
|
+
tests: [],
|
|
188
|
+
}, null, 2));
|
|
189
|
+
const downloadedVideoPath = path_1.default.join(mockBasePath, `${workerVideoBaseName}.mp4`);
|
|
190
|
+
// Write a file WITHOUT moov atom — simulates an incomplete BrowserStack download
|
|
191
|
+
await promises_1.default.writeFile(downloadedVideoPath, "corrupt-partial-video-data");
|
|
192
|
+
downloadVideoMock.mockResolvedValueOnce({
|
|
193
|
+
path: downloadedVideoPath,
|
|
194
|
+
contentType: "video/mp4",
|
|
195
|
+
});
|
|
196
|
+
const { logger } = await import("../logger.js");
|
|
197
|
+
const reporter = new VideoDownloader();
|
|
198
|
+
const testCase = {
|
|
199
|
+
id: "test-corrupt",
|
|
200
|
+
title: "corrupt video test",
|
|
201
|
+
annotations: [],
|
|
202
|
+
};
|
|
203
|
+
const testResult = {
|
|
204
|
+
workerIndex,
|
|
205
|
+
duration: 5000,
|
|
206
|
+
startTime: testStart,
|
|
207
|
+
retry: 0,
|
|
208
|
+
attachments: [],
|
|
209
|
+
};
|
|
210
|
+
reporter.onTestEnd(testCase, testResult);
|
|
211
|
+
await vitest_1.vi.advanceTimersByTimeAsync(5000);
|
|
212
|
+
await reporter.onEnd();
|
|
213
|
+
// Should fall back to attaching the full untrimmed video
|
|
214
|
+
(0, vitest_1.expect)(testResult.attachments).toMatchObject([
|
|
215
|
+
{
|
|
216
|
+
contentType: "video/mp4",
|
|
217
|
+
name: "video",
|
|
218
|
+
path: downloadedVideoPath,
|
|
219
|
+
},
|
|
220
|
+
]);
|
|
221
|
+
// Should annotate the test with a videoError explaining the fallback
|
|
222
|
+
const videoErrorAnnotation = testCase.annotations.find((a) => a.type === "videoError");
|
|
223
|
+
(0, vitest_1.expect)(videoErrorAnnotation).toBeDefined();
|
|
224
|
+
(0, vitest_1.expect)(videoErrorAnnotation.description).toContain("Unable to trim video");
|
|
225
|
+
// Should log the trim failure
|
|
226
|
+
(0, vitest_1.expect)(logger.error).toHaveBeenCalledWith("Failed to trim video:", vitest_1.expect.objectContaining({
|
|
227
|
+
message: vitest_1.expect.stringContaining("missing moov atom"),
|
|
228
|
+
}));
|
|
229
|
+
});
|
|
168
230
|
(0, vitest_1.test)("skips BrowserStack video handling when video download is disabled", async () => {
|
|
169
231
|
vitest_1.vi.useFakeTimers();
|
|
170
232
|
process.env.APPWRIGHT_DISABLE_VIDEO_DOWNLOAD = "true";
|
|
@@ -201,4 +263,45 @@ let VideoDownloader;
|
|
|
201
263
|
(0, vitest_1.expect)(downloadVideoMock).not.toHaveBeenCalled();
|
|
202
264
|
(0, vitest_1.expect)(testResult.attachments).toEqual([]);
|
|
203
265
|
});
|
|
266
|
+
(0, vitest_1.test)("skips video gracefully when worker has no session (e.g., skipped test)", async () => {
|
|
267
|
+
vitest_1.vi.useFakeTimers();
|
|
268
|
+
mockBasePath = await promises_1.default.mkdtemp(path_1.default.join(os_1.default.tmpdir(), "appwright-videos-"));
|
|
269
|
+
const workerIndex = 1;
|
|
270
|
+
// Worker info exists but has no providerName or sessionId
|
|
271
|
+
// (worker was assigned but test was skipped before session creation)
|
|
272
|
+
await promises_1.default.writeFile(path_1.default.join(mockBasePath, `worker-info-${workerIndex}.json`), JSON.stringify({
|
|
273
|
+
idx: workerIndex,
|
|
274
|
+
startTime: {
|
|
275
|
+
beforeAppiumSession: new Date().toISOString(),
|
|
276
|
+
afterAppiumSession: new Date().toISOString(),
|
|
277
|
+
},
|
|
278
|
+
tests: [],
|
|
279
|
+
}, null, 2));
|
|
280
|
+
const { logger } = await import("../logger.js");
|
|
281
|
+
// Clear any state from previous tests
|
|
282
|
+
vitest_1.vi.mocked(logger.error).mockClear();
|
|
283
|
+
vitest_1.vi.mocked(logger.log).mockClear();
|
|
284
|
+
const reporter = new VideoDownloader();
|
|
285
|
+
const testCase = {
|
|
286
|
+
id: "test-skipped",
|
|
287
|
+
title: "skipped test on worker without session",
|
|
288
|
+
annotations: [],
|
|
289
|
+
};
|
|
290
|
+
const testResult = {
|
|
291
|
+
workerIndex,
|
|
292
|
+
duration: 100,
|
|
293
|
+
startTime: new Date(),
|
|
294
|
+
attachments: [],
|
|
295
|
+
};
|
|
296
|
+
reporter.onTestEnd(testCase, testResult);
|
|
297
|
+
await vitest_1.vi.advanceTimersByTimeAsync(5000);
|
|
298
|
+
await reporter.onEnd();
|
|
299
|
+
// Should not attempt to download any video
|
|
300
|
+
(0, vitest_1.expect)(downloadVideoMock).not.toHaveBeenCalled();
|
|
301
|
+
(0, vitest_1.expect)(testResult.attachments).toEqual([]);
|
|
302
|
+
// Should NOT log an error (previously threw and was caught as error)
|
|
303
|
+
(0, vitest_1.expect)(logger.error).not.toHaveBeenCalled();
|
|
304
|
+
// Should log an informational skip message
|
|
305
|
+
(0, vitest_1.expect)(logger.log).toHaveBeenCalledWith(vitest_1.expect.stringContaining("skipping video"));
|
|
306
|
+
});
|
|
204
307
|
});
|