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

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 (61) hide show
  1. package/.rush/temp/chunked-rush-logs/ts-web-extras.build.chunks.jsonl +26 -26
  2. package/.rush/temp/chunked-rush-logs/ts-web-extras.test.chunks.jsonl +47 -42
  3. package/.rush/temp/operation/build/all.log +26 -26
  4. package/.rush/temp/operation/build/error.log +9 -9
  5. package/.rush/temp/operation/build/log-chunks.jsonl +26 -26
  6. package/.rush/temp/operation/build/state.json +1 -1
  7. package/.rush/temp/operation/test/all.log +47 -42
  8. package/.rush/temp/operation/test/error.log +10 -8
  9. package/.rush/temp/operation/test/log-chunks.jsonl +47 -42
  10. package/.rush/temp/operation/test/state.json +1 -1
  11. package/dist/packlets/file-tree/httpTreeAccessors.js +68 -11
  12. package/dist/packlets/file-tree/httpTreeAccessors.js.map +1 -1
  13. package/dist/test/unit/httpTreeAccessors.test.js +303 -74
  14. package/dist/test/unit/httpTreeAccessors.test.js.map +1 -1
  15. package/dist/ts-web-extras.d.ts +14 -0
  16. package/lib/packlets/file-tree/httpTreeAccessors.d.ts +14 -0
  17. package/lib/packlets/file-tree/httpTreeAccessors.d.ts.map +1 -1
  18. package/lib/packlets/file-tree/httpTreeAccessors.js +68 -11
  19. package/lib/packlets/file-tree/httpTreeAccessors.js.map +1 -1
  20. package/lib/test/unit/httpTreeAccessors.test.js +303 -74
  21. package/lib/test/unit/httpTreeAccessors.test.js.map +1 -1
  22. package/package.json +1 -1
  23. package/rush-logs/ts-web-extras.build.error.log +9 -9
  24. package/rush-logs/ts-web-extras.build.log +26 -26
  25. package/rush-logs/ts-web-extras.test.error.log +10 -8
  26. package/rush-logs/ts-web-extras.test.log +47 -42
  27. package/src/packlets/file-tree/httpTreeAccessors.ts +79 -12
  28. package/src/test/unit/httpTreeAccessors.test.ts +377 -84
  29. package/temp/build/typescript/ts_8nwakTlr.json +1 -1
  30. package/temp/coverage/crypto-utils/browserCryptoProvider.ts.html +1 -1
  31. package/temp/coverage/crypto-utils/browserHashProvider.ts.html +1 -1
  32. package/temp/coverage/crypto-utils/index.html +1 -1
  33. package/temp/coverage/file-tree/directoryHandleStore.ts.html +1 -1
  34. package/temp/coverage/file-tree/fileApiTreeAccessors.ts.html +1 -1
  35. package/temp/coverage/file-tree/fileSystemAccessTreeAccessors.ts.html +1 -1
  36. package/temp/coverage/file-tree/httpTreeAccessors.ts.html +369 -168
  37. package/temp/coverage/file-tree/index.html +11 -11
  38. package/temp/coverage/file-tree/localStorageTreeAccessors.ts.html +1 -1
  39. package/temp/coverage/helpers/fileTreeHelpers.ts.html +1 -1
  40. package/temp/coverage/helpers/index.html +1 -1
  41. package/temp/coverage/index.html +11 -11
  42. package/temp/coverage/lcov-report/crypto-utils/browserCryptoProvider.ts.html +1 -1
  43. package/temp/coverage/lcov-report/crypto-utils/browserHashProvider.ts.html +1 -1
  44. package/temp/coverage/lcov-report/crypto-utils/index.html +1 -1
  45. package/temp/coverage/lcov-report/file-tree/directoryHandleStore.ts.html +1 -1
  46. package/temp/coverage/lcov-report/file-tree/fileApiTreeAccessors.ts.html +1 -1
  47. package/temp/coverage/lcov-report/file-tree/fileSystemAccessTreeAccessors.ts.html +1 -1
  48. package/temp/coverage/lcov-report/file-tree/httpTreeAccessors.ts.html +369 -168
  49. package/temp/coverage/lcov-report/file-tree/index.html +11 -11
  50. package/temp/coverage/lcov-report/file-tree/localStorageTreeAccessors.ts.html +1 -1
  51. package/temp/coverage/lcov-report/helpers/fileTreeHelpers.ts.html +1 -1
  52. package/temp/coverage/lcov-report/helpers/index.html +1 -1
  53. package/temp/coverage/lcov-report/index.html +11 -11
  54. package/temp/coverage/lcov-report/url-utils/index.html +1 -1
  55. package/temp/coverage/lcov-report/url-utils/urlParams.ts.html +1 -1
  56. package/temp/coverage/lcov.info +479 -379
  57. package/temp/coverage/url-utils/index.html +1 -1
  58. package/temp/coverage/url-utils/urlParams.ts.html +1 -1
  59. package/temp/test/jest/haste-map-b931e4e63102f86c5bd4949f7dced44f-9d713eb41149188b4e5c0ae3d86d0a57-2ad8e16b24e391b8cdbe50b55c137169 +0 -0
  60. package/temp/test/jest/perf-cache-b931e4e63102f86c5bd4949f7dced44f-da39a3ee5e6b4b0d3255bfef95601890 +1 -1
  61. package/temp/ts-web-extras.api.json +1 -1
@@ -40,6 +40,7 @@ function makeMockResponse(options: IMockResponse): Response {
40
40
  return {
41
41
  ok,
42
42
  status,
43
+ statusText: ok ? 'OK' : `Error ${status}`,
43
44
  json: throwOnJson
44
45
  ? () => Promise.reject(new Error('JSON parse error'))
45
46
  : () => Promise.resolve(jsonValue),
@@ -770,84 +771,128 @@ describe('HttpTreeAccessors', () => {
770
771
  });
771
772
 
772
773
  test('fails when PUT for a dirty file encounters a network error', async () => {
773
- let callCount = 0;
774
- const fetchImpl: typeof fetch = (_url, _init) => {
775
- callCount++;
776
- if (callCount === 1) {
777
- return Promise.resolve(makeMockResponse({ ok: true, jsonValue: rootWithOneFile('data.json') }));
778
- }
779
- if (callCount === 2) {
780
- return Promise.resolve(makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '{}') }));
781
- }
782
- return Promise.reject(new Error('PUT network error'));
783
- };
774
+ jest.useFakeTimers();
775
+ try {
776
+ let callCount = 0;
777
+ const fetchImpl: typeof fetch = (_url, _init) => {
778
+ callCount++;
779
+ if (callCount === 1) {
780
+ return Promise.resolve(makeMockResponse({ ok: true, jsonValue: rootWithOneFile('data.json') }));
781
+ }
782
+ if (callCount === 2) {
783
+ return Promise.resolve(
784
+ makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '{}') })
785
+ );
786
+ }
787
+ return Promise.reject(new Error('PUT network error'));
788
+ };
784
789
 
785
- const accessors = (
786
- await HttpTreeAccessors.fromHttp({
787
- baseUrl: 'http://localhost:3000',
788
- fetchImpl,
789
- mutable: true
790
- })
791
- ).orThrow();
790
+ const accessors = (
791
+ await HttpTreeAccessors.fromHttp({
792
+ baseUrl: 'http://localhost:3000',
793
+ fetchImpl,
794
+ mutable: true
795
+ })
796
+ ).orThrow();
792
797
 
793
- accessors.saveFileContents('/data.json', '"updated"').orThrow();
794
- const result = await accessors.syncToDisk();
798
+ accessors.saveFileContents('/data.json', '"updated"').orThrow();
795
799
 
796
- expect(result).toFailWith(/put network error/i);
800
+ const syncPromise = accessors.syncToDisk();
801
+ await jest.advanceTimersByTimeAsync(1500);
802
+
803
+ const result = await syncPromise;
804
+ expect(result).toFailWith(/put network error/i);
805
+ } finally {
806
+ jest.useRealTimers();
807
+ }
797
808
  });
798
809
 
799
810
  test('fails when POST /sync returns a non-ok response', async () => {
800
- const { fetchImpl } = makeMockFetch([
801
- { ok: true, jsonValue: rootWithOneFile('data.json') },
802
- { ok: true, jsonValue: fileResponse('/data.json', '{}') },
803
- { ok: true, jsonValue: fileResponse('/data.json', '"v2"') },
804
- { ok: false, status: 503, textValue: 'Service Unavailable' }
805
- ]);
811
+ jest.useFakeTimers();
812
+ try {
813
+ let callCount = 0;
814
+ const fetchImpl: typeof fetch = (_url, _init) => {
815
+ callCount++;
816
+ if (callCount === 1) {
817
+ return Promise.resolve(makeMockResponse({ ok: true, jsonValue: rootWithOneFile('data.json') }));
818
+ }
819
+ if (callCount === 2) {
820
+ return Promise.resolve(
821
+ makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '{}') })
822
+ );
823
+ }
824
+ if (callCount === 3) {
825
+ return Promise.resolve(
826
+ makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '"v2"') })
827
+ );
828
+ }
829
+ // All /sync attempts: persistent 503
830
+ return Promise.resolve(
831
+ makeMockResponse({ ok: false, status: 503, textValue: 'Service Unavailable' })
832
+ );
833
+ };
806
834
 
807
- const accessors = (
808
- await HttpTreeAccessors.fromHttp({
809
- baseUrl: 'http://localhost:3000',
810
- fetchImpl,
811
- mutable: true
812
- })
813
- ).orThrow();
835
+ const accessors = (
836
+ await HttpTreeAccessors.fromHttp({
837
+ baseUrl: 'http://localhost:3000',
838
+ fetchImpl,
839
+ mutable: true
840
+ })
841
+ ).orThrow();
814
842
 
815
- accessors.saveFileContents('/data.json', '"v2"').orThrow();
816
- const result = await accessors.syncToDisk();
843
+ accessors.saveFileContents('/data.json', '"v2"').orThrow();
844
+
845
+ const syncPromise = accessors.syncToDisk();
846
+ // Advance past both backoff delays: 500ms + 1000ms
847
+ await jest.advanceTimersByTimeAsync(1500);
817
848
 
818
- expect(result).toFailWith(/service unavailable/i);
849
+ const result = await syncPromise;
850
+ expect(result).toFailWith(/service unavailable/i);
851
+ } finally {
852
+ jest.useRealTimers();
853
+ }
819
854
  });
820
855
 
821
856
  test('fails when POST /sync encounters a network error', async () => {
822
- let callCount = 0;
823
- const fetchImpl: typeof fetch = (_url, _init) => {
824
- callCount++;
825
- if (callCount === 1) {
826
- return Promise.resolve(makeMockResponse({ ok: true, jsonValue: rootWithOneFile('data.json') }));
827
- }
828
- if (callCount === 2) {
829
- return Promise.resolve(makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '{}') }));
830
- }
831
- if (callCount === 3) {
832
- return Promise.resolve(
833
- makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '"v2"') })
834
- );
835
- }
836
- return Promise.reject(new Error('POST network error'));
837
- };
857
+ jest.useFakeTimers();
858
+ try {
859
+ let callCount = 0;
860
+ const fetchImpl: typeof fetch = (_url, _init) => {
861
+ callCount++;
862
+ if (callCount === 1) {
863
+ return Promise.resolve(makeMockResponse({ ok: true, jsonValue: rootWithOneFile('data.json') }));
864
+ }
865
+ if (callCount === 2) {
866
+ return Promise.resolve(
867
+ makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '{}') })
868
+ );
869
+ }
870
+ if (callCount === 3) {
871
+ return Promise.resolve(
872
+ makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '"v2"') })
873
+ );
874
+ }
875
+ return Promise.reject(new Error('POST network error'));
876
+ };
838
877
 
839
- const accessors = (
840
- await HttpTreeAccessors.fromHttp({
841
- baseUrl: 'http://localhost:3000',
842
- fetchImpl,
843
- mutable: true
844
- })
845
- ).orThrow();
878
+ const accessors = (
879
+ await HttpTreeAccessors.fromHttp({
880
+ baseUrl: 'http://localhost:3000',
881
+ fetchImpl,
882
+ mutable: true
883
+ })
884
+ ).orThrow();
846
885
 
847
- accessors.saveFileContents('/data.json', '"v2"').orThrow();
848
- const result = await accessors.syncToDisk();
886
+ accessors.saveFileContents('/data.json', '"v2"').orThrow();
887
+
888
+ const syncPromise = accessors.syncToDisk();
889
+ await jest.advanceTimersByTimeAsync(1500);
849
890
 
850
- expect(result).toFailWith(/post network error/i);
891
+ const result = await syncPromise;
892
+ expect(result).toFailWith(/post network error/i);
893
+ } finally {
894
+ jest.useRealTimers();
895
+ }
851
896
  });
852
897
 
853
898
  test('syncs multiple dirty files in order', async () => {
@@ -1087,30 +1132,41 @@ describe('HttpTreeAccessors', () => {
1087
1132
 
1088
1133
  test('returns failure with HTTP status fallback when response.text() throws during sync', async () => {
1089
1134
  // Covers the text() catch branch in _request(): uses `HTTP ${status}` fallback
1090
- let callCount = 0;
1091
- const fetchImpl: typeof fetch = (_url, _init) => {
1092
- callCount++;
1093
- if (callCount === 1) {
1094
- return Promise.resolve(makeMockResponse({ ok: true, jsonValue: rootWithOneFile('data.json') }));
1095
- }
1096
- if (callCount === 2) {
1097
- return Promise.resolve(makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '{}') }));
1098
- }
1099
- return Promise.resolve(makeMockResponse({ ok: false, status: 502, throwOnText: true }));
1100
- };
1135
+ // 502 is transient so retries will be attempted; use fake timers to avoid real delays
1136
+ jest.useFakeTimers();
1137
+ try {
1138
+ let callCount = 0;
1139
+ const fetchImpl: typeof fetch = (_url, _init) => {
1140
+ callCount++;
1141
+ if (callCount === 1) {
1142
+ return Promise.resolve(makeMockResponse({ ok: true, jsonValue: rootWithOneFile('data.json') }));
1143
+ }
1144
+ if (callCount === 2) {
1145
+ return Promise.resolve(
1146
+ makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '{}') })
1147
+ );
1148
+ }
1149
+ return Promise.resolve(makeMockResponse({ ok: false, status: 502, throwOnText: true }));
1150
+ };
1101
1151
 
1102
- const accessors = (
1103
- await HttpTreeAccessors.fromHttp({
1104
- baseUrl: 'http://localhost:3000',
1105
- fetchImpl,
1106
- mutable: true
1107
- })
1108
- ).orThrow();
1152
+ const accessors = (
1153
+ await HttpTreeAccessors.fromHttp({
1154
+ baseUrl: 'http://localhost:3000',
1155
+ fetchImpl,
1156
+ mutable: true
1157
+ })
1158
+ ).orThrow();
1109
1159
 
1110
- accessors.saveFileContents('/data.json', '"updated"').orThrow();
1111
- const result = await accessors.syncToDisk();
1160
+ accessors.saveFileContents('/data.json', '"updated"').orThrow();
1112
1161
 
1113
- expect(result).toFailWith(/http 502/i);
1162
+ const syncPromise = accessors.syncToDisk();
1163
+ await jest.advanceTimersByTimeAsync(1500);
1164
+
1165
+ const result = await syncPromise;
1166
+ expect(result).toFailWith(/http 502/i);
1167
+ } finally {
1168
+ jest.useRealTimers();
1169
+ }
1114
1170
  });
1115
1171
  });
1116
1172
 
@@ -1275,4 +1331,241 @@ describe('HttpTreeAccessors', () => {
1275
1331
  expect(methodCalls).toContain('POST');
1276
1332
  });
1277
1333
  });
1334
+
1335
+ describe('_requestWithRetry()', () => {
1336
+ test('succeeds on second attempt after a transient 503 error', async () => {
1337
+ jest.useFakeTimers();
1338
+ try {
1339
+ // Responses: init (GET tree/children), then PUT fails with 503, then PUT succeeds, then POST /sync succeeds
1340
+ let callCount = 0;
1341
+ const fetchImpl: typeof fetch = (_url, _init) => {
1342
+ callCount++;
1343
+ if (callCount === 1) {
1344
+ return Promise.resolve(makeMockResponse({ ok: true, jsonValue: rootWithOneFile('data.json') }));
1345
+ }
1346
+ if (callCount === 2) {
1347
+ return Promise.resolve(
1348
+ makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '{}') })
1349
+ );
1350
+ }
1351
+ // First PUT attempt: transient 503
1352
+ if (callCount === 3) {
1353
+ return Promise.resolve(
1354
+ makeMockResponse({ ok: false, status: 503, textValue: '503 Service Unavailable' })
1355
+ );
1356
+ }
1357
+ // Second PUT attempt: success
1358
+ if (callCount === 4) {
1359
+ return Promise.resolve(
1360
+ makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '"updated"') })
1361
+ );
1362
+ }
1363
+ // POST /sync
1364
+ return Promise.resolve(makeMockResponse({ ok: true, jsonValue: { synced: 1 } }));
1365
+ };
1366
+
1367
+ const accessors = (
1368
+ await HttpTreeAccessors.fromHttp({
1369
+ baseUrl: 'http://localhost:3000',
1370
+ fetchImpl,
1371
+ mutable: true
1372
+ })
1373
+ ).orThrow();
1374
+
1375
+ accessors.saveFileContents('/data.json', '"updated"').orThrow();
1376
+
1377
+ const syncPromise = accessors.syncToDisk();
1378
+
1379
+ // Advance past the 500ms backoff for the first retry
1380
+ await jest.advanceTimersByTimeAsync(500);
1381
+
1382
+ const result = await syncPromise;
1383
+ expect(result).toSucceed();
1384
+ // Three PUT-related calls: init (2) + first PUT attempt (1) + retry PUT (1) + POST sync (1)
1385
+ expect(callCount).toBe(5);
1386
+ } finally {
1387
+ jest.useRealTimers();
1388
+ }
1389
+ });
1390
+
1391
+ test('does not retry on a non-transient error (e.g. 404)', async () => {
1392
+ jest.useFakeTimers();
1393
+ try {
1394
+ let callCount = 0;
1395
+ const fetchImpl: typeof fetch = (_url, _init) => {
1396
+ callCount++;
1397
+ if (callCount === 1) {
1398
+ return Promise.resolve(makeMockResponse({ ok: true, jsonValue: rootWithOneFile('data.json') }));
1399
+ }
1400
+ if (callCount === 2) {
1401
+ return Promise.resolve(
1402
+ makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '{}') })
1403
+ );
1404
+ }
1405
+ // PUT attempt: non-transient 404
1406
+ return Promise.resolve(makeMockResponse({ ok: false, status: 404, textValue: 'Not Found' }));
1407
+ };
1408
+
1409
+ const accessors = (
1410
+ await HttpTreeAccessors.fromHttp({
1411
+ baseUrl: 'http://localhost:3000',
1412
+ fetchImpl,
1413
+ mutable: true
1414
+ })
1415
+ ).orThrow();
1416
+
1417
+ accessors.saveFileContents('/data.json', '"updated"').orThrow();
1418
+
1419
+ const result = await accessors.syncToDisk();
1420
+
1421
+ // No timers should need advancing - non-transient errors return immediately
1422
+ expect(result).toFailWith(/not found/i);
1423
+ // Only 3 calls: 2 init + 1 PUT (no retries)
1424
+ expect(callCount).toBe(3);
1425
+ } finally {
1426
+ jest.useRealTimers();
1427
+ }
1428
+ });
1429
+
1430
+ test('returns the last error after exhausting all retries on persistent transient errors', async () => {
1431
+ jest.useFakeTimers();
1432
+ try {
1433
+ let callCount = 0;
1434
+ const fetchImpl: typeof fetch = (_url, _init) => {
1435
+ callCount++;
1436
+ if (callCount === 1) {
1437
+ return Promise.resolve(makeMockResponse({ ok: true, jsonValue: rootWithOneFile('data.json') }));
1438
+ }
1439
+ if (callCount === 2) {
1440
+ return Promise.resolve(
1441
+ makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '{}') })
1442
+ );
1443
+ }
1444
+ // All PUT attempts: transient 502 every time
1445
+ return Promise.resolve(makeMockResponse({ ok: false, status: 502, textValue: '502 Bad Gateway' }));
1446
+ };
1447
+
1448
+ const accessors = (
1449
+ await HttpTreeAccessors.fromHttp({
1450
+ baseUrl: 'http://localhost:3000',
1451
+ fetchImpl,
1452
+ mutable: true
1453
+ })
1454
+ ).orThrow();
1455
+
1456
+ accessors.saveFileContents('/data.json', '"updated"').orThrow();
1457
+
1458
+ const syncPromise = accessors.syncToDisk();
1459
+
1460
+ // Advance past both backoff delays: 500ms (attempt 1) + 1000ms (attempt 2)
1461
+ await jest.advanceTimersByTimeAsync(1500);
1462
+
1463
+ const result = await syncPromise;
1464
+ expect(result).toFailWith(/502 bad gateway/i);
1465
+ // 2 init + 3 PUT attempts (maxAttempts = 3)
1466
+ expect(callCount).toBe(5);
1467
+ } finally {
1468
+ jest.useRealTimers();
1469
+ }
1470
+ });
1471
+
1472
+ test('logs retry message with method name from init when retrying', async () => {
1473
+ jest.useFakeTimers();
1474
+ try {
1475
+ const logger = {
1476
+ detail: jest.fn(),
1477
+ info: jest.fn(),
1478
+ warn: jest.fn(),
1479
+ error: jest.fn()
1480
+ } as unknown as Logging.LogReporter<unknown>;
1481
+
1482
+ let callCount = 0;
1483
+ const fetchImpl: typeof fetch = (_url, _init) => {
1484
+ callCount++;
1485
+ if (callCount === 1) {
1486
+ return Promise.resolve(makeMockResponse({ ok: true, jsonValue: rootWithOneFile('data.json') }));
1487
+ }
1488
+ if (callCount === 2) {
1489
+ return Promise.resolve(
1490
+ makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '{}') })
1491
+ );
1492
+ }
1493
+ // First PUT attempt: transient 503
1494
+ if (callCount === 3) {
1495
+ return Promise.resolve(
1496
+ makeMockResponse({ ok: false, status: 503, textValue: '503 Service Unavailable' })
1497
+ );
1498
+ }
1499
+ // Second PUT attempt: success
1500
+ if (callCount === 4) {
1501
+ return Promise.resolve(
1502
+ makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '"updated"') })
1503
+ );
1504
+ }
1505
+ // POST /sync
1506
+ return Promise.resolve(makeMockResponse({ ok: true, jsonValue: { synced: 1 } }));
1507
+ };
1508
+
1509
+ const accessors = (
1510
+ await HttpTreeAccessors.fromHttp({
1511
+ baseUrl: 'http://localhost:3000',
1512
+ fetchImpl,
1513
+ mutable: true,
1514
+ logger
1515
+ })
1516
+ ).orThrow();
1517
+
1518
+ accessors.saveFileContents('/data.json', '"updated"').orThrow();
1519
+
1520
+ const syncPromise = accessors.syncToDisk();
1521
+ await jest.advanceTimersByTimeAsync(500);
1522
+ await syncPromise;
1523
+
1524
+ // The retry log message should include the method name (PUT) from init
1525
+ expect(logger.detail).toHaveBeenCalledWith(expect.stringContaining('PUT'));
1526
+ } finally {
1527
+ jest.useRealTimers();
1528
+ }
1529
+ });
1530
+ });
1531
+
1532
+ describe('syncToDisk() concurrency guard', () => {
1533
+ test('concurrent syncToDisk calls share the same promise and only make one set of network calls', async () => {
1534
+ const { fetchImpl, calls } = makeMockFetch([
1535
+ { ok: true, jsonValue: rootWithOneFile('data.json') },
1536
+ { ok: true, jsonValue: fileResponse('/data.json', '{}') },
1537
+ // PUT /file for the single sync
1538
+ { ok: true, jsonValue: fileResponse('/data.json', '"updated"') },
1539
+ // POST /sync
1540
+ { ok: true, jsonValue: { synced: 1 } }
1541
+ ]);
1542
+
1543
+ const accessors = (
1544
+ await HttpTreeAccessors.fromHttp({
1545
+ baseUrl: 'http://localhost:3000',
1546
+ fetchImpl,
1547
+ mutable: true
1548
+ })
1549
+ ).orThrow();
1550
+
1551
+ accessors.saveFileContents('/data.json', '"updated"').orThrow();
1552
+
1553
+ // Start two concurrent syncToDisk calls without awaiting the first
1554
+ const promise1 = accessors.syncToDisk();
1555
+ const promise2 = accessors.syncToDisk();
1556
+
1557
+ const [result1, result2] = await Promise.all([promise1, promise2]);
1558
+
1559
+ expect(result1).toSucceed();
1560
+ expect(result2).toSucceed();
1561
+
1562
+ // Both promises should have resolved to the same underlying promise result.
1563
+ // Only one PUT and one POST /sync should have been issued (not doubled).
1564
+ const syncCalls = calls.slice(2);
1565
+ const putCalls = syncCalls.filter((c) => c.init?.method === 'PUT');
1566
+ const postCalls = syncCalls.filter((c) => c.init?.method === 'POST');
1567
+ expect(putCalls).toHaveLength(1);
1568
+ expect(postCalls).toHaveLength(1);
1569
+ });
1570
+ });
1278
1571
  });