@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.
- package/.rush/temp/chunked-rush-logs/ts-web-extras.build.chunks.jsonl +26 -26
- package/.rush/temp/chunked-rush-logs/ts-web-extras.test.chunks.jsonl +47 -42
- package/.rush/temp/operation/build/all.log +26 -26
- package/.rush/temp/operation/build/error.log +9 -9
- package/.rush/temp/operation/build/log-chunks.jsonl +26 -26
- package/.rush/temp/operation/build/state.json +1 -1
- package/.rush/temp/operation/test/all.log +47 -42
- package/.rush/temp/operation/test/error.log +10 -8
- package/.rush/temp/operation/test/log-chunks.jsonl +47 -42
- package/.rush/temp/operation/test/state.json +1 -1
- package/dist/packlets/file-tree/httpTreeAccessors.js +68 -11
- package/dist/packlets/file-tree/httpTreeAccessors.js.map +1 -1
- package/dist/test/unit/httpTreeAccessors.test.js +303 -74
- package/dist/test/unit/httpTreeAccessors.test.js.map +1 -1
- package/dist/ts-web-extras.d.ts +14 -0
- package/lib/packlets/file-tree/httpTreeAccessors.d.ts +14 -0
- package/lib/packlets/file-tree/httpTreeAccessors.d.ts.map +1 -1
- package/lib/packlets/file-tree/httpTreeAccessors.js +68 -11
- package/lib/packlets/file-tree/httpTreeAccessors.js.map +1 -1
- package/lib/test/unit/httpTreeAccessors.test.js +303 -74
- package/lib/test/unit/httpTreeAccessors.test.js.map +1 -1
- package/package.json +1 -1
- package/rush-logs/ts-web-extras.build.error.log +9 -9
- package/rush-logs/ts-web-extras.build.log +26 -26
- package/rush-logs/ts-web-extras.test.error.log +10 -8
- package/rush-logs/ts-web-extras.test.log +47 -42
- package/src/packlets/file-tree/httpTreeAccessors.ts +79 -12
- package/src/test/unit/httpTreeAccessors.test.ts +377 -84
- package/temp/build/typescript/ts_8nwakTlr.json +1 -1
- package/temp/coverage/crypto-utils/browserCryptoProvider.ts.html +1 -1
- package/temp/coverage/crypto-utils/browserHashProvider.ts.html +1 -1
- package/temp/coverage/crypto-utils/index.html +1 -1
- package/temp/coverage/file-tree/directoryHandleStore.ts.html +1 -1
- package/temp/coverage/file-tree/fileApiTreeAccessors.ts.html +1 -1
- package/temp/coverage/file-tree/fileSystemAccessTreeAccessors.ts.html +1 -1
- package/temp/coverage/file-tree/httpTreeAccessors.ts.html +369 -168
- package/temp/coverage/file-tree/index.html +11 -11
- package/temp/coverage/file-tree/localStorageTreeAccessors.ts.html +1 -1
- package/temp/coverage/helpers/fileTreeHelpers.ts.html +1 -1
- package/temp/coverage/helpers/index.html +1 -1
- package/temp/coverage/index.html +11 -11
- package/temp/coverage/lcov-report/crypto-utils/browserCryptoProvider.ts.html +1 -1
- package/temp/coverage/lcov-report/crypto-utils/browserHashProvider.ts.html +1 -1
- package/temp/coverage/lcov-report/crypto-utils/index.html +1 -1
- package/temp/coverage/lcov-report/file-tree/directoryHandleStore.ts.html +1 -1
- package/temp/coverage/lcov-report/file-tree/fileApiTreeAccessors.ts.html +1 -1
- package/temp/coverage/lcov-report/file-tree/fileSystemAccessTreeAccessors.ts.html +1 -1
- package/temp/coverage/lcov-report/file-tree/httpTreeAccessors.ts.html +369 -168
- package/temp/coverage/lcov-report/file-tree/index.html +11 -11
- package/temp/coverage/lcov-report/file-tree/localStorageTreeAccessors.ts.html +1 -1
- package/temp/coverage/lcov-report/helpers/fileTreeHelpers.ts.html +1 -1
- package/temp/coverage/lcov-report/helpers/index.html +1 -1
- package/temp/coverage/lcov-report/index.html +11 -11
- package/temp/coverage/lcov-report/url-utils/index.html +1 -1
- package/temp/coverage/lcov-report/url-utils/urlParams.ts.html +1 -1
- package/temp/coverage/lcov.info +479 -379
- package/temp/coverage/url-utils/index.html +1 -1
- package/temp/coverage/url-utils/urlParams.ts.html +1 -1
- package/temp/test/jest/haste-map-b931e4e63102f86c5bd4949f7dced44f-9d713eb41149188b4e5c0ae3d86d0a57-2ad8e16b24e391b8cdbe50b55c137169 +0 -0
- package/temp/test/jest/perf-cache-b931e4e63102f86c5bd4949f7dced44f-da39a3ee5e6b4b0d3255bfef95601890 +1 -1
- 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
|
-
|
|
774
|
-
|
|
775
|
-
callCount
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
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
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
790
|
+
const accessors = (
|
|
791
|
+
await HttpTreeAccessors.fromHttp({
|
|
792
|
+
baseUrl: 'http://localhost:3000',
|
|
793
|
+
fetchImpl,
|
|
794
|
+
mutable: true
|
|
795
|
+
})
|
|
796
|
+
).orThrow();
|
|
792
797
|
|
|
793
|
-
|
|
794
|
-
const result = await accessors.syncToDisk();
|
|
798
|
+
accessors.saveFileContents('/data.json', '"updated"').orThrow();
|
|
795
799
|
|
|
796
|
-
|
|
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
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
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
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
835
|
+
const accessors = (
|
|
836
|
+
await HttpTreeAccessors.fromHttp({
|
|
837
|
+
baseUrl: 'http://localhost:3000',
|
|
838
|
+
fetchImpl,
|
|
839
|
+
mutable: true
|
|
840
|
+
})
|
|
841
|
+
).orThrow();
|
|
814
842
|
|
|
815
|
-
|
|
816
|
-
|
|
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
|
-
|
|
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
|
-
|
|
823
|
-
|
|
824
|
-
callCount
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
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
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
878
|
+
const accessors = (
|
|
879
|
+
await HttpTreeAccessors.fromHttp({
|
|
880
|
+
baseUrl: 'http://localhost:3000',
|
|
881
|
+
fetchImpl,
|
|
882
|
+
mutable: true
|
|
883
|
+
})
|
|
884
|
+
).orThrow();
|
|
846
885
|
|
|
847
|
-
|
|
848
|
-
|
|
886
|
+
accessors.saveFileContents('/data.json', '"v2"').orThrow();
|
|
887
|
+
|
|
888
|
+
const syncPromise = accessors.syncToDisk();
|
|
889
|
+
await jest.advanceTimersByTimeAsync(1500);
|
|
849
890
|
|
|
850
|
-
|
|
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
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
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
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1152
|
+
const accessors = (
|
|
1153
|
+
await HttpTreeAccessors.fromHttp({
|
|
1154
|
+
baseUrl: 'http://localhost:3000',
|
|
1155
|
+
fetchImpl,
|
|
1156
|
+
mutable: true
|
|
1157
|
+
})
|
|
1158
|
+
).orThrow();
|
|
1109
1159
|
|
|
1110
|
-
|
|
1111
|
-
const result = await accessors.syncToDisk();
|
|
1160
|
+
accessors.saveFileContents('/data.json', '"updated"').orThrow();
|
|
1112
1161
|
|
|
1113
|
-
|
|
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
|
});
|