@samsara-dev/appwright 0.9.7 → 0.9.9
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,19 @@
|
|
|
1
1
|
# appwright
|
|
2
2
|
|
|
3
|
+
## 0.9.9
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- bfa24e5: Fix corrupt video trim when BrowserStack video download is incomplete
|
|
8
|
+
- Recreate write stream on each download retry to avoid appending corrupt data from previous failed attempts
|
|
9
|
+
- Add MP4 moov atom validation before attempting ffmpeg trim to fail fast with a clear error
|
|
10
|
+
|
|
11
|
+
## 0.9.8
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- eb8aea2: Cap video download retry backoff at 60s and add timestamps to all download logs
|
|
16
|
+
|
|
3
17
|
## 0.9.7
|
|
4
18
|
|
|
5
19
|
### 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,10 +171,10 @@ 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) {
|
|
177
|
+
logger_1.logger.log(`[${new Date().toISOString()}] Video download starting: ${videoURL}`);
|
|
178
178
|
await (0, async_retry_1.default)(async () => {
|
|
179
179
|
const response = await fetch(videoURL, {
|
|
180
180
|
method: "GET",
|
|
@@ -187,53 +187,52 @@ class BrowserStackDeviceProvider {
|
|
|
187
187
|
if (!reader) {
|
|
188
188
|
throw new Error("Failed to get reader from response body.");
|
|
189
189
|
}
|
|
190
|
-
|
|
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 {
|
|
191
200
|
while (true) {
|
|
192
201
|
const { done, value } = await reader.read();
|
|
193
202
|
if (done)
|
|
194
203
|
break;
|
|
195
|
-
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
|
+
}
|
|
196
208
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
209
|
+
fileStream.end();
|
|
210
|
+
await streamDone;
|
|
211
|
+
}
|
|
212
|
+
catch (err) {
|
|
213
|
+
fileStream.destroy();
|
|
214
|
+
throw err;
|
|
215
|
+
}
|
|
200
216
|
}, {
|
|
201
217
|
retries: 10,
|
|
202
218
|
minTimeout: 3_000,
|
|
219
|
+
maxTimeout: 60_000,
|
|
203
220
|
onRetry: (err, i) => {
|
|
204
221
|
const message = err instanceof Error ? err.message : String(err);
|
|
205
|
-
|
|
206
|
-
logger_1.logger.warn(`Retry attempt ${i} failed: ${message}`);
|
|
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(`Download finished and file closed: ${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;
|
|
233
231
|
}
|
|
234
232
|
}
|
|
235
233
|
catch (e) {
|
|
236
|
-
|
|
234
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
235
|
+
logger_1.logger.warn(`[${new Date().toISOString()}] Video download failed after all retries: ${message}. Test will complete without video attachment.`);
|
|
237
236
|
return null;
|
|
238
237
|
}
|
|
239
238
|
}
|
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;IAsGtC,KAAK;YAKG,kCAAkC;IA6ChD,OAAO,CAAC,4BAA4B;IAyBpC,OAAO,CAAC,qBAAqB;CAU9B;
|
|
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;IAsGtC,KAAK;YAKG,kCAAkC;IA6ChD,OAAO,CAAC,4BAA4B;IAyBpC,OAAO,CAAC,qBAAqB;CAU9B;AAwHD,eAAe,eAAe,CAAC"}
|
package/dist/reporter.js
CHANGED
|
@@ -189,8 +189,43 @@ function waitFor(condition, timeout = 60 * 60 * 1000) {
|
|
|
189
189
|
}, 500);
|
|
190
190
|
});
|
|
191
191
|
}
|
|
192
|
+
/**
|
|
193
|
+
* Validate that an MP4 file has a valid moov atom (metadata header).
|
|
194
|
+
* BrowserStack videos can be incomplete if downloaded before finalization,
|
|
195
|
+
* resulting in a missing moov atom that causes ffmpeg to fail.
|
|
196
|
+
*/
|
|
197
|
+
function validateMp4(filePath) {
|
|
198
|
+
try {
|
|
199
|
+
const fd = fs_1.default.openSync(filePath, "r");
|
|
200
|
+
const stat = fs_1.default.fstatSync(fd);
|
|
201
|
+
const moovMarker = Buffer.from("moov");
|
|
202
|
+
const chunkSize = Math.min(stat.size, 128 * 1024);
|
|
203
|
+
// Check the beginning (fast-start MP4s place moov before mdat)
|
|
204
|
+
const head = Buffer.alloc(chunkSize);
|
|
205
|
+
fs_1.default.readSync(fd, head, 0, chunkSize, 0);
|
|
206
|
+
if (head.includes(moovMarker)) {
|
|
207
|
+
fs_1.default.closeSync(fd);
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
// Check the end (standard MP4s place moov after mdat)
|
|
211
|
+
if (stat.size > chunkSize) {
|
|
212
|
+
const tail = Buffer.alloc(chunkSize);
|
|
213
|
+
fs_1.default.readSync(fd, tail, 0, chunkSize, stat.size - chunkSize);
|
|
214
|
+
fs_1.default.closeSync(fd);
|
|
215
|
+
return tail.includes(moovMarker);
|
|
216
|
+
}
|
|
217
|
+
fs_1.default.closeSync(fd);
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
192
224
|
function trimVideo({ originalVideoPath, startSecs, durationSecs, outputPath, }) {
|
|
193
225
|
logger_1.logger.log(`Attemping to trim video: ${originalVideoPath} at start: ${startSecs} and duration: ${durationSecs} to ${outputPath}`);
|
|
226
|
+
if (!validateMp4(originalVideoPath)) {
|
|
227
|
+
throw new Error(`Video file is incomplete or corrupt (missing moov atom): ${originalVideoPath}`);
|
|
228
|
+
}
|
|
194
229
|
const copyName = `draft-for-${outputPath}`;
|
|
195
230
|
const dirPath = path_1.default.dirname(originalVideoPath);
|
|
196
231
|
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";
|