@cartanova/qgrid-cli 2.4.4 → 2.4.5
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/bundle/dist/application/qgrid/qgrid.frame.js +5 -3
- package/bundle/dist/application/qgrid/qgrid.types.js +3 -2
- package/bundle/dist/utils/providers/openai/codex-worker.js +4 -2
- package/bundle/dist/utils/providers/openai/openai-dispatcher.js +173 -15
- package/bundle/src/application/qgrid/qgrid.frame.test.ts +24 -0
- package/bundle/src/application/qgrid/qgrid.frame.ts +2 -0
- package/bundle/src/application/qgrid/qgrid.types.ts +1 -0
- package/bundle/src/utils/providers/openai/codex-worker.test.ts +26 -0
- package/bundle/src/utils/providers/openai/codex-worker.ts +3 -1
- package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +196 -7
- package/bundle/src/utils/providers/openai/openai-dispatcher.ts +286 -17
- package/bundle/web-dist/client/assets/{index-D1ExP77_.js → index-D0dRwMp0.js} +3 -3
- package/bundle/web-dist/client/assets/{logs-vuI-Zgnq.js → logs-BMzzMMRA.js} +1 -1
- package/bundle/web-dist/client/assets/{routes-0Fki0JEi.js → routes-XqQHRGxI.js} +2 -2
- package/bundle/web-dist/client/assets/{show-BreAgaFH.js → show-63BsotX6.js} +1 -1
- package/bundle/web-dist/client/assets/{tokens-4VgHQtXd.js → tokens-C416mWQ-.js} +1 -1
- package/bundle/web-dist/client/index.html +1 -1
- package/bundle/web-dist/server/assets/{routes-DhIpPgXz.js → routes-VY2um2KO.js} +10 -2
- package/bundle/web-dist/server/entry-server.generated.js +2 -2
- package/package.json +1 -1
|
@@ -305,4 +305,28 @@ describe("QgridFrame.usage", () => {
|
|
|
305
305
|
});
|
|
306
306
|
expect(getRateLimitsByTokenIdMock).not.toHaveBeenCalled();
|
|
307
307
|
});
|
|
308
|
+
|
|
309
|
+
it("returns the provider-reported OpenAI window duration", async () => {
|
|
310
|
+
findManyMock.mockResolvedValueOnce({
|
|
311
|
+
rows: [{ ...tokenEntry, provider: "openai", active: true }],
|
|
312
|
+
});
|
|
313
|
+
getRateLimitsByTokenIdMock.mockResolvedValueOnce({
|
|
314
|
+
data: {
|
|
315
|
+
rateLimits: {
|
|
316
|
+
primary: { usedPercent: 11, resetsAt: 1_784_000_000, windowDurationMins: 10_080 },
|
|
317
|
+
secondary: null,
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
await expect(QgridFrame.usage(1)).resolves.toEqual({
|
|
323
|
+
provider: "openai",
|
|
324
|
+
fiveHour: {
|
|
325
|
+
utilization: 11,
|
|
326
|
+
resetsAt: new Date(1_784_000_000 * 1_000).toISOString(),
|
|
327
|
+
windowDurationMins: 10_080,
|
|
328
|
+
},
|
|
329
|
+
sevenDay: null,
|
|
330
|
+
});
|
|
331
|
+
});
|
|
308
332
|
});
|
|
@@ -583,12 +583,14 @@ class QgridFrameClass extends BaseFrameClass {
|
|
|
583
583
|
? {
|
|
584
584
|
utilization: rl.primary.usedPercent,
|
|
585
585
|
resetsAt: unixSecondsToIso(rl.primary.resetsAt),
|
|
586
|
+
windowDurationMins: rl.primary.windowDurationMins,
|
|
586
587
|
}
|
|
587
588
|
: null,
|
|
588
589
|
sevenDay: rl?.secondary
|
|
589
590
|
? {
|
|
590
591
|
utilization: rl.secondary.usedPercent,
|
|
591
592
|
resetsAt: unixSecondsToIso(rl.secondary.resetsAt),
|
|
593
|
+
windowDurationMins: rl.secondary.windowDurationMins,
|
|
592
594
|
}
|
|
593
595
|
: null,
|
|
594
596
|
};
|
|
@@ -92,6 +92,32 @@ function createWorkerWithNotificationRpc(
|
|
|
92
92
|
return { worker, request, handlers };
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
describe("CodexAppServerWorker home isolation", () => {
|
|
96
|
+
it("places worker homes under the configured OS temp directory", () => {
|
|
97
|
+
const previousTmpdir = process.env.TMPDIR;
|
|
98
|
+
const customTmpdir = mkdtempSync(join(tmpdir(), "qgrid-tmp-root-test-"));
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
process.env.TMPDIR = customTmpdir;
|
|
102
|
+
const worker = new CodexAppServerWorker({
|
|
103
|
+
tokenId: 41,
|
|
104
|
+
tokenName: "token",
|
|
105
|
+
accessToken: "access",
|
|
106
|
+
accountId: "account",
|
|
107
|
+
workerIndex: 14,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
expect((worker as unknown as { codexHome: string }).codexHome).toBe(
|
|
111
|
+
join(customTmpdir, "qgrid-codex", "41-14"),
|
|
112
|
+
);
|
|
113
|
+
} finally {
|
|
114
|
+
if (previousTmpdir === undefined) delete process.env.TMPDIR;
|
|
115
|
+
else process.env.TMPDIR = previousTmpdir;
|
|
116
|
+
rmSync(customTmpdir, { recursive: true, force: true });
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
95
121
|
describe("CodexAppServerWorker active turn cleanup", () => {
|
|
96
122
|
it("rejects a non-streaming turn when the worker process exits", async () => {
|
|
97
123
|
const { worker } = createWorkerWithFakeRpc();
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
2
|
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
3
5
|
|
|
4
6
|
import { getLogger } from "@logtape/logtape";
|
|
5
7
|
|
|
@@ -169,7 +171,7 @@ export class CodexAppServerWorker {
|
|
|
169
171
|
|
|
170
172
|
constructor(private config: WorkerConfig) {
|
|
171
173
|
const suffix = config.workerIndex !== undefined ? `-${config.workerIndex}` : "";
|
|
172
|
-
this.codexHome =
|
|
174
|
+
this.codexHome = join(tmpdir(), "qgrid-codex", `${config.tokenId}${suffix}`);
|
|
173
175
|
}
|
|
174
176
|
|
|
175
177
|
// ── Lifecycle ───────────────────────────────────────────────────
|
|
@@ -4,7 +4,15 @@ import { QuotaThresholdExceededError } from "../../../application/qgrid/qgrid.ty
|
|
|
4
4
|
import { type OpenAICredentials } from "../../../application/token/token.types";
|
|
5
5
|
import { type GenerateRequest, type GenerateResult } from "../common/provider-dispatcher";
|
|
6
6
|
import { type CodexAppServerWorker } from "./codex-worker";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
estimateOpenAIWorkerRssGiB,
|
|
9
|
+
ImageGenerationError,
|
|
10
|
+
makeOpenAIWorkerId,
|
|
11
|
+
MAX_OPENAI_WORKERS_PER_TOKEN,
|
|
12
|
+
type OpenAIWorkerPoolConfig,
|
|
13
|
+
OpenAIDispatcher,
|
|
14
|
+
resolveOpenAIWorkerPoolConfig,
|
|
15
|
+
} from "./openai-dispatcher";
|
|
8
16
|
import { type OpenAIQuotaUsageResult } from "./openai-quota";
|
|
9
17
|
|
|
10
18
|
const { readOpenAIQuotaUsageMock, loggerInfoMock, loggerWarnMock } = vi.hoisted(() => ({
|
|
@@ -70,7 +78,7 @@ function resultFor(worker: CodexAppServerWorker, threadId = "thread-new"): Gener
|
|
|
70
78
|
durationMs: 10,
|
|
71
79
|
model: "gpt-5-codex",
|
|
72
80
|
threadCoord: {
|
|
73
|
-
workerId: worker.tokenId
|
|
81
|
+
workerId: makeOpenAIWorkerId(worker.tokenId, worker.workerIndex),
|
|
74
82
|
threadId,
|
|
75
83
|
epoch: worker.epoch,
|
|
76
84
|
},
|
|
@@ -159,6 +167,21 @@ function addWorkers(dispatcher: OpenAIDispatcher, workers: CodexAppServerWorker[
|
|
|
159
167
|
for (const [tokenId, group] of byToken) dispatcher.workerPool.set(tokenId, group);
|
|
160
168
|
}
|
|
161
169
|
|
|
170
|
+
function autoscaleConfig(
|
|
171
|
+
overrides: Partial<OpenAIWorkerPoolConfig> = {},
|
|
172
|
+
): OpenAIWorkerPoolConfig {
|
|
173
|
+
return {
|
|
174
|
+
autoscale: true,
|
|
175
|
+
minWorkersPerToken: 1,
|
|
176
|
+
maxWorkersPerToken: 3,
|
|
177
|
+
scaleIntervalMs: 5_000,
|
|
178
|
+
scaleDownIdleMs: 600_000,
|
|
179
|
+
maxEstimatedRssGiB: 16,
|
|
180
|
+
minHostAvailableGiB: 20,
|
|
181
|
+
...overrides,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
162
185
|
async function waitForQueue(dispatcher: OpenAIDispatcher, length = 1): Promise<void> {
|
|
163
186
|
for (let i = 0; i < 10; i++) {
|
|
164
187
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
@@ -167,6 +190,159 @@ async function waitForQueue(dispatcher: OpenAIDispatcher, length = 1): Promise<v
|
|
|
167
190
|
expect(dispatcher.queueLength).toBe(length);
|
|
168
191
|
}
|
|
169
192
|
|
|
193
|
+
describe("OpenAIDispatcher worker capacity", () => {
|
|
194
|
+
it("supports 20 workers per token without worker id collisions", () => {
|
|
195
|
+
expect(MAX_OPENAI_WORKERS_PER_TOKEN).toBe(20);
|
|
196
|
+
|
|
197
|
+
const tokenIds = [1, 2, 41, 47, 55, 56, 58];
|
|
198
|
+
const workerIds = tokenIds.flatMap((tokenId) =>
|
|
199
|
+
Array.from({ length: MAX_OPENAI_WORKERS_PER_TOKEN }, (_, workerIndex) =>
|
|
200
|
+
makeOpenAIWorkerId(tokenId, workerIndex),
|
|
201
|
+
),
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
expect(new Set(workerIds)).toHaveLength(workerIds.length);
|
|
205
|
+
expect(makeOpenAIWorkerId(1, 10)).not.toBe(makeOpenAIWorkerId(2, 0));
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("defaults to autoscaling from 5 to 15 workers per token", () => {
|
|
209
|
+
expect(resolveOpenAIWorkerPoolConfig({})).toMatchObject({
|
|
210
|
+
autoscale: true,
|
|
211
|
+
minWorkersPerToken: 5,
|
|
212
|
+
maxWorkersPerToken: 15,
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("keeps an explicit fixed worker mode for emergency rollback", () => {
|
|
217
|
+
expect(
|
|
218
|
+
resolveOpenAIWorkerPoolConfig({
|
|
219
|
+
QGRID_WORKERS_PER_TOKEN: "5",
|
|
220
|
+
QGRID_OPENAI_AUTOSCALE: "false",
|
|
221
|
+
}),
|
|
222
|
+
).toMatchObject({
|
|
223
|
+
autoscale: false,
|
|
224
|
+
minWorkersPerToken: 5,
|
|
225
|
+
maxWorkersPerToken: 5,
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("estimates worker RSS from the dev0 measurement model", () => {
|
|
230
|
+
expect(estimateOpenAIWorkerRssGiB(50)).toBeCloseTo(8.56, 2);
|
|
231
|
+
expect(estimateOpenAIWorkerRssGiB(60)).toBeCloseTo(10.13, 2);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe("OpenAIDispatcher autoscaling", () => {
|
|
236
|
+
it("scales six active tokens from 5 to 15 workers per token", async () => {
|
|
237
|
+
const dispatcher = new OpenAIDispatcher(
|
|
238
|
+
autoscaleConfig({
|
|
239
|
+
minWorkersPerToken: 5,
|
|
240
|
+
maxWorkersPerToken: 15,
|
|
241
|
+
maxEstimatedRssGiB: 16,
|
|
242
|
+
}),
|
|
243
|
+
() => 64,
|
|
244
|
+
);
|
|
245
|
+
vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
|
|
246
|
+
|
|
247
|
+
for (let tokenId = 1; tokenId <= 6; tokenId++) {
|
|
248
|
+
await dispatcher.onTokenAdded(tokenId, `token-${tokenId}`, credentials(), null, 1);
|
|
249
|
+
dispatcher.workerPool.set(
|
|
250
|
+
tokenId,
|
|
251
|
+
Array.from({ length: 5 }, (_, workerIndex) => fakeWorker({ tokenId, workerIndex })),
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
vi.spyOn(dispatcher, "spawnSingleWorker").mockImplementation(
|
|
256
|
+
async (tokenId, tokenName, _credentials, workerIndex) =>
|
|
257
|
+
fakeWorker({ tokenId, tokenName, workerIndex }),
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
for (let step = 0; step < 12; step++) await dispatcher.scaleUpOneStep();
|
|
261
|
+
|
|
262
|
+
expect(dispatcher.workerCount).toBe(90);
|
|
263
|
+
expect([...dispatcher.workerPool.values()].map((workers) => workers.length)).toEqual([
|
|
264
|
+
15, 15, 15, 15, 15, 15,
|
|
265
|
+
]);
|
|
266
|
+
expect(estimateOpenAIWorkerRssGiB(dispatcher.workerCount)).toBeLessThan(16);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("requests a scale-up evaluation when a request enters the queue", async () => {
|
|
270
|
+
const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
|
|
271
|
+
addWorkers(dispatcher, [fakeWorker({ tokenId: 1, busy: true })]);
|
|
272
|
+
const scaleUpOneStep = vi.spyOn(dispatcher, "scaleUpOneStep").mockResolvedValue(undefined);
|
|
273
|
+
|
|
274
|
+
const queued = dispatcher.enqueue(async () => "ok");
|
|
275
|
+
const rejection = expect(queued).rejects.toThrow("TEST_CLEANUP");
|
|
276
|
+
await waitForQueue(dispatcher);
|
|
277
|
+
dispatcher.rejectAllQueued("TEST_CLEANUP");
|
|
278
|
+
|
|
279
|
+
await rejection;
|
|
280
|
+
expect(scaleUpOneStep).toHaveBeenCalledTimes(1);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("adds one worker to each active token in a scale-up step", async () => {
|
|
284
|
+
const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
|
|
285
|
+
const tokenCredentials = credentials();
|
|
286
|
+
vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
|
|
287
|
+
await dispatcher.onTokenAdded(1, "token", tokenCredentials, null, 1);
|
|
288
|
+
dispatcher.workerPool.set(1, [fakeWorker({ tokenId: 1, workerIndex: 0 })]);
|
|
289
|
+
const added = fakeWorker({ tokenId: 1, workerIndex: 1 });
|
|
290
|
+
const spawnSingleWorker = vi.spyOn(dispatcher, "spawnSingleWorker").mockResolvedValue(added);
|
|
291
|
+
|
|
292
|
+
await dispatcher.scaleUpOneStep();
|
|
293
|
+
|
|
294
|
+
expect(spawnSingleWorker).toHaveBeenCalledWith(1, "token", tokenCredentials, 1);
|
|
295
|
+
expect(dispatcher.workerPool.get(1)).toHaveLength(2);
|
|
296
|
+
expect(dispatcher.workerCount).toBe(2);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("does not scale past the estimated RSS guard", async () => {
|
|
300
|
+
const dispatcher = new OpenAIDispatcher(
|
|
301
|
+
autoscaleConfig({ maxEstimatedRssGiB: 0.9 }),
|
|
302
|
+
() => 64,
|
|
303
|
+
);
|
|
304
|
+
vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
|
|
305
|
+
await dispatcher.onTokenAdded(1, "token", credentials(), null, 1);
|
|
306
|
+
dispatcher.workerPool.set(1, [fakeWorker({ tokenId: 1, workerIndex: 0 })]);
|
|
307
|
+
const spawnSingleWorker = vi.spyOn(dispatcher, "spawnSingleWorker");
|
|
308
|
+
|
|
309
|
+
await dispatcher.scaleUpOneStep();
|
|
310
|
+
|
|
311
|
+
expect(spawnSingleWorker).not.toHaveBeenCalled();
|
|
312
|
+
expect(dispatcher.workerCount).toBe(1);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("does not scale when host available memory is below the floor", async () => {
|
|
316
|
+
const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 10);
|
|
317
|
+
vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
|
|
318
|
+
await dispatcher.onTokenAdded(1, "token", credentials(), null, 1);
|
|
319
|
+
dispatcher.workerPool.set(1, [fakeWorker({ tokenId: 1, workerIndex: 0 })]);
|
|
320
|
+
const spawnSingleWorker = vi.spyOn(dispatcher, "spawnSingleWorker");
|
|
321
|
+
|
|
322
|
+
await dispatcher.scaleUpOneStep();
|
|
323
|
+
|
|
324
|
+
expect(spawnSingleWorker).not.toHaveBeenCalled();
|
|
325
|
+
expect(dispatcher.workerCount).toBe(1);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("removes only idle workers above the configured minimum for inactive tokens", async () => {
|
|
329
|
+
const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
|
|
330
|
+
vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
|
|
331
|
+
await dispatcher.onTokenAdded(1, "token", credentials(), null, 1);
|
|
332
|
+
const busy = fakeWorker({ tokenId: 1, workerIndex: 0, busy: true });
|
|
333
|
+
const idle = fakeWorker({ tokenId: 1, workerIndex: 1 });
|
|
334
|
+
dispatcher.workerPool.set(1, [busy, idle]);
|
|
335
|
+
dispatcher.onTokenDeactivated(1);
|
|
336
|
+
|
|
337
|
+
await dispatcher.scaleDownOneStep();
|
|
338
|
+
await dispatcher.scaleDownOneStep();
|
|
339
|
+
|
|
340
|
+
expect(idle.kill).toHaveBeenCalledTimes(1);
|
|
341
|
+
expect(busy.kill).not.toHaveBeenCalled();
|
|
342
|
+
expect(dispatcher.workerPool.get(1)).toEqual([busy]);
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
170
346
|
describe("OpenAIDispatcher token updates", () => {
|
|
171
347
|
it("keeps existing workers when only OpenAI auth tokens rotate", async () => {
|
|
172
348
|
const dispatcher = new OpenAIDispatcher();
|
|
@@ -329,7 +505,9 @@ describe("OpenAIDispatcher weighted routing", () => {
|
|
|
329
505
|
|
|
330
506
|
const cold1 = await dispatcher.generate(baseReq());
|
|
331
507
|
const reused = await dispatcher.generate(
|
|
332
|
-
baseReq({
|
|
508
|
+
baseReq({
|
|
509
|
+
reuse: { workerId: makeOpenAIWorkerId(2, 0), threadId: "thread-B", epoch: 1 },
|
|
510
|
+
}),
|
|
333
511
|
);
|
|
334
512
|
const cold2 = await dispatcher.generate(baseReq());
|
|
335
513
|
|
|
@@ -470,7 +648,9 @@ describe("OpenAIDispatcher quota threshold gate", () => {
|
|
|
470
648
|
);
|
|
471
649
|
|
|
472
650
|
const result = await dispatcher.generate(
|
|
473
|
-
baseReq({
|
|
651
|
+
baseReq({
|
|
652
|
+
reuse: { workerId: makeOpenAIWorkerId(1, 0), threadId: "thread-1", epoch: 1 },
|
|
653
|
+
}),
|
|
474
654
|
);
|
|
475
655
|
|
|
476
656
|
expect(result.tokenName).toBe("tok-B");
|
|
@@ -492,7 +672,9 @@ describe("OpenAIDispatcher quota threshold gate", () => {
|
|
|
492
672
|
);
|
|
493
673
|
|
|
494
674
|
const result = await dispatcher.generate(
|
|
495
|
-
baseReq({
|
|
675
|
+
baseReq({
|
|
676
|
+
reuse: { workerId: makeOpenAIWorkerId(1, 0), threadId: "thread-1", epoch: 1 },
|
|
677
|
+
}),
|
|
496
678
|
);
|
|
497
679
|
|
|
498
680
|
expect(result.tokenName).toBe("tok-A");
|
|
@@ -543,7 +725,11 @@ describe("OpenAIDispatcher quota threshold gate", () => {
|
|
|
543
725
|
readOpenAIQuotaUsageMock.mockResolvedValueOnce(quotaOk(85));
|
|
544
726
|
|
|
545
727
|
await expect(
|
|
546
|
-
dispatcher.generate(
|
|
728
|
+
dispatcher.generate(
|
|
729
|
+
baseReq({
|
|
730
|
+
reuse: { workerId: makeOpenAIWorkerId(1, 0), threadId: "thread-1", epoch: 1 },
|
|
731
|
+
}),
|
|
732
|
+
),
|
|
547
733
|
).rejects.toBeInstanceOf(QuotaThresholdExceededError);
|
|
548
734
|
});
|
|
549
735
|
});
|
|
@@ -621,7 +807,10 @@ describe("OpenAIDispatcher image generation routing", () => {
|
|
|
621
807
|
it("forces cold thread for image requests even when a reuse coordinate is present", async () => {
|
|
622
808
|
const dispatcher = new OpenAIDispatcher();
|
|
623
809
|
const worker = await dispatcher.acquireReuseWorker(
|
|
624
|
-
baseReq({
|
|
810
|
+
baseReq({
|
|
811
|
+
imageGeneration: true,
|
|
812
|
+
reuse: { workerId: makeOpenAIWorkerId(1, 0), threadId: "t1", epoch: 1 },
|
|
813
|
+
}),
|
|
625
814
|
);
|
|
626
815
|
expect(worker).toBeNull();
|
|
627
816
|
});
|