happo 6.5.2 → 6.6.1

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.
Files changed (53) hide show
  1. package/dist/cli/cancelJob-DETLA3XZ.js +10 -0
  2. package/dist/cli/chunk-2LNQIG6R.js +84 -0
  3. package/dist/cli/chunk-2LNQIG6R.js.map +7 -0
  4. package/dist/cli/{chunk-JZDVA76O.js → chunk-JMN6VM22.js} +3 -3
  5. package/dist/cli/{chunk-SB3TDZLE.js → chunk-MCYQSPED.js} +2 -2
  6. package/dist/cli/{chunk-I357LIPJ.js → chunk-MGNFR3W2.js} +2 -2
  7. package/dist/cli/{chunk-DSAGPJIH.js → chunk-OJHKEE3W.js} +4 -3
  8. package/dist/cli/chunk-OJHKEE3W.js.map +7 -0
  9. package/dist/cli/{chunk-IOLNNTKP.js → chunk-SE7XKHF6.js} +2 -2
  10. package/dist/cli/createAsyncComparison-B3USWOAI.js +10 -0
  11. package/dist/cli/{createAsyncReport-4M7HVIS3.js → createAsyncReport-3FKLGSYD.js} +4 -4
  12. package/dist/cli/{getFlakes-QCVM7BHM.js → getFlakes-5DA34KWE.js} +4 -4
  13. package/dist/cli/index.d.ts.map +1 -1
  14. package/dist/cli/main.js +11 -11
  15. package/dist/cli/main.js.map +2 -2
  16. package/dist/cli/package-CY7G2FGG.js +7 -0
  17. package/dist/cli/{prepareSnapRequests-NQGLK5M6.js → prepareSnapRequests-RSZU5TQG.js} +158 -73
  18. package/dist/cli/prepareSnapRequests-RSZU5TQG.js.map +7 -0
  19. package/dist/cli/startJob-OPTUE4PO.js +10 -0
  20. package/dist/cli/{wrapper-AGHA5SGK.js → wrapper-QAPBXQCV.js} +7 -9
  21. package/dist/cli/wrapper-QAPBXQCV.js.map +7 -0
  22. package/dist/config/RemoteBrowserTarget.d.ts.map +1 -1
  23. package/dist/cypress/task.js +191 -79
  24. package/dist/cypress/task.js.map +4 -4
  25. package/dist/e2e/controller.d.ts.map +1 -1
  26. package/dist/e2e/wrapper.d.ts.map +1 -1
  27. package/dist/network/getSignedToken.d.ts +3 -0
  28. package/dist/network/getSignedToken.d.ts.map +1 -0
  29. package/dist/network/makeHappoAPIRequest.d.ts +0 -1
  30. package/dist/network/makeHappoAPIRequest.d.ts.map +1 -1
  31. package/dist/network/uploadAssets.d.ts.map +1 -1
  32. package/dist/playwright/index.js +191 -79
  33. package/dist/playwright/index.js.map +4 -4
  34. package/package.json +3 -2
  35. package/dist/cli/cancelJob-RTDZ3IL3.js +0 -10
  36. package/dist/cli/chunk-DSAGPJIH.js.map +0 -7
  37. package/dist/cli/chunk-JEFG3R6O.js +0 -54
  38. package/dist/cli/chunk-JEFG3R6O.js.map +0 -7
  39. package/dist/cli/createAsyncComparison-3ZFRRUDB.js +0 -10
  40. package/dist/cli/package-OPMNMDIG.js +0 -7
  41. package/dist/cli/prepareSnapRequests-NQGLK5M6.js.map +0 -7
  42. package/dist/cli/startJob-GO6BQDB4.js +0 -10
  43. package/dist/cli/wrapper-AGHA5SGK.js.map +0 -7
  44. /package/dist/cli/{cancelJob-RTDZ3IL3.js.map → cancelJob-DETLA3XZ.js.map} +0 -0
  45. /package/dist/cli/{chunk-JZDVA76O.js.map → chunk-JMN6VM22.js.map} +0 -0
  46. /package/dist/cli/{chunk-SB3TDZLE.js.map → chunk-MCYQSPED.js.map} +0 -0
  47. /package/dist/cli/{chunk-I357LIPJ.js.map → chunk-MGNFR3W2.js.map} +0 -0
  48. /package/dist/cli/{chunk-IOLNNTKP.js.map → chunk-SE7XKHF6.js.map} +0 -0
  49. /package/dist/cli/{createAsyncComparison-3ZFRRUDB.js.map → createAsyncComparison-B3USWOAI.js.map} +0 -0
  50. /package/dist/cli/{createAsyncReport-4M7HVIS3.js.map → createAsyncReport-3FKLGSYD.js.map} +0 -0
  51. /package/dist/cli/{getFlakes-QCVM7BHM.js.map → getFlakes-5DA34KWE.js.map} +0 -0
  52. /package/dist/cli/{package-OPMNMDIG.js.map → package-CY7G2FGG.js.map} +0 -0
  53. /package/dist/cli/{startJob-GO6BQDB4.js.map → startJob-OPTUE4PO.js.map} +0 -0
@@ -19,7 +19,7 @@ import asyncRetry from "async-retry";
19
19
  // package.json
20
20
  var package_default = {
21
21
  name: "happo",
22
- version: "6.5.2",
22
+ version: "6.6.1",
23
23
  description: "Catch unexpected visual and accessibility changes and UI bugs",
24
24
  license: "MIT",
25
25
  repository: {
@@ -92,6 +92,7 @@ var package_default = {
92
92
  "test:cypress": "pnpm build:dist && node --env-file-if-exists=.env.local dist/cli/main.js -c ./happoconfigs/happo.cypress.config.ts e2e -- cypress run -C src/cypress/__cypress__/cypress.config.ts",
93
93
  "test:cypress:open": "cypress open -C src/cypress/__cypress__/cypress.config.ts",
94
94
  "test:playwright": "pnpm build:dist && node --env-file-if-exists=.env.local dist/cli/main.js -c ./happoconfigs/happo.playwright.config.ts e2e -- playwright test",
95
+ "test:playwright:nonce": "pnpm build:dist && node --env-file-if-exists=.env.local dist/cli/main.js -c ./happoconfigs/happo.playwright-nonce.config.ts --nonce $HAPPO_NONCE e2e -- playwright test && node --env-file-if-exists=.env.local dist/cli/main.js -c ./happoconfigs/happo.playwright-nonce.config.ts --nonce $HAPPO_NONCE finalize",
95
96
  "test:storybook": "pnpm build:dist && node --env-file-if-exists=.env.local dist/cli/main.js -c ./happoconfigs/happo.storybook.config.ts",
96
97
  "test:pages": "pnpm build:dist && node --env-file-if-exists=.env.local dist/cli/main.js -c ./happoconfigs/happo.pages.config.ts",
97
98
  tsc: "tsc --build tsconfig.json"
@@ -143,7 +144,7 @@ var package_default = {
143
144
  "eslint-plugin-simple-import-sort": "^12.1.1",
144
145
  "eslint-plugin-unicorn": "^63.0.0",
145
146
  jiti: "^2.6.1",
146
- jsdom: "^28.0.0",
147
+ jsdom: "^29.0.0",
147
148
  multiparty: "^4.2.3",
148
149
  prettier: "^3.6.2",
149
150
  react: "^19.2.0",
@@ -655,12 +656,42 @@ async function loadConfigFile(configFilePath, environment, logger = console) {
655
656
  return configWithDefaults;
656
657
  }
657
658
 
658
- // src/network/makeHappoAPIRequest.ts
659
+ // src/network/getSignedToken.ts
659
660
  import { SignJWT } from "jose";
660
- async function signRequest(apiKey, apiSecret) {
661
- const encodedSecret = new TextEncoder().encode(apiSecret);
662
- return await new SignJWT({ key: apiKey }).setProtectedHeader({ alg: "HS256", kid: apiKey }).sign(encodedSecret);
661
+ var TOKEN_TTL_SECONDS = 5 * 60;
662
+ var TOKEN_REFRESH_BUFFER_SECONDS = 30;
663
+ var cache = /* @__PURE__ */ new Map();
664
+ function getCacheKey(apiKey, apiSecret) {
665
+ return `${apiKey}:${apiSecret}`;
663
666
  }
667
+ async function getSignedToken(apiKey, apiSecret) {
668
+ const cacheKey = getCacheKey(apiKey, apiSecret);
669
+ const cachedPromise = cache.get(cacheKey);
670
+ if (cachedPromise) {
671
+ const cached = await cachedPromise;
672
+ const nowSeconds = Date.now() / 1e3;
673
+ if (cached.expiresAt - nowSeconds > TOKEN_REFRESH_BUFFER_SECONDS) {
674
+ return cached.token;
675
+ }
676
+ }
677
+ const signingPromise = (async () => {
678
+ const nowSeconds = Date.now() / 1e3;
679
+ const expiresAt = Math.floor(nowSeconds) + TOKEN_TTL_SECONDS;
680
+ const encodedSecret = new TextEncoder().encode(apiSecret);
681
+ const token = await new SignJWT({ key: apiKey }).setProtectedHeader({ alg: "HS256", kid: apiKey }).setExpirationTime(expiresAt).sign(encodedSecret);
682
+ return { token, expiresAt };
683
+ })();
684
+ cache.set(cacheKey, signingPromise);
685
+ try {
686
+ const { token } = await signingPromise;
687
+ return token;
688
+ } catch (error) {
689
+ cache.delete(cacheKey);
690
+ throw error;
691
+ }
692
+ }
693
+
694
+ // src/network/makeHappoAPIRequest.ts
664
695
  async function makeHappoAPIRequest({ url, path: path3, method = "GET", formData, body }, { apiKey, apiSecret, endpoint }, {
665
696
  retryCount = 0,
666
697
  timeout = 6e4,
@@ -673,7 +704,7 @@ async function makeHappoAPIRequest({ url, path: path3, method = "GET", formData,
673
704
  "No fetch URL provided. Either `path` (preferred) or `url` must be provided."
674
705
  );
675
706
  }
676
- const signed = await signRequest(apiKey, apiSecret);
707
+ const signed = await getSignedToken(apiKey, apiSecret);
677
708
  const headers = {
678
709
  Authorization: `Bearer ${signed}`
679
710
  };
@@ -709,6 +740,7 @@ function createHash(data) {
709
740
 
710
741
  // src/config/RemoteBrowserTarget.ts
711
742
  var VIEWPORT_PATTERN = /^([0-9]+)x([0-9]+)$/;
743
+ var MAX_BULK_ITEMS_PER_REQUEST = 50;
712
744
  function computeDefaultChunks(estimatedSnapCount) {
713
745
  if (!Number.isFinite(estimatedSnapCount) || estimatedSnapCount <= 0) {
714
746
  return 1;
@@ -729,6 +761,71 @@ function getPageSlices(pages, chunks) {
729
761
  }
730
762
  return result;
731
763
  }
764
+ function buildChunkItem({
765
+ slice,
766
+ chunk,
767
+ pageSlice,
768
+ browserName,
769
+ viewport,
770
+ maxHeight,
771
+ otherOptions,
772
+ globalCSS,
773
+ staticPackage,
774
+ assetsPackage,
775
+ targetName
776
+ }) {
777
+ const payloadString = JSON.stringify({
778
+ viewport,
779
+ maxHeight,
780
+ ...otherOptions,
781
+ globalCSS,
782
+ snapPayloads: slice,
783
+ chunk,
784
+ staticPackage,
785
+ assetsPackage,
786
+ pages: pageSlice,
787
+ extendsSha: pageSlice ? pageSlice.extendsSha : void 0
788
+ });
789
+ const payloadHash = createHash(payloadString + (pageSlice ? Math.random() : ""));
790
+ const type = pageSlice && pageSlice.extendsSha ? "extends-report" : `browser-${browserName}`;
791
+ const item = { type, targetName, payloadString, payloadHash };
792
+ if (pageSlice?.extendsSha) {
793
+ item.extendsSha = pageSlice.extendsSha;
794
+ }
795
+ return item;
796
+ }
797
+ async function sendIndividualSnapRequest(item, config) {
798
+ const formData = {
799
+ type: item.type,
800
+ targetName: item.targetName,
801
+ payloadHash: item.payloadHash,
802
+ payload: new File([item.payloadString], "payload.json", {
803
+ type: "application/json"
804
+ })
805
+ };
806
+ if (item.extendsSha) {
807
+ formData.extendsSha = item.extendsSha;
808
+ }
809
+ const requestResult = await makeHappoAPIRequest(
810
+ {
811
+ path: `/api/snap-requests?payloadHash=${item.payloadHash}`,
812
+ method: "POST",
813
+ formData
814
+ },
815
+ config,
816
+ { retryCount: 5 }
817
+ );
818
+ if (!requestResult) {
819
+ throw new Error("No requestResult");
820
+ }
821
+ if (!("requestId" in requestResult)) {
822
+ throw new Error("No requestId in requestResult");
823
+ }
824
+ if (typeof requestResult.requestId !== "number") {
825
+ throw new TypeError("requestId is not a number");
826
+ }
827
+ return requestResult.requestId;
828
+ }
732
829
  var RemoteBrowserTarget = class {
733
830
  chunks;
734
831
  browserName;
@@ -767,73 +864,30 @@ var RemoteBrowserTarget = class {
767
864
  targetName,
768
865
  estimatedSnapsCount
769
866
  }, config) {
770
- const boundMakeRequest = async ({
771
- slice,
772
- chunk,
773
- pageSlice
774
- }) => {
775
- const payloadString = JSON.stringify({
776
- viewport: this.viewport,
777
- maxHeight: this.maxHeight,
778
- ...this.otherOptions,
779
- globalCSS,
780
- snapPayloads: slice,
781
- chunk,
782
- staticPackage,
783
- assetsPackage,
784
- pages: pageSlice,
785
- extendsSha: pageSlice ? pageSlice.extendsSha : void 0
786
- });
787
- const payloadHash = createHash(
788
- payloadString + (pageSlice ? Math.random() : "")
789
- );
790
- const formData = {
791
- type: pageSlice && pageSlice.extendsSha ? "extends-report" : `browser-${this.browserName}`,
792
- targetName,
793
- payloadHash,
794
- payload: new File([payloadString], "payload.json", {
795
- type: "application/json"
796
- })
797
- };
798
- if (pageSlice && pageSlice.extendsSha) {
799
- formData.extendsSha = pageSlice.extendsSha;
800
- }
801
- const requestResult = await makeHappoAPIRequest(
802
- {
803
- path: `/api/snap-requests?payloadHash=${payloadHash}`,
804
- method: "POST",
805
- json: true,
806
- formData
807
- },
808
- config,
809
- { retryCount: 5 }
810
- );
811
- if (!requestResult) {
812
- throw new Error("No requestResult");
813
- }
814
- if (!("requestId" in requestResult)) {
815
- throw new Error("No requestId in requestResult");
816
- }
817
- if (typeof requestResult.requestId !== "number") {
818
- throw new TypeError("requestId is not a number");
819
- }
820
- return requestResult.requestId;
867
+ const buildItemParams = {
868
+ browserName: this.browserName,
869
+ viewport: this.viewport,
870
+ maxHeight: this.maxHeight,
871
+ otherOptions: this.otherOptions,
872
+ globalCSS,
873
+ staticPackage,
874
+ assetsPackage,
875
+ targetName
821
876
  };
822
- const requestIds = [];
877
+ const items = [];
823
878
  if (staticPackage) {
824
879
  const effectiveChunks = this.chunks ?? Math.max(1, computeDefaultChunks(estimatedSnapsCount ?? 0));
825
880
  for (let i = 0; i < effectiveChunks; i += 1) {
826
- const requestId = await boundMakeRequest({
827
- chunk: effectiveChunks > 1 ? { index: i, total: effectiveChunks } : void 0
828
- });
829
- requestIds.push(requestId);
881
+ items.push(
882
+ buildChunkItem({
883
+ ...buildItemParams,
884
+ chunk: effectiveChunks > 1 ? { index: i, total: effectiveChunks } : void 0
885
+ })
886
+ );
830
887
  }
831
888
  } else if (pages) {
832
889
  for (const pageSlice of getPageSlices(pages, this.chunks ?? 1)) {
833
- const requestId = await boundMakeRequest({
834
- pageSlice
835
- });
836
- requestIds.push(requestId);
890
+ items.push(buildChunkItem({ ...buildItemParams, pageSlice }));
837
891
  }
838
892
  } else {
839
893
  const effectiveChunks = this.chunks ?? 1;
@@ -843,17 +897,69 @@ var RemoteBrowserTarget = class {
843
897
  i * snapsPerChunk,
844
898
  i * snapsPerChunk + snapsPerChunk
845
899
  );
846
- const requestId = await boundMakeRequest({
847
- slice
848
- });
849
- requestIds.push(requestId);
900
+ items.push(buildChunkItem({ ...buildItemParams, slice }));
901
+ }
902
+ }
903
+ if (items.length === 0) {
904
+ return [];
905
+ }
906
+ try {
907
+ const requestIds2 = Array.from({
908
+ length: items.length
909
+ });
910
+ for (let batchStart = 0; batchStart < items.length; batchStart += MAX_BULK_ITEMS_PER_REQUEST) {
911
+ const batch = items.slice(
912
+ batchStart,
913
+ batchStart + MAX_BULK_ITEMS_PER_REQUEST
914
+ );
915
+ const result = await makeHappoAPIRequest(
916
+ {
917
+ path: "/api/snap-requests/bulk",
918
+ method: "POST",
919
+ body: { items: batch }
920
+ },
921
+ config,
922
+ { retryCount: 5 }
923
+ );
924
+ if (result && "results" in result && Array.isArray(result.results) && result.results.length === batch.length) {
925
+ const bulkResults = result.results;
926
+ for (const [i, r] of bulkResults.entries()) {
927
+ requestIds2[batchStart + i] = typeof r.requestId === "number" ? r.requestId : void 0;
928
+ }
929
+ } else {
930
+ throw new Error(
931
+ "Bulk snap-requests endpoint returned an unexpected payload shape; aborting to avoid duplicate snap-requests."
932
+ );
933
+ }
934
+ }
935
+ for (const [i, item] of items.entries()) {
936
+ if (requestIds2[i] === void 0) {
937
+ requestIds2[i] = await sendIndividualSnapRequest(item, config);
938
+ }
939
+ }
940
+ return requestIds2.map((id, index) => {
941
+ if (id === void 0) {
942
+ throw new Error(
943
+ `Failed to obtain snap request ID for item at index ${index}`
944
+ );
945
+ }
946
+ return id;
947
+ });
948
+ } catch (error) {
949
+ if (!(error instanceof ErrorWithStatusCode && (error.statusCode === 404 || error.statusCode === 501))) {
950
+ throw error;
850
951
  }
851
952
  }
953
+ const requestIds = [];
954
+ for (const item of items) {
955
+ requestIds.push(await sendIndividualSnapRequest(item, config));
956
+ }
852
957
  return requestIds;
853
958
  }
854
959
  };
855
960
 
856
961
  // src/network/uploadAssets.ts
962
+ import { createHash as createHash2 } from "node:crypto";
857
963
  import retry from "async-retry";
858
964
 
859
965
  // src/utils/Logger.ts
@@ -868,8 +974,7 @@ async function uploadAssets(buffer, options, config) {
868
974
  const signedUrlRes = await makeHappoAPIRequest(
869
975
  {
870
976
  path: `/api/snap-requests/assets/${hash}/signed-url`,
871
- method: "GET",
872
- json: true
977
+ method: "GET"
873
978
  },
874
979
  config,
875
980
  { retryCount: 3 }
@@ -895,7 +1000,8 @@ async function uploadAssets(buffer, options, config) {
895
1000
  body: buffer,
896
1001
  headers: {
897
1002
  "Content-Type": "application/zip"
898
- }
1003
+ },
1004
+ signal: AbortSignal.timeout(6e4)
899
1005
  });
900
1006
  if (!res.ok) {
901
1007
  const error = new Error(
@@ -907,6 +1013,15 @@ async function uploadAssets(buffer, options, config) {
907
1013
  }
908
1014
  throw error;
909
1015
  }
1016
+ const etag = res.headers.get("etag");
1017
+ const expectedEtag = createHash2("md5").update(buffer).digest("hex");
1018
+ if (!etag || !etag.includes(expectedEtag)) {
1019
+ const error = new Error(
1020
+ `S3 upload verification failed: expected ETag to include ${expectedEtag}, got ${etag ?? "(none)"}. A firewall may be intercepting the upload.`
1021
+ );
1022
+ bail(error);
1023
+ return;
1024
+ }
910
1025
  return res;
911
1026
  },
912
1027
  {
@@ -921,8 +1036,7 @@ async function uploadAssets(buffer, options, config) {
921
1036
  const finalizeRes = await makeHappoAPIRequest(
922
1037
  {
923
1038
  path: `/api/snap-requests/assets/${hash}/signed-url/finalize`,
924
- method: "POST",
925
- json: true
1039
+ method: "POST"
926
1040
  },
927
1041
  config,
928
1042
  { retryCount: 3 }
@@ -1593,8 +1707,7 @@ var Controller = class {
1593
1707
  const uploadUrlResult = await makeHappoAPIRequest(
1594
1708
  {
1595
1709
  path: `/api/images/${hash}/upload-url`,
1596
- method: "GET",
1597
- json: true
1710
+ method: "GET"
1598
1711
  },
1599
1712
  this.happoConfig,
1600
1713
  { retryCount: 2 }
@@ -1620,7 +1733,6 @@ var Controller = class {
1620
1733
  {
1621
1734
  url: uploadUrl,
1622
1735
  method: "POST",
1623
- json: true,
1624
1736
  formData: {
1625
1737
  file: new File([buffer], "image.png", { type: "image/png" })
1626
1738
  }