@decocms/apps-vtex 7.23.0 → 7.25.0
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/package.json +12 -5
- package/src/actions/checkout.ts +475 -338
- package/src/hooks/__tests__/createCart.render.test.ts +114 -0
- package/src/hooks/__tests__/createCart.test.ts +92 -0
- package/src/hooks/cartQuery.ts +204 -0
- package/src/hooks/createCart.ts +427 -0
- package/src/hooks/index.ts +6 -0
- package/src/invoke.ts +159 -95
- package/src/loaders/cart/attachments.ts +53 -0
- package/src/loaders/cart/full.ts +67 -0
- package/src/loaders/cart/gifts.ts +43 -0
- package/src/loaders/cart/orderFormId.ts +31 -0
- package/src/loaders/cart/shipping.test.ts +38 -1
- package/src/loaders/cart/shipping.ts +67 -1
- package/src/loaders/cart/summary.ts +36 -0
- package/src/manifest.gen.ts +48 -38
- package/src/utils/__tests__/cartProjection.test.ts +118 -0
- package/src/utils/__tests__/fetchCache.test.ts +147 -104
- package/src/utils/__tests__/resilience.test.ts +205 -0
- package/src/utils/cartProjection.ts +96 -0
- package/src/utils/constants.ts +98 -0
- package/src/utils/fetchCache.ts +144 -167
- package/src/utils/instrumentedFetch.ts +52 -33
- package/src/utils/resilience.ts +352 -0
package/src/manifest.gen.ts
CHANGED
|
@@ -17,6 +17,11 @@ import * as loaders_address from "./loaders/address";
|
|
|
17
17
|
import * as loaders_autocomplete from "./loaders/autocomplete";
|
|
18
18
|
import * as loaders_brands from "./loaders/brands";
|
|
19
19
|
import * as loaders_cart from "./loaders/cart";
|
|
20
|
+
import * as loaders_cart_attachments from "./loaders/cart/attachments";
|
|
21
|
+
import * as loaders_cart_full from "./loaders/cart/full";
|
|
22
|
+
import * as loaders_cart_gifts from "./loaders/cart/gifts";
|
|
23
|
+
import * as loaders_cart_shipping from "./loaders/cart/shipping";
|
|
24
|
+
import * as loaders_cart_summary from "./loaders/cart/summary";
|
|
20
25
|
import * as loaders_catalog from "./loaders/catalog";
|
|
21
26
|
import * as loaders_collections from "./loaders/collections";
|
|
22
27
|
import * as loaders_legacy from "./loaders/legacy";
|
|
@@ -35,44 +40,49 @@ import * as loaders_wishlistProducts from "./loaders/wishlistProducts";
|
|
|
35
40
|
import * as loaders_workflow from "./loaders/workflow";
|
|
36
41
|
|
|
37
42
|
const manifest = {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
43
|
+
name: "vtex",
|
|
44
|
+
loaders: {
|
|
45
|
+
"vtex/loaders/address": loaders_address,
|
|
46
|
+
"vtex/loaders/autocomplete": loaders_autocomplete,
|
|
47
|
+
"vtex/loaders/brands": loaders_brands,
|
|
48
|
+
"vtex/loaders/cart": loaders_cart,
|
|
49
|
+
"vtex/loaders/cart/summary": loaders_cart_summary,
|
|
50
|
+
"vtex/loaders/cart/full": loaders_cart_full,
|
|
51
|
+
"vtex/loaders/cart/shipping": loaders_cart_shipping,
|
|
52
|
+
"vtex/loaders/cart/gifts": loaders_cart_gifts,
|
|
53
|
+
"vtex/loaders/cart/attachments": loaders_cart_attachments,
|
|
54
|
+
"vtex/loaders/catalog": loaders_catalog,
|
|
55
|
+
"vtex/loaders/collections": loaders_collections,
|
|
56
|
+
"vtex/loaders/legacy": loaders_legacy,
|
|
57
|
+
"vtex/loaders/logistics": loaders_logistics,
|
|
58
|
+
"vtex/loaders/navbar": loaders_navbar,
|
|
59
|
+
"vtex/loaders/orders": loaders_orders,
|
|
60
|
+
"vtex/loaders/pageType": loaders_pageType,
|
|
61
|
+
"vtex/loaders/payment": loaders_payment,
|
|
62
|
+
"vtex/loaders/profile": loaders_profile,
|
|
63
|
+
"vtex/loaders/promotion": loaders_promotion,
|
|
64
|
+
"vtex/loaders/search": loaders_search,
|
|
65
|
+
"vtex/loaders/session": loaders_session,
|
|
66
|
+
"vtex/loaders/user": loaders_user,
|
|
67
|
+
"vtex/loaders/wishlist": loaders_wishlist,
|
|
68
|
+
"vtex/loaders/wishlistProducts": loaders_wishlistProducts,
|
|
69
|
+
"vtex/loaders/workflow": loaders_workflow,
|
|
70
|
+
},
|
|
71
|
+
actions: {
|
|
72
|
+
"vtex/actions/address": actions_address,
|
|
73
|
+
"vtex/actions/analytics/sendEvent": actions_analytics_sendEvent,
|
|
74
|
+
"vtex/actions/auth": actions_auth,
|
|
75
|
+
"vtex/actions/checkout": actions_checkout,
|
|
76
|
+
"vtex/actions/masterData": actions_masterData,
|
|
77
|
+
"vtex/actions/misc": actions_misc,
|
|
78
|
+
"vtex/actions/newsletter": actions_newsletter,
|
|
79
|
+
"vtex/actions/orders": actions_orders,
|
|
80
|
+
"vtex/actions/profile": actions_profile,
|
|
81
|
+
"vtex/actions/session": actions_session,
|
|
82
|
+
"vtex/actions/trigger": actions_trigger,
|
|
83
|
+
"vtex/actions/wishlist": actions_wishlist,
|
|
84
|
+
},
|
|
85
|
+
sections: {},
|
|
76
86
|
} as const;
|
|
77
87
|
|
|
78
88
|
export type Manifest = typeof manifest;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `projectOrderForm` — the server-side fragmentation of a VTEX
|
|
3
|
+
* OrderForm into the requested `CartProjection`. Pure function, no I/O.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { Minicart } from "@decocms/apps-commerce/types";
|
|
7
|
+
import { describe, expect, it } from "vitest";
|
|
8
|
+
import type { OrderForm } from "../../types";
|
|
9
|
+
import { projectOrderForm } from "../cartProjection";
|
|
10
|
+
|
|
11
|
+
function makeOrderForm(overrides: Partial<OrderForm> = {}): OrderForm {
|
|
12
|
+
return {
|
|
13
|
+
orderFormId: "of-123",
|
|
14
|
+
salesChannel: "1",
|
|
15
|
+
loggedIn: false,
|
|
16
|
+
isCheckedIn: false,
|
|
17
|
+
allowManualPrice: false,
|
|
18
|
+
canEditData: true,
|
|
19
|
+
ignoreProfileData: false,
|
|
20
|
+
value: 15000, // R$150.00 in cents
|
|
21
|
+
messages: [],
|
|
22
|
+
items: [
|
|
23
|
+
{
|
|
24
|
+
id: "sku-1",
|
|
25
|
+
productId: "prod-1",
|
|
26
|
+
name: "Camiseta",
|
|
27
|
+
skuName: "Camiseta P Azul",
|
|
28
|
+
imageUrl: "http://img.example/x.jpg",
|
|
29
|
+
detailUrl: "/camiseta/p",
|
|
30
|
+
price: 10000,
|
|
31
|
+
listPrice: 12000,
|
|
32
|
+
sellingPrice: 10000,
|
|
33
|
+
quantity: 1,
|
|
34
|
+
seller: "1",
|
|
35
|
+
uniqueId: "u1",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: "sku-2",
|
|
39
|
+
productId: "prod-2",
|
|
40
|
+
name: "Boné",
|
|
41
|
+
skuName: "Boné U Preto",
|
|
42
|
+
imageUrl: "https://img.example/y.jpg",
|
|
43
|
+
detailUrl: "/bone/u",
|
|
44
|
+
price: 5000,
|
|
45
|
+
listPrice: 5000,
|
|
46
|
+
sellingPrice: 5000,
|
|
47
|
+
quantity: 2,
|
|
48
|
+
seller: "1",
|
|
49
|
+
uniqueId: "u2",
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
totalizers: [
|
|
53
|
+
{ id: "Items", name: "Items", value: 20000 },
|
|
54
|
+
{ id: "Discounts", name: "Discounts", value: -5000 },
|
|
55
|
+
],
|
|
56
|
+
shippingData: null,
|
|
57
|
+
clientProfileData: null,
|
|
58
|
+
paymentData: null,
|
|
59
|
+
marketingData: null,
|
|
60
|
+
...overrides,
|
|
61
|
+
} as OrderForm;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe("projectOrderForm", () => {
|
|
65
|
+
it("none → { ok: true } and discards the payload", () => {
|
|
66
|
+
expect(projectOrderForm(makeOrderForm(), "none")).toEqual({ ok: true });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("summary → orderFormId, summed quantities, total in major units", () => {
|
|
70
|
+
const r = projectOrderForm(makeOrderForm(), "summary") as {
|
|
71
|
+
orderFormId: string;
|
|
72
|
+
totalItems: number;
|
|
73
|
+
total: number;
|
|
74
|
+
};
|
|
75
|
+
expect(r.orderFormId).toBe("of-123");
|
|
76
|
+
expect(r.totalItems).toBe(3); // 1 + 2
|
|
77
|
+
expect(r.total).toBe(150); // 15000 cents → 150
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("summary+items → summary plus slim, https-normalized items", () => {
|
|
81
|
+
const r = projectOrderForm(makeOrderForm(), "summary+items") as {
|
|
82
|
+
totalItems: number;
|
|
83
|
+
items: Array<{ item_name: string; image: string; price: number; quantity: number }>;
|
|
84
|
+
};
|
|
85
|
+
expect(r.totalItems).toBe(3);
|
|
86
|
+
expect(r.items).toHaveLength(2);
|
|
87
|
+
expect(r.items[0]).toMatchObject({
|
|
88
|
+
item_name: "Camiseta",
|
|
89
|
+
item_variant: "Camiseta P Azul",
|
|
90
|
+
image: "https://img.example/x.jpg", // http → https
|
|
91
|
+
price: 100, // 10000 cents
|
|
92
|
+
quantity: 1,
|
|
93
|
+
});
|
|
94
|
+
// slim projection must NOT leak the full VTEX item fields
|
|
95
|
+
expect(r.items[0]).not.toHaveProperty("uniqueId");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("minicart → canonical Minicart with major-unit totals", () => {
|
|
99
|
+
const r = projectOrderForm(makeOrderForm(), "minicart") as Minicart<OrderForm>;
|
|
100
|
+
expect(r.storefront.items).toHaveLength(2);
|
|
101
|
+
expect(r.storefront.total).toBe(150);
|
|
102
|
+
expect(r.original.orderFormId).toBe("of-123");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("raw → the untouched OrderForm", () => {
|
|
106
|
+
const of = makeOrderForm();
|
|
107
|
+
expect(projectOrderForm(of, "raw")).toBe(of);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("summary handles an empty cart without NaN", () => {
|
|
111
|
+
const r = projectOrderForm(
|
|
112
|
+
makeOrderForm({ items: [], value: 0, totalizers: [] }),
|
|
113
|
+
"summary",
|
|
114
|
+
) as { totalItems: number; total: number };
|
|
115
|
+
expect(r.totalItems).toBe(0);
|
|
116
|
+
expect(r.total).toBe(0);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -1,112 +1,155 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { clearFetchCache, fetchWithCache, getFetchCacheStats } from "../fetchCache";
|
|
3
|
+
import { VtexCircuitOpenError } from "../resilience";
|
|
3
4
|
|
|
4
5
|
function mockResponse(body: unknown, status = 200): Response {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
return {
|
|
7
|
+
ok: status >= 200 && status < 300,
|
|
8
|
+
status,
|
|
9
|
+
statusText: status === 200 ? "OK" : "Error",
|
|
10
|
+
json: () => Promise.resolve(body),
|
|
11
|
+
} as Response;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
describe("fetchWithCache", () => {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
clearFetchCache();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("returns fetched data on cache miss", async () => {
|
|
20
|
+
const data = { id: 1, name: "product" };
|
|
21
|
+
const doFetch = vi.fn(() => Promise.resolve(mockResponse(data)));
|
|
22
|
+
|
|
23
|
+
const result = await fetchWithCache("key1", doFetch);
|
|
24
|
+
expect(result).toEqual(data);
|
|
25
|
+
expect(doFetch).toHaveBeenCalledOnce();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("returns cached data on subsequent calls", async () => {
|
|
29
|
+
const data = { id: 1 };
|
|
30
|
+
const doFetch = vi.fn(() => Promise.resolve(mockResponse(data)));
|
|
31
|
+
|
|
32
|
+
await fetchWithCache("key2", doFetch);
|
|
33
|
+
const result = await fetchWithCache("key2", doFetch);
|
|
34
|
+
|
|
35
|
+
expect(result).toEqual(data);
|
|
36
|
+
expect(doFetch).toHaveBeenCalledOnce();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("deduplicates in-flight requests", async () => {
|
|
40
|
+
const data = { id: 1 };
|
|
41
|
+
const doFetch = vi.fn(
|
|
42
|
+
() => new Promise<Response>((resolve) => setTimeout(() => resolve(mockResponse(data)), 10)),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const [r1, r2] = await Promise.all([
|
|
46
|
+
fetchWithCache("key3", doFetch),
|
|
47
|
+
fetchWithCache("key3", doFetch),
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
expect(r1).toEqual(data);
|
|
51
|
+
expect(r2).toEqual(data);
|
|
52
|
+
expect(doFetch).toHaveBeenCalledOnce();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("throws on 5xx responses", async () => {
|
|
56
|
+
const doFetch = vi.fn(() => Promise.resolve(mockResponse(null, 500)));
|
|
57
|
+
|
|
58
|
+
await expect(fetchWithCache("key4", doFetch)).rejects.toThrow("500");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("does not retry a circuit-open error (fast-fail)", async () => {
|
|
62
|
+
const doFetch = vi.fn(() => Promise.reject(new VtexCircuitOpenError("host")));
|
|
63
|
+
|
|
64
|
+
await expect(fetchWithCache("circuit-key", doFetch)).rejects.toBeInstanceOf(
|
|
65
|
+
VtexCircuitOpenError,
|
|
66
|
+
);
|
|
67
|
+
// The breaker already decided to shed load — must not hammer with retries.
|
|
68
|
+
expect(doFetch).toHaveBeenCalledOnce();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("serves last-good stale data when a background refresh fails", async () => {
|
|
72
|
+
vi.useFakeTimers();
|
|
73
|
+
try {
|
|
74
|
+
let call = 0;
|
|
75
|
+
const doFetch = vi.fn(() => {
|
|
76
|
+
call++;
|
|
77
|
+
if (call === 1) return Promise.resolve(mockResponse({ v: "good" }));
|
|
78
|
+
return Promise.reject(new VtexCircuitOpenError("host"));
|
|
79
|
+
});
|
|
80
|
+
// Prime a good entry.
|
|
81
|
+
const first = await fetchWithCache<{ v: string }>("sie-key", doFetch);
|
|
82
|
+
expect(first).toEqual({ v: "good" });
|
|
83
|
+
|
|
84
|
+
// Advance past the 2xx freshness TTL (180s) but within the 24h SIE window.
|
|
85
|
+
await vi.advanceTimersByTimeAsync(200_000);
|
|
86
|
+
|
|
87
|
+
// Stale read: serves last-good immediately, refresh happens in background.
|
|
88
|
+
const second = await fetchWithCache<{ v: string }>("sie-key", doFetch);
|
|
89
|
+
expect(second).toEqual({ v: "good" });
|
|
90
|
+
} finally {
|
|
91
|
+
vi.useRealTimers();
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("does not retry — retries are owned by the resilience layer", async () => {
|
|
96
|
+
// A 5xx used to be retried 3× here, which also re-entered the circuit
|
|
97
|
+
// breaker 3× per request. fetchCache now makes a single attempt.
|
|
98
|
+
const doFetch = vi.fn(() => Promise.resolve(mockResponse(null, 500)));
|
|
99
|
+
await expect(fetchWithCache("single-attempt", doFetch)).rejects.toThrow("500");
|
|
100
|
+
expect(doFetch).toHaveBeenCalledOnce();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("returns null for 404 responses", async () => {
|
|
104
|
+
const doFetch = vi.fn(() => Promise.resolve(mockResponse(null, 404)));
|
|
105
|
+
|
|
106
|
+
const result = await fetchWithCache("key5", doFetch);
|
|
107
|
+
expect(result).toBeNull();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("tracks cache stats", async () => {
|
|
111
|
+
const doFetch = () => Promise.resolve(mockResponse({ ok: true }));
|
|
112
|
+
|
|
113
|
+
expect(getFetchCacheStats()).toEqual({ entries: 0, inflight: 0 });
|
|
114
|
+
|
|
115
|
+
await fetchWithCache("stats1", doFetch);
|
|
116
|
+
await fetchWithCache("stats2", doFetch);
|
|
117
|
+
|
|
118
|
+
expect(getFetchCacheStats()).toEqual({ entries: 2, inflight: 0 });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("clears cache", async () => {
|
|
122
|
+
const doFetch = () => Promise.resolve(mockResponse({ ok: true }));
|
|
123
|
+
await fetchWithCache("clear1", doFetch);
|
|
124
|
+
|
|
125
|
+
clearFetchCache();
|
|
126
|
+
expect(getFetchCacheStats()).toEqual({ entries: 0, inflight: 0 });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("evicts the inflight slot when the fetch never settles", async () => {
|
|
130
|
+
vi.useFakeTimers();
|
|
131
|
+
try {
|
|
132
|
+
// `doFetch` returns a Promise that never resolves — simulates a hung
|
|
133
|
+
// VTEX subrequest (TCP open, no FIN, no response). Without the
|
|
134
|
+
// timeout guard, the inflight Map entry would leak forever and
|
|
135
|
+
// subsequent callers would `await` a zombie Promise — the prod
|
|
136
|
+
// memory-leak this fix addresses.
|
|
137
|
+
const doFetch = vi.fn(() => new Promise<Response>(() => {}));
|
|
138
|
+
|
|
139
|
+
const pending = fetchWithCache("hung-key", doFetch);
|
|
140
|
+
// Swallow the eventual rejection so the unhandled rejection doesn't
|
|
141
|
+
// fail the test runner.
|
|
142
|
+
pending.catch(() => {});
|
|
143
|
+
|
|
144
|
+
expect(getFetchCacheStats().inflight).toBe(1);
|
|
145
|
+
|
|
146
|
+
// Fast-forward past the 15s inflight backstop timeout.
|
|
147
|
+
await vi.advanceTimersByTimeAsync(16_000);
|
|
148
|
+
|
|
149
|
+
await expect(pending).rejects.toThrow(/timed out/);
|
|
150
|
+
expect(getFetchCacheStats().inflight).toBe(0);
|
|
151
|
+
} finally {
|
|
152
|
+
vi.useRealTimers();
|
|
153
|
+
}
|
|
154
|
+
});
|
|
112
155
|
});
|