@samsara-dev/appwright 0.9.8 → 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,13 @@
|
|
|
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
|
+
|
|
3
11
|
## 0.9.8
|
|
4
12
|
|
|
5
13
|
### 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;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";
|