@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;IAsGlD,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"}
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
- const streamToFile = async () => {
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
- await streamToFile();
200
- fileStream.close();
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
- return new Promise((resolve, reject) => {
211
- // Ensure file stream is closed even in case of an error
212
- fileStream.on("finish", () => {
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;
@@ -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;AA8ED,eAAe,eAAe,CAAC"}
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
- await promises_1.default.writeFile(downloadedVideoPath, "video-bytes");
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";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@samsara-dev/appwright",
3
- "version": "0.9.8",
3
+ "version": "0.9.9",
4
4
  "publishConfig": {
5
5
  "registry": "https://registry.npmjs.org/",
6
6
  "access": "public"