@fgv/ts-web-extras 5.1.0-1 → 5.1.0-3

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 (71) hide show
  1. package/.rush/temp/61c1d4a91d98f048b475a5bb3e6541c5d27819de.tar.log +237 -0
  2. package/.rush/temp/chunked-rush-logs/ts-web-extras.build.chunks.jsonl +17 -37
  3. package/.rush/temp/operation/build/all.log +17 -37
  4. package/.rush/temp/operation/build/log-chunks.jsonl +17 -37
  5. package/.rush/temp/operation/build/state.json +1 -1
  6. package/dist/packlets/file-tree/httpTreeAccessors.js +68 -11
  7. package/dist/packlets/file-tree/httpTreeAccessors.js.map +1 -1
  8. package/dist/test/unit/httpTreeAccessors.test.js +303 -74
  9. package/dist/test/unit/httpTreeAccessors.test.js.map +1 -1
  10. package/dist/ts-web-extras.d.ts +14 -0
  11. package/lib/packlets/file-tree/httpTreeAccessors.d.ts +14 -0
  12. package/lib/packlets/file-tree/httpTreeAccessors.d.ts.map +1 -1
  13. package/lib/packlets/file-tree/httpTreeAccessors.js +68 -11
  14. package/lib/packlets/file-tree/httpTreeAccessors.js.map +1 -1
  15. package/lib/test/unit/httpTreeAccessors.test.js +303 -74
  16. package/lib/test/unit/httpTreeAccessors.test.js.map +1 -1
  17. package/package.json +24 -24
  18. package/rush-logs/ts-web-extras.build.cache.log +3 -0
  19. package/rush-logs/ts-web-extras.build.log +17 -37
  20. package/src/packlets/file-tree/httpTreeAccessors.ts +79 -12
  21. package/src/test/unit/httpTreeAccessors.test.ts +377 -84
  22. package/temp/build/typescript/ts_8nwakTlr.json +1 -1
  23. package/temp/coverage/crypto-utils/browserCryptoProvider.ts.html +1 -1
  24. package/temp/coverage/crypto-utils/browserHashProvider.ts.html +1 -1
  25. package/temp/coverage/crypto-utils/index.html +1 -1
  26. package/temp/coverage/file-tree/directoryHandleStore.ts.html +1 -1
  27. package/temp/coverage/file-tree/fileApiTreeAccessors.ts.html +1 -1
  28. package/temp/coverage/file-tree/fileSystemAccessTreeAccessors.ts.html +1 -1
  29. package/temp/coverage/file-tree/httpTreeAccessors.ts.html +369 -168
  30. package/temp/coverage/file-tree/index.html +11 -11
  31. package/temp/coverage/file-tree/localStorageTreeAccessors.ts.html +1 -1
  32. package/temp/coverage/helpers/fileTreeHelpers.ts.html +1 -1
  33. package/temp/coverage/helpers/index.html +1 -1
  34. package/temp/coverage/index.html +11 -11
  35. package/temp/coverage/lcov-report/crypto-utils/browserCryptoProvider.ts.html +1 -1
  36. package/temp/coverage/lcov-report/crypto-utils/browserHashProvider.ts.html +1 -1
  37. package/temp/coverage/lcov-report/crypto-utils/index.html +1 -1
  38. package/temp/coverage/lcov-report/file-tree/directoryHandleStore.ts.html +1 -1
  39. package/temp/coverage/lcov-report/file-tree/fileApiTreeAccessors.ts.html +1 -1
  40. package/temp/coverage/lcov-report/file-tree/fileSystemAccessTreeAccessors.ts.html +1 -1
  41. package/temp/coverage/lcov-report/file-tree/httpTreeAccessors.ts.html +369 -168
  42. package/temp/coverage/lcov-report/file-tree/index.html +11 -11
  43. package/temp/coverage/lcov-report/file-tree/localStorageTreeAccessors.ts.html +1 -1
  44. package/temp/coverage/lcov-report/helpers/fileTreeHelpers.ts.html +1 -1
  45. package/temp/coverage/lcov-report/helpers/index.html +1 -1
  46. package/temp/coverage/lcov-report/index.html +11 -11
  47. package/temp/coverage/lcov-report/url-utils/index.html +1 -1
  48. package/temp/coverage/lcov-report/url-utils/urlParams.ts.html +1 -1
  49. package/temp/coverage/lcov.info +479 -379
  50. package/temp/coverage/url-utils/index.html +1 -1
  51. package/temp/coverage/url-utils/urlParams.ts.html +1 -1
  52. package/temp/test/jest/haste-map-7492f1b44480e0cdd1f220078fb3afd8-c8dd6c3430605adeb2f1cadf4f75e791-8c9336785555d572065b28c111982ba4 +0 -0
  53. package/temp/test/jest/perf-cache-7492f1b44480e0cdd1f220078fb3afd8-da39a3ee5e6b4b0d3255bfef95601890 +1 -0
  54. package/temp/ts-web-extras.api.json +1 -1
  55. package/.rush/temp/chunked-rush-logs/ts-web-extras.test.chunks.jsonl +0 -70
  56. package/.rush/temp/operation/build/error.log +0 -18
  57. package/.rush/temp/operation/test/all.log +0 -70
  58. package/.rush/temp/operation/test/error.log +0 -16
  59. package/.rush/temp/operation/test/log-chunks.jsonl +0 -70
  60. package/.rush/temp/operation/test/state.json +0 -3
  61. package/rush-logs/ts-web-extras.build.error.log +0 -18
  62. package/rush-logs/ts-web-extras.test.cache.log +0 -1
  63. package/rush-logs/ts-web-extras.test.error.log +0 -16
  64. package/rush-logs/ts-web-extras.test.log +0 -70
  65. package/temp/coverage/crypto/browserHashProvider.ts.html +0 -304
  66. package/temp/coverage/crypto/index.html +0 -116
  67. package/temp/coverage/lcov-report/crypto/browserHashProvider.ts.html +0 -304
  68. package/temp/coverage/lcov-report/crypto/index.html +0 -116
  69. package/temp/test/jest/haste-map-b931e4e63102f86c5bd4949f7dced44f-9d713eb41149188b4e5c0ae3d86d0a57-2ad8e16b24e391b8cdbe50b55c137169 +0 -0
  70. package/temp/test/jest/perf-cache-b931e4e63102f86c5bd4949f7dced44f-da39a3ee5e6b4b0d3255bfef95601890 +0 -1
  71. /package/temp/test/jest/{jest-transform-cache-b931e4e63102f86c5bd4949f7dced44f-79ef2876fae7ca75eedb2aa53dc48338/b5/package_b5f57afc9ec2c011239b1608ee5bdfa5 → jest-transform-cache-7492f1b44480e0cdd1f220078fb3afd8-79ef2876fae7ca75eedb2aa53dc48338/d6/package_d6e3b0fa94752e16b0b2a2777739b973} +0 -0
@@ -27,6 +27,7 @@ function makeMockResponse(options) {
27
27
  return {
28
28
  ok,
29
29
  status,
30
+ statusText: ok ? 'OK' : `Error ${status}`,
30
31
  json: throwOnJson
31
32
  ? () => Promise.reject(new Error('JSON parse error'))
32
33
  : () => Promise.resolve(jsonValue),
@@ -595,65 +596,99 @@ describe('HttpTreeAccessors', () => {
595
596
  expect(result).toFailWith(/sync.*data\.json.*server error/i);
596
597
  });
597
598
  test('fails when PUT for a dirty file encounters a network error', async () => {
598
- let callCount = 0;
599
- const fetchImpl = (_url, _init) => {
600
- callCount++;
601
- if (callCount === 1) {
602
- return Promise.resolve(makeMockResponse({ ok: true, jsonValue: rootWithOneFile('data.json') }));
603
- }
604
- if (callCount === 2) {
605
- return Promise.resolve(makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '{}') }));
606
- }
607
- return Promise.reject(new Error('PUT network error'));
608
- };
609
- const accessors = (await HttpTreeAccessors.fromHttp({
610
- baseUrl: 'http://localhost:3000',
611
- fetchImpl,
612
- mutable: true
613
- })).orThrow();
614
- accessors.saveFileContents('/data.json', '"updated"').orThrow();
615
- const result = await accessors.syncToDisk();
616
- expect(result).toFailWith(/put network error/i);
599
+ jest.useFakeTimers();
600
+ try {
601
+ let callCount = 0;
602
+ const fetchImpl = (_url, _init) => {
603
+ callCount++;
604
+ if (callCount === 1) {
605
+ return Promise.resolve(makeMockResponse({ ok: true, jsonValue: rootWithOneFile('data.json') }));
606
+ }
607
+ if (callCount === 2) {
608
+ return Promise.resolve(makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '{}') }));
609
+ }
610
+ return Promise.reject(new Error('PUT network error'));
611
+ };
612
+ const accessors = (await HttpTreeAccessors.fromHttp({
613
+ baseUrl: 'http://localhost:3000',
614
+ fetchImpl,
615
+ mutable: true
616
+ })).orThrow();
617
+ accessors.saveFileContents('/data.json', '"updated"').orThrow();
618
+ const syncPromise = accessors.syncToDisk();
619
+ await jest.advanceTimersByTimeAsync(1500);
620
+ const result = await syncPromise;
621
+ expect(result).toFailWith(/put network error/i);
622
+ }
623
+ finally {
624
+ jest.useRealTimers();
625
+ }
617
626
  });
618
627
  test('fails when POST /sync returns a non-ok response', async () => {
619
- const { fetchImpl } = makeMockFetch([
620
- { ok: true, jsonValue: rootWithOneFile('data.json') },
621
- { ok: true, jsonValue: fileResponse('/data.json', '{}') },
622
- { ok: true, jsonValue: fileResponse('/data.json', '"v2"') },
623
- { ok: false, status: 503, textValue: 'Service Unavailable' }
624
- ]);
625
- const accessors = (await HttpTreeAccessors.fromHttp({
626
- baseUrl: 'http://localhost:3000',
627
- fetchImpl,
628
- mutable: true
629
- })).orThrow();
630
- accessors.saveFileContents('/data.json', '"v2"').orThrow();
631
- const result = await accessors.syncToDisk();
632
- expect(result).toFailWith(/service unavailable/i);
628
+ jest.useFakeTimers();
629
+ try {
630
+ let callCount = 0;
631
+ const fetchImpl = (_url, _init) => {
632
+ callCount++;
633
+ if (callCount === 1) {
634
+ return Promise.resolve(makeMockResponse({ ok: true, jsonValue: rootWithOneFile('data.json') }));
635
+ }
636
+ if (callCount === 2) {
637
+ return Promise.resolve(makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '{}') }));
638
+ }
639
+ if (callCount === 3) {
640
+ return Promise.resolve(makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '"v2"') }));
641
+ }
642
+ // All /sync attempts: persistent 503
643
+ return Promise.resolve(makeMockResponse({ ok: false, status: 503, textValue: 'Service Unavailable' }));
644
+ };
645
+ const accessors = (await HttpTreeAccessors.fromHttp({
646
+ baseUrl: 'http://localhost:3000',
647
+ fetchImpl,
648
+ mutable: true
649
+ })).orThrow();
650
+ accessors.saveFileContents('/data.json', '"v2"').orThrow();
651
+ const syncPromise = accessors.syncToDisk();
652
+ // Advance past both backoff delays: 500ms + 1000ms
653
+ await jest.advanceTimersByTimeAsync(1500);
654
+ const result = await syncPromise;
655
+ expect(result).toFailWith(/service unavailable/i);
656
+ }
657
+ finally {
658
+ jest.useRealTimers();
659
+ }
633
660
  });
634
661
  test('fails when POST /sync encounters a network error', async () => {
635
- let callCount = 0;
636
- const fetchImpl = (_url, _init) => {
637
- callCount++;
638
- if (callCount === 1) {
639
- return Promise.resolve(makeMockResponse({ ok: true, jsonValue: rootWithOneFile('data.json') }));
640
- }
641
- if (callCount === 2) {
642
- return Promise.resolve(makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '{}') }));
643
- }
644
- if (callCount === 3) {
645
- return Promise.resolve(makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '"v2"') }));
646
- }
647
- return Promise.reject(new Error('POST network error'));
648
- };
649
- const accessors = (await HttpTreeAccessors.fromHttp({
650
- baseUrl: 'http://localhost:3000',
651
- fetchImpl,
652
- mutable: true
653
- })).orThrow();
654
- accessors.saveFileContents('/data.json', '"v2"').orThrow();
655
- const result = await accessors.syncToDisk();
656
- expect(result).toFailWith(/post network error/i);
662
+ jest.useFakeTimers();
663
+ try {
664
+ let callCount = 0;
665
+ const fetchImpl = (_url, _init) => {
666
+ callCount++;
667
+ if (callCount === 1) {
668
+ return Promise.resolve(makeMockResponse({ ok: true, jsonValue: rootWithOneFile('data.json') }));
669
+ }
670
+ if (callCount === 2) {
671
+ return Promise.resolve(makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '{}') }));
672
+ }
673
+ if (callCount === 3) {
674
+ return Promise.resolve(makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '"v2"') }));
675
+ }
676
+ return Promise.reject(new Error('POST network error'));
677
+ };
678
+ const accessors = (await HttpTreeAccessors.fromHttp({
679
+ baseUrl: 'http://localhost:3000',
680
+ fetchImpl,
681
+ mutable: true
682
+ })).orThrow();
683
+ accessors.saveFileContents('/data.json', '"v2"').orThrow();
684
+ const syncPromise = accessors.syncToDisk();
685
+ await jest.advanceTimersByTimeAsync(1500);
686
+ const result = await syncPromise;
687
+ expect(result).toFailWith(/post network error/i);
688
+ }
689
+ finally {
690
+ jest.useRealTimers();
691
+ }
657
692
  });
658
693
  test('syncs multiple dirty files in order', async () => {
659
694
  const { fetchImpl, calls } = makeMockFetch([
@@ -846,25 +881,34 @@ describe('HttpTreeAccessors', () => {
846
881
  });
847
882
  test('returns failure with HTTP status fallback when response.text() throws during sync', async () => {
848
883
  // Covers the text() catch branch in _request(): uses `HTTP ${status}` fallback
849
- let callCount = 0;
850
- const fetchImpl = (_url, _init) => {
851
- callCount++;
852
- if (callCount === 1) {
853
- return Promise.resolve(makeMockResponse({ ok: true, jsonValue: rootWithOneFile('data.json') }));
854
- }
855
- if (callCount === 2) {
856
- return Promise.resolve(makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '{}') }));
857
- }
858
- return Promise.resolve(makeMockResponse({ ok: false, status: 502, throwOnText: true }));
859
- };
860
- const accessors = (await HttpTreeAccessors.fromHttp({
861
- baseUrl: 'http://localhost:3000',
862
- fetchImpl,
863
- mutable: true
864
- })).orThrow();
865
- accessors.saveFileContents('/data.json', '"updated"').orThrow();
866
- const result = await accessors.syncToDisk();
867
- expect(result).toFailWith(/http 502/i);
884
+ // 502 is transient so retries will be attempted; use fake timers to avoid real delays
885
+ jest.useFakeTimers();
886
+ try {
887
+ let callCount = 0;
888
+ const fetchImpl = (_url, _init) => {
889
+ callCount++;
890
+ if (callCount === 1) {
891
+ return Promise.resolve(makeMockResponse({ ok: true, jsonValue: rootWithOneFile('data.json') }));
892
+ }
893
+ if (callCount === 2) {
894
+ return Promise.resolve(makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '{}') }));
895
+ }
896
+ return Promise.resolve(makeMockResponse({ ok: false, status: 502, throwOnText: true }));
897
+ };
898
+ const accessors = (await HttpTreeAccessors.fromHttp({
899
+ baseUrl: 'http://localhost:3000',
900
+ fetchImpl,
901
+ mutable: true
902
+ })).orThrow();
903
+ accessors.saveFileContents('/data.json', '"updated"').orThrow();
904
+ const syncPromise = accessors.syncToDisk();
905
+ await jest.advanceTimersByTimeAsync(1500);
906
+ const result = await syncPromise;
907
+ expect(result).toFailWith(/http 502/i);
908
+ }
909
+ finally {
910
+ jest.useRealTimers();
911
+ }
868
912
  });
869
913
  });
870
914
  describe('_requestWithParams() error handling', () => {
@@ -996,5 +1040,190 @@ describe('HttpTreeAccessors', () => {
996
1040
  expect(methodCalls).toContain('POST');
997
1041
  });
998
1042
  });
1043
+ describe('_requestWithRetry()', () => {
1044
+ test('succeeds on second attempt after a transient 503 error', async () => {
1045
+ jest.useFakeTimers();
1046
+ try {
1047
+ // Responses: init (GET tree/children), then PUT fails with 503, then PUT succeeds, then POST /sync succeeds
1048
+ let callCount = 0;
1049
+ const fetchImpl = (_url, _init) => {
1050
+ callCount++;
1051
+ if (callCount === 1) {
1052
+ return Promise.resolve(makeMockResponse({ ok: true, jsonValue: rootWithOneFile('data.json') }));
1053
+ }
1054
+ if (callCount === 2) {
1055
+ return Promise.resolve(makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '{}') }));
1056
+ }
1057
+ // First PUT attempt: transient 503
1058
+ if (callCount === 3) {
1059
+ return Promise.resolve(makeMockResponse({ ok: false, status: 503, textValue: '503 Service Unavailable' }));
1060
+ }
1061
+ // Second PUT attempt: success
1062
+ if (callCount === 4) {
1063
+ return Promise.resolve(makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '"updated"') }));
1064
+ }
1065
+ // POST /sync
1066
+ return Promise.resolve(makeMockResponse({ ok: true, jsonValue: { synced: 1 } }));
1067
+ };
1068
+ const accessors = (await HttpTreeAccessors.fromHttp({
1069
+ baseUrl: 'http://localhost:3000',
1070
+ fetchImpl,
1071
+ mutable: true
1072
+ })).orThrow();
1073
+ accessors.saveFileContents('/data.json', '"updated"').orThrow();
1074
+ const syncPromise = accessors.syncToDisk();
1075
+ // Advance past the 500ms backoff for the first retry
1076
+ await jest.advanceTimersByTimeAsync(500);
1077
+ const result = await syncPromise;
1078
+ expect(result).toSucceed();
1079
+ // Three PUT-related calls: init (2) + first PUT attempt (1) + retry PUT (1) + POST sync (1)
1080
+ expect(callCount).toBe(5);
1081
+ }
1082
+ finally {
1083
+ jest.useRealTimers();
1084
+ }
1085
+ });
1086
+ test('does not retry on a non-transient error (e.g. 404)', async () => {
1087
+ jest.useFakeTimers();
1088
+ try {
1089
+ let callCount = 0;
1090
+ const fetchImpl = (_url, _init) => {
1091
+ callCount++;
1092
+ if (callCount === 1) {
1093
+ return Promise.resolve(makeMockResponse({ ok: true, jsonValue: rootWithOneFile('data.json') }));
1094
+ }
1095
+ if (callCount === 2) {
1096
+ return Promise.resolve(makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '{}') }));
1097
+ }
1098
+ // PUT attempt: non-transient 404
1099
+ return Promise.resolve(makeMockResponse({ ok: false, status: 404, textValue: 'Not Found' }));
1100
+ };
1101
+ const accessors = (await HttpTreeAccessors.fromHttp({
1102
+ baseUrl: 'http://localhost:3000',
1103
+ fetchImpl,
1104
+ mutable: true
1105
+ })).orThrow();
1106
+ accessors.saveFileContents('/data.json', '"updated"').orThrow();
1107
+ const result = await accessors.syncToDisk();
1108
+ // No timers should need advancing - non-transient errors return immediately
1109
+ expect(result).toFailWith(/not found/i);
1110
+ // Only 3 calls: 2 init + 1 PUT (no retries)
1111
+ expect(callCount).toBe(3);
1112
+ }
1113
+ finally {
1114
+ jest.useRealTimers();
1115
+ }
1116
+ });
1117
+ test('returns the last error after exhausting all retries on persistent transient errors', async () => {
1118
+ jest.useFakeTimers();
1119
+ try {
1120
+ let callCount = 0;
1121
+ const fetchImpl = (_url, _init) => {
1122
+ callCount++;
1123
+ if (callCount === 1) {
1124
+ return Promise.resolve(makeMockResponse({ ok: true, jsonValue: rootWithOneFile('data.json') }));
1125
+ }
1126
+ if (callCount === 2) {
1127
+ return Promise.resolve(makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '{}') }));
1128
+ }
1129
+ // All PUT attempts: transient 502 every time
1130
+ return Promise.resolve(makeMockResponse({ ok: false, status: 502, textValue: '502 Bad Gateway' }));
1131
+ };
1132
+ const accessors = (await HttpTreeAccessors.fromHttp({
1133
+ baseUrl: 'http://localhost:3000',
1134
+ fetchImpl,
1135
+ mutable: true
1136
+ })).orThrow();
1137
+ accessors.saveFileContents('/data.json', '"updated"').orThrow();
1138
+ const syncPromise = accessors.syncToDisk();
1139
+ // Advance past both backoff delays: 500ms (attempt 1) + 1000ms (attempt 2)
1140
+ await jest.advanceTimersByTimeAsync(1500);
1141
+ const result = await syncPromise;
1142
+ expect(result).toFailWith(/502 bad gateway/i);
1143
+ // 2 init + 3 PUT attempts (maxAttempts = 3)
1144
+ expect(callCount).toBe(5);
1145
+ }
1146
+ finally {
1147
+ jest.useRealTimers();
1148
+ }
1149
+ });
1150
+ test('logs retry message with method name from init when retrying', async () => {
1151
+ jest.useFakeTimers();
1152
+ try {
1153
+ const logger = {
1154
+ detail: jest.fn(),
1155
+ info: jest.fn(),
1156
+ warn: jest.fn(),
1157
+ error: jest.fn()
1158
+ };
1159
+ let callCount = 0;
1160
+ const fetchImpl = (_url, _init) => {
1161
+ callCount++;
1162
+ if (callCount === 1) {
1163
+ return Promise.resolve(makeMockResponse({ ok: true, jsonValue: rootWithOneFile('data.json') }));
1164
+ }
1165
+ if (callCount === 2) {
1166
+ return Promise.resolve(makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '{}') }));
1167
+ }
1168
+ // First PUT attempt: transient 503
1169
+ if (callCount === 3) {
1170
+ return Promise.resolve(makeMockResponse({ ok: false, status: 503, textValue: '503 Service Unavailable' }));
1171
+ }
1172
+ // Second PUT attempt: success
1173
+ if (callCount === 4) {
1174
+ return Promise.resolve(makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '"updated"') }));
1175
+ }
1176
+ // POST /sync
1177
+ return Promise.resolve(makeMockResponse({ ok: true, jsonValue: { synced: 1 } }));
1178
+ };
1179
+ const accessors = (await HttpTreeAccessors.fromHttp({
1180
+ baseUrl: 'http://localhost:3000',
1181
+ fetchImpl,
1182
+ mutable: true,
1183
+ logger
1184
+ })).orThrow();
1185
+ accessors.saveFileContents('/data.json', '"updated"').orThrow();
1186
+ const syncPromise = accessors.syncToDisk();
1187
+ await jest.advanceTimersByTimeAsync(500);
1188
+ await syncPromise;
1189
+ // The retry log message should include the method name (PUT) from init
1190
+ expect(logger.detail).toHaveBeenCalledWith(expect.stringContaining('PUT'));
1191
+ }
1192
+ finally {
1193
+ jest.useRealTimers();
1194
+ }
1195
+ });
1196
+ });
1197
+ describe('syncToDisk() concurrency guard', () => {
1198
+ test('concurrent syncToDisk calls share the same promise and only make one set of network calls', async () => {
1199
+ const { fetchImpl, calls } = makeMockFetch([
1200
+ { ok: true, jsonValue: rootWithOneFile('data.json') },
1201
+ { ok: true, jsonValue: fileResponse('/data.json', '{}') },
1202
+ // PUT /file for the single sync
1203
+ { ok: true, jsonValue: fileResponse('/data.json', '"updated"') },
1204
+ // POST /sync
1205
+ { ok: true, jsonValue: { synced: 1 } }
1206
+ ]);
1207
+ const accessors = (await HttpTreeAccessors.fromHttp({
1208
+ baseUrl: 'http://localhost:3000',
1209
+ fetchImpl,
1210
+ mutable: true
1211
+ })).orThrow();
1212
+ accessors.saveFileContents('/data.json', '"updated"').orThrow();
1213
+ // Start two concurrent syncToDisk calls without awaiting the first
1214
+ const promise1 = accessors.syncToDisk();
1215
+ const promise2 = accessors.syncToDisk();
1216
+ const [result1, result2] = await Promise.all([promise1, promise2]);
1217
+ expect(result1).toSucceed();
1218
+ expect(result2).toSucceed();
1219
+ // Both promises should have resolved to the same underlying promise result.
1220
+ // Only one PUT and one POST /sync should have been issued (not doubled).
1221
+ const syncCalls = calls.slice(2);
1222
+ const putCalls = syncCalls.filter((c) => { var _a; return ((_a = c.init) === null || _a === void 0 ? void 0 : _a.method) === 'PUT'; });
1223
+ const postCalls = syncCalls.filter((c) => { var _a; return ((_a = c.init) === null || _a === void 0 ? void 0 : _a.method) === 'POST'; });
1224
+ expect(putCalls).toHaveLength(1);
1225
+ expect(postCalls).toHaveLength(1);
1226
+ });
1227
+ });
999
1228
  });
1000
1229
  //# sourceMappingURL=httpTreeAccessors.test.js.map