@toshicloud/sdk 0.3.1 → 0.4.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.
@@ -0,0 +1,845 @@
1
+ import { clone, create } from "@bufbuild/protobuf";
2
+ import { Code, ConnectError, createRouterTransport } from "@connectrpc/connect";
3
+ import { createToshiClient } from "../client.js";
4
+ import { CancelJobResponseSchema, CreateJobResponseSchema, CreateSandboxResponseSchema, GetJobResponseSchema, GetSandboxResponseSchema, ListJobsResponseSchema, ListSandboxesResponseSchema, WaitJobResponseSchema, WaitSandboxResponseSchema, WatchJobsResponseSchema, WatchSandboxesResponseSchema, WorkloadService, } from "../gen/toshi/workspace/v1/workloads_pb.js";
5
+ import { EndReason, EventType, ExecuteResponseSchema, ExecutionExitSchema, ExecutionStartedSchema, JobSchema, JobState, JobStatusSchema, OutputEndSchema, RecordingPolicy, SandboxSchema, SandboxState, SandboxStatusSchema, WatchOutputResponseSchema, } from "../gen/toshi/workspace/v1/workspace_pb.js";
6
+ import { apiMethods, apiPackage, hookOnly as hookOnlyMethods, isApiMethod, procedurePath } from "./procedures.js";
7
+ /** The token `client` sends. */
8
+ export const testToken = "test-token";
9
+ // The scope client fills into a request.
10
+ const projectScope = "default";
11
+ const environmentScope = "production";
12
+ // Answers a fake's errors at once, so a hook that answers UNAVAILABLE does not
13
+ // wait through the client's backoff.
14
+ const fastRetry = { maxAttempts: 5, initialBackoffMs: 1, maxBackoffMs: 2 };
15
+ /** Creates a fake Toshi: an in-memory Workspace API and the state its calls answer from. */
16
+ export function createFake() {
17
+ return new FakeToshi();
18
+ }
19
+ /** The whole API's methods, as [path, method], for the router. */
20
+ const routerMethods = [...apiMethods];
21
+ /** The answers the state machine gives, one entry per method it covers. */
22
+ const builtinTable = [
23
+ [
24
+ workloadMethod("createJob"),
25
+ (fake) => ({
26
+ kind: "unary",
27
+ run: (request) => fake.createJobAnswer(request),
28
+ }),
29
+ ],
30
+ [
31
+ workloadMethod("getJob"),
32
+ (fake) => ({ kind: "unary", run: (request) => fake.getJobAnswer(request) }),
33
+ ],
34
+ [
35
+ workloadMethod("listJobs"),
36
+ (fake) => ({ kind: "unary", run: () => fake.listJobsAnswer() }),
37
+ ],
38
+ [
39
+ workloadMethod("cancelJob"),
40
+ (fake) => ({ kind: "unary", run: (request) => fake.cancelJobAnswer(request) }),
41
+ ],
42
+ [
43
+ workloadMethod("waitJob"),
44
+ (fake) => ({ kind: "unary", run: (request) => fake.waitJobAnswer(request) }),
45
+ ],
46
+ [
47
+ workloadMethod("createSandbox"),
48
+ (fake) => ({
49
+ kind: "unary",
50
+ run: (request) => fake.createSandboxAnswer(request),
51
+ }),
52
+ ],
53
+ [
54
+ workloadMethod("getSandbox"),
55
+ (fake) => ({ kind: "unary", run: (request) => fake.getSandboxAnswer(request) }),
56
+ ],
57
+ [
58
+ workloadMethod("listSandboxes"),
59
+ (fake) => ({ kind: "unary", run: () => fake.listSandboxesAnswer() }),
60
+ ],
61
+ [
62
+ workloadMethod("waitSandbox"),
63
+ (fake) => ({
64
+ kind: "unary",
65
+ run: (request) => fake.waitSandboxAnswer(request),
66
+ }),
67
+ ],
68
+ [
69
+ workloadMethod("watchJobs"),
70
+ (fake) => ({
71
+ kind: "server",
72
+ run: (request, context) => fake.watchJobsAnswer(request, context),
73
+ }),
74
+ ],
75
+ [
76
+ workloadMethod("watchOutput"),
77
+ (fake) => ({
78
+ kind: "server",
79
+ run: (request) => fake.watchOutputAnswer(request),
80
+ }),
81
+ ],
82
+ [
83
+ workloadMethod("watchSandboxes"),
84
+ (fake) => ({
85
+ kind: "server",
86
+ run: (request, context) => fake.watchSandboxesAnswer(request, context),
87
+ }),
88
+ ],
89
+ [
90
+ workloadMethod("execute"),
91
+ (fake) => ({
92
+ kind: "bidi",
93
+ run: (requests) => fake.executeAnswer(requests),
94
+ }),
95
+ ],
96
+ ];
97
+ /** The procedures the state machine answers. The coverage test holds it against the API. */
98
+ export const answeredProcedures = builtinTable.map(([method]) => method);
99
+ /** One of WorkloadService's methods, as the descriptor the API exports. */
100
+ function workloadMethod(name) {
101
+ return WorkloadService.method[name];
102
+ }
103
+ /** The fake itself; `createFake` hands it out as `Fake`. */
104
+ class FakeToshi {
105
+ transport;
106
+ hooks = new Map();
107
+ builtin = new Map();
108
+ callLog = [];
109
+ jobs = new Map();
110
+ jobQueue = [];
111
+ jobCreated = [];
112
+ jobKeys = new Map();
113
+ jobLog = [];
114
+ jobWaiters = new Set();
115
+ nextJob = 0;
116
+ sandboxes = new Map();
117
+ sandboxQueue = [];
118
+ sandboxCreated = [];
119
+ sandboxKeys = new Map();
120
+ sandboxLog = [];
121
+ sandboxWaiters = new Set();
122
+ nextSandbox = 0;
123
+ executions = new Map();
124
+ nextExecution = 0;
125
+ constructor() {
126
+ for (const [method, make] of builtinTable) {
127
+ this.builtin.set(procedurePath(method), make(this));
128
+ }
129
+ this.transport = (interceptors) => createRouterTransport((router) => {
130
+ this.mount(router);
131
+ }, { transport: { interceptors } });
132
+ }
133
+ job(id) {
134
+ if (!this.jobs.has(id)) {
135
+ this.jobs.set(id, newJobRecord(id));
136
+ this.jobQueue.push(id);
137
+ }
138
+ return new JobBuilder(this, id);
139
+ }
140
+ sandbox(id) {
141
+ if (!this.sandboxes.has(id)) {
142
+ this.sandboxes.set(id, newSandboxRecord(id));
143
+ this.sandboxQueue.push(id);
144
+ }
145
+ return new SandboxBuilder(this, id);
146
+ }
147
+ execute(sandboxId) {
148
+ if (!this.sandboxes.has(sandboxId)) {
149
+ this.sandboxes.set(sandboxId, newSandboxRecord(sandboxId));
150
+ }
151
+ if (!this.executions.has(sandboxId)) {
152
+ this.executions.set(sandboxId, { stdout: "", stderr: "", exitCode: 0 });
153
+ }
154
+ return new ExecutionBuilder(this, sandboxId);
155
+ }
156
+ on(method, impl) {
157
+ if (!isApiMethod(method)) {
158
+ throw new Error(`toshi: ${procedurePath(method)} is not a method of the ${apiPackage} API`);
159
+ }
160
+ this.hooks.set(procedurePath(method), hookAnswer(method, impl));
161
+ return this;
162
+ }
163
+ calls() {
164
+ return this.callLog.slice();
165
+ }
166
+ client(options = {}) {
167
+ return createToshiClient({
168
+ transport: this.transport,
169
+ token: testToken,
170
+ project: projectScope,
171
+ environment: environmentScope,
172
+ retry: fastRetry,
173
+ ...options,
174
+ });
175
+ }
176
+ // The builders' state changes. They are public so the builders can reach
177
+ // them; `Fake` is the interface callers see.
178
+ setJobState(id, state) {
179
+ const record = this.jobs.get(id);
180
+ if (record === undefined) {
181
+ return;
182
+ }
183
+ record.state = state;
184
+ this.changedJob(id, EventType.WORKLOAD_STATE_CHANGED);
185
+ }
186
+ setJobStdout(id, stdout) {
187
+ const record = this.jobs.get(id);
188
+ if (record !== undefined) {
189
+ record.stdout = stdout;
190
+ }
191
+ }
192
+ setJobStderr(id, stderr) {
193
+ const record = this.jobs.get(id);
194
+ if (record !== undefined) {
195
+ record.stderr = stderr;
196
+ }
197
+ }
198
+ setJobExitCode(id, code) {
199
+ const record = this.jobs.get(id);
200
+ if (record === undefined) {
201
+ return;
202
+ }
203
+ record.exitCode = code;
204
+ record.state = JobState.ENDED;
205
+ record.endReason = EndReason.EXITED;
206
+ this.changedJob(id, EventType.WORKLOAD_STATE_CHANGED);
207
+ }
208
+ setSandboxState(id, state) {
209
+ const record = this.sandboxes.get(id);
210
+ if (record === undefined) {
211
+ return;
212
+ }
213
+ record.state = state;
214
+ this.changedSandbox(id, EventType.WORKLOAD_STATE_CHANGED);
215
+ }
216
+ setExecutionStdout(sandboxId, stdout) {
217
+ const record = this.executions.get(sandboxId);
218
+ if (record !== undefined) {
219
+ record.stdout = stdout;
220
+ }
221
+ }
222
+ setExecutionStderr(sandboxId, stderr) {
223
+ const record = this.executions.get(sandboxId);
224
+ if (record !== undefined) {
225
+ record.stderr = stderr;
226
+ }
227
+ }
228
+ setExecutionExitCode(sandboxId, code) {
229
+ const record = this.executions.get(sandboxId);
230
+ if (record !== undefined) {
231
+ record.exitCode = code;
232
+ }
233
+ }
234
+ // The state machine.
235
+ createJobAnswer(request) {
236
+ const key = request.requestKey;
237
+ if (key !== "") {
238
+ const replayed = this.jobKeys.get(key);
239
+ if (replayed !== undefined) {
240
+ return create(CreateJobResponseSchema, { job: jobProto(replayed) });
241
+ }
242
+ }
243
+ const record = this.newJob();
244
+ record.project = request.project;
245
+ record.environment = request.environment;
246
+ record.spec = request.spec;
247
+ record.labels = { ...request.labels };
248
+ record.recording = request.recording;
249
+ record.created = true;
250
+ if (key !== "") {
251
+ this.jobKeys.set(key, record);
252
+ }
253
+ this.jobCreated.push(record.id);
254
+ const response = create(CreateJobResponseSchema, { job: jobProto(record) });
255
+ this.changedJob(record.id, EventType.WORKLOAD_CREATED);
256
+ return response;
257
+ }
258
+ getJobAnswer(request) {
259
+ const record = this.jobs.get(request.id);
260
+ if (record === undefined) {
261
+ throw notFound("job", request.id);
262
+ }
263
+ return create(GetJobResponseSchema, { job: jobProto(record) });
264
+ }
265
+ listJobsAnswer() {
266
+ const jobs = [];
267
+ for (const id of this.jobCreated) {
268
+ const record = this.jobs.get(id);
269
+ if (record !== undefined) {
270
+ jobs.push(jobProto(record));
271
+ }
272
+ }
273
+ return create(ListJobsResponseSchema, { jobs, cursor: String(this.jobLog.length) });
274
+ }
275
+ cancelJobAnswer(request) {
276
+ const record = this.jobs.get(request.id);
277
+ if (record === undefined) {
278
+ throw notFound("job", request.id);
279
+ }
280
+ if (record.state !== JobState.ENDED) {
281
+ record.state = JobState.ENDED;
282
+ record.endReason = EndReason.CANCELLED;
283
+ }
284
+ const response = create(CancelJobResponseSchema, { job: jobProto(record) });
285
+ this.changedJob(request.id, EventType.WORKLOAD_STATE_CHANGED);
286
+ return response;
287
+ }
288
+ waitJobAnswer(request) {
289
+ const record = this.jobs.get(request.id);
290
+ if (record === undefined) {
291
+ throw notFound("job", request.id);
292
+ }
293
+ return create(WaitJobResponseSchema, { job: jobProto(record), reached: true });
294
+ }
295
+ createSandboxAnswer(request) {
296
+ const key = request.requestKey;
297
+ if (key !== "") {
298
+ const replayed = this.sandboxKeys.get(key);
299
+ if (replayed !== undefined) {
300
+ return create(CreateSandboxResponseSchema, { sandbox: sandboxProto(replayed) });
301
+ }
302
+ }
303
+ const record = this.newSandbox();
304
+ record.project = request.project;
305
+ record.environment = request.environment;
306
+ record.spec = request.spec;
307
+ record.labels = { ...request.labels };
308
+ record.recording = request.recording;
309
+ record.created = true;
310
+ if (key !== "") {
311
+ this.sandboxKeys.set(key, record);
312
+ }
313
+ this.sandboxCreated.push(record.id);
314
+ const response = create(CreateSandboxResponseSchema, { sandbox: sandboxProto(record) });
315
+ this.changedSandbox(record.id, EventType.WORKLOAD_CREATED);
316
+ return response;
317
+ }
318
+ getSandboxAnswer(request) {
319
+ const record = this.sandboxes.get(request.id);
320
+ if (record === undefined) {
321
+ throw notFound("sandbox", request.id);
322
+ }
323
+ return create(GetSandboxResponseSchema, { sandbox: sandboxProto(record) });
324
+ }
325
+ listSandboxesAnswer() {
326
+ const sandboxes = [];
327
+ for (const id of this.sandboxCreated) {
328
+ const record = this.sandboxes.get(id);
329
+ if (record !== undefined) {
330
+ sandboxes.push(sandboxProto(record));
331
+ }
332
+ }
333
+ return create(ListSandboxesResponseSchema, { sandboxes, cursor: String(this.sandboxLog.length) });
334
+ }
335
+ waitSandboxAnswer(request) {
336
+ const record = this.sandboxes.get(request.id);
337
+ if (record === undefined) {
338
+ throw notFound("sandbox", request.id);
339
+ }
340
+ return create(WaitSandboxResponseSchema, { sandbox: sandboxProto(record), reached: true });
341
+ }
342
+ /** Streams a job's output from the request's offsets, then the job's end. */
343
+ watchOutputAnswer(request) {
344
+ const record = this.jobs.get(request.id);
345
+ if (record === undefined) {
346
+ return failed(notFound("job", request.id));
347
+ }
348
+ const stdout = fromOffset(encode(record.stdout), request.stdoutOffset);
349
+ const stderr = fromOffset(encode(record.stderr), request.stderrOffset);
350
+ const cancelled = record.endReason === EndReason.CANCELLED;
351
+ const messages = [];
352
+ if (stdout.length > 0) {
353
+ messages.push(create(WatchOutputResponseSchema, { message: { case: "stdout", value: stdout } }));
354
+ }
355
+ if (stderr.length > 0) {
356
+ messages.push(create(WatchOutputResponseSchema, { message: { case: "stderr", value: stderr } }));
357
+ }
358
+ messages.push(create(WatchOutputResponseSchema, {
359
+ message: {
360
+ case: "end",
361
+ value: create(OutputEndSchema, { exitCode: record.exitCode, cancelled }),
362
+ },
363
+ }));
364
+ return iterableOf(messages);
365
+ }
366
+ /** Reports the job changes made after the cursor, then each change made while the stream is open. */
367
+ async *watchJobsAnswer(request, context) {
368
+ let from = parseCursor(request.cursor);
369
+ for (;;) {
370
+ if (from > this.jobLog.length) {
371
+ throw staleCursor(from);
372
+ }
373
+ while (from < this.jobLog.length) {
374
+ const change = this.jobLog[from];
375
+ from += 1;
376
+ yield clone(WatchJobsResponseSchema, change);
377
+ }
378
+ await waitForChange(this.jobWaiters, context.signal);
379
+ if (context.signal.aborted) {
380
+ return;
381
+ }
382
+ }
383
+ }
384
+ /** Reports the sandbox changes made after the cursor, then each change made while the stream is open. */
385
+ async *watchSandboxesAnswer(request, context) {
386
+ let from = parseCursor(request.cursor);
387
+ for (;;) {
388
+ if (from > this.sandboxLog.length) {
389
+ throw staleCursor(from);
390
+ }
391
+ while (from < this.sandboxLog.length) {
392
+ const change = this.sandboxLog[from];
393
+ from += 1;
394
+ yield clone(WatchSandboxesResponseSchema, change);
395
+ }
396
+ await waitForChange(this.sandboxWaiters, context.signal);
397
+ if (context.signal.aborted) {
398
+ return;
399
+ }
400
+ }
401
+ }
402
+ /**
403
+ * Answers Execute with the outcome the test set for the sandbox: an
404
+ * execution id, the output, and the exit code. Standard input is not read.
405
+ */
406
+ async *executeAnswer(requests) {
407
+ let start;
408
+ for await (const message of requests) {
409
+ const request = message;
410
+ if (request.message.case !== "start") {
411
+ throw new ConnectError("toshi: Execute needs a start message", Code.InvalidArgument);
412
+ }
413
+ start = request.message.value;
414
+ break;
415
+ }
416
+ if (start === undefined) {
417
+ throw new ConnectError("toshi: Execute needs a start message", Code.InvalidArgument);
418
+ }
419
+ const record = this.executions.get(start.sandboxId);
420
+ this.nextExecution += 1;
421
+ const executionId = `exec-${this.nextExecution}`;
422
+ yield create(ExecuteResponseSchema, {
423
+ message: { case: "started", value: create(ExecutionStartedSchema, { executionId }) },
424
+ });
425
+ const stdout = encode(record?.stdout ?? "");
426
+ if (stdout.length > 0) {
427
+ yield create(ExecuteResponseSchema, { message: { case: "stdout", value: stdout } });
428
+ }
429
+ const stderr = encode(record?.stderr ?? "");
430
+ if (stderr.length > 0) {
431
+ yield create(ExecuteResponseSchema, { message: { case: "stderr", value: stderr } });
432
+ }
433
+ yield create(ExecuteResponseSchema, {
434
+ message: {
435
+ case: "exit",
436
+ value: create(ExecutionExitSchema, { exitCode: record?.exitCode ?? 0, cancelled: false }),
437
+ },
438
+ });
439
+ }
440
+ // The router and the dispatch.
441
+ mount(router) {
442
+ for (const [path, method] of routerMethods) {
443
+ switch (method.methodKind) {
444
+ case "unary":
445
+ router.rpc(method, ((request, context) => {
446
+ this.record(method, request, context.requestHeader);
447
+ return this.answerUnary(path, request, context);
448
+ }));
449
+ break;
450
+ case "server_streaming":
451
+ router.rpc(method, ((request, context) => {
452
+ this.record(method, request, context.requestHeader);
453
+ return this.answerServer(path, request, context);
454
+ }));
455
+ break;
456
+ case "bidi_streaming":
457
+ router.rpc(method, ((requests, context) => this.answerBidi(path, method, requests, context)));
458
+ break;
459
+ case "client_streaming":
460
+ router.rpc(method, (() => Promise.reject(notImplemented(path))));
461
+ break;
462
+ }
463
+ }
464
+ }
465
+ answerUnary(path, request, context) {
466
+ let answer;
467
+ try {
468
+ answer = this.answerFor(path);
469
+ }
470
+ catch (err) {
471
+ return Promise.reject(errorOf(err));
472
+ }
473
+ if (answer.kind !== "unary") {
474
+ return Promise.reject(wrongKind(path, answer.kind));
475
+ }
476
+ return Promise.resolve(answer.run(request, context));
477
+ }
478
+ answerServer(path, request, context) {
479
+ let answer;
480
+ try {
481
+ answer = this.answerFor(path);
482
+ }
483
+ catch (err) {
484
+ return failed(errorOf(err));
485
+ }
486
+ if (answer.kind !== "server") {
487
+ return failed(wrongKind(path, answer.kind));
488
+ }
489
+ return answer.run(request, context);
490
+ }
491
+ answerBidi(path, method, requests, context) {
492
+ let answer;
493
+ try {
494
+ answer = this.answerFor(path);
495
+ }
496
+ catch (err) {
497
+ return failed(errorOf(err));
498
+ }
499
+ if (answer.kind !== "bidi") {
500
+ return failed(wrongKind(path, answer.kind));
501
+ }
502
+ // A bidirectional call is recorded when its first message arrives, as
503
+ // toshi-go's toshitest does; an unhooked one is answered before reading
504
+ // anything.
505
+ let recorded = false;
506
+ const recordedRequests = {
507
+ [Symbol.asyncIterator]: () => {
508
+ const iterator = requests[Symbol.asyncIterator]();
509
+ return {
510
+ next: async () => {
511
+ const next = await iterator.next();
512
+ if (!recorded && next.done !== true) {
513
+ recorded = true;
514
+ this.record(method, next.value, context.requestHeader);
515
+ }
516
+ return next;
517
+ },
518
+ return: async (value) => iterator.return?.(value) ?? { done: true, value: undefined },
519
+ };
520
+ },
521
+ };
522
+ return answer.run(recordedRequests, context);
523
+ }
524
+ answerFor(path) {
525
+ return this.hooks.get(path) ?? this.builtin.get(path) ?? missingAnswer(path);
526
+ }
527
+ record(method, request, header) {
528
+ this.callLog.push({
529
+ procedure: procedurePath(method),
530
+ request: clone(method.input, request),
531
+ header: new Headers(header),
532
+ });
533
+ }
534
+ // The records the built-ins answer from.
535
+ newJob() {
536
+ const queued = this.jobQueue.shift();
537
+ if (queued !== undefined) {
538
+ const record = this.jobs.get(queued);
539
+ if (record !== undefined) {
540
+ return record;
541
+ }
542
+ }
543
+ for (;;) {
544
+ this.nextJob += 1;
545
+ const id = `job-${this.nextJob}`;
546
+ if (this.jobs.has(id)) {
547
+ continue;
548
+ }
549
+ const record = newJobRecord(id);
550
+ this.jobs.set(id, record);
551
+ return record;
552
+ }
553
+ }
554
+ newSandbox() {
555
+ const queued = this.sandboxQueue.shift();
556
+ if (queued !== undefined) {
557
+ const record = this.sandboxes.get(queued);
558
+ if (record !== undefined) {
559
+ return record;
560
+ }
561
+ }
562
+ for (;;) {
563
+ this.nextSandbox += 1;
564
+ const id = `sandbox-${this.nextSandbox}`;
565
+ if (this.sandboxes.has(id)) {
566
+ continue;
567
+ }
568
+ const record = newSandboxRecord(id);
569
+ this.sandboxes.set(id, record);
570
+ return record;
571
+ }
572
+ }
573
+ changedJob(id, type) {
574
+ const record = this.jobs.get(id);
575
+ if (record === undefined || !record.created) {
576
+ return;
577
+ }
578
+ this.jobLog.push(create(WatchJobsResponseSchema, {
579
+ type,
580
+ job: jobProto(record),
581
+ cursor: String(this.jobLog.length + 1),
582
+ }));
583
+ for (const wake of [...this.jobWaiters]) {
584
+ wake();
585
+ }
586
+ }
587
+ changedSandbox(id, type) {
588
+ const record = this.sandboxes.get(id);
589
+ if (record === undefined || !record.created) {
590
+ return;
591
+ }
592
+ this.sandboxLog.push(create(WatchSandboxesResponseSchema, {
593
+ type,
594
+ sandbox: sandboxProto(record),
595
+ cursor: String(this.sandboxLog.length + 1),
596
+ }));
597
+ for (const wake of [...this.sandboxWaiters]) {
598
+ wake();
599
+ }
600
+ }
601
+ }
602
+ /** Answers a job's setters. */
603
+ class JobBuilder {
604
+ fake;
605
+ id;
606
+ constructor(fake, id) {
607
+ this.fake = fake;
608
+ this.id = id;
609
+ }
610
+ state(state) {
611
+ this.fake.setJobState(this.id, state);
612
+ return this;
613
+ }
614
+ stdout(stdout) {
615
+ this.fake.setJobStdout(this.id, stdout);
616
+ return this;
617
+ }
618
+ stderr(stderr) {
619
+ this.fake.setJobStderr(this.id, stderr);
620
+ return this;
621
+ }
622
+ exitCode(code) {
623
+ this.fake.setJobExitCode(this.id, code);
624
+ return this;
625
+ }
626
+ }
627
+ /** Answers a sandbox's setters. */
628
+ class SandboxBuilder {
629
+ fake;
630
+ id;
631
+ constructor(fake, id) {
632
+ this.fake = fake;
633
+ this.id = id;
634
+ }
635
+ state(state) {
636
+ this.fake.setSandboxState(this.id, state);
637
+ return this;
638
+ }
639
+ }
640
+ /** Answers an execution's setters. */
641
+ class ExecutionBuilder {
642
+ fake;
643
+ sandboxId;
644
+ constructor(fake, sandboxId) {
645
+ this.fake = fake;
646
+ this.sandboxId = sandboxId;
647
+ }
648
+ stdout(stdout) {
649
+ this.fake.setExecutionStdout(this.sandboxId, stdout);
650
+ return this;
651
+ }
652
+ stderr(stderr) {
653
+ this.fake.setExecutionStderr(this.sandboxId, stderr);
654
+ return this;
655
+ }
656
+ exitCode(code) {
657
+ this.fake.setExecutionExitCode(this.sandboxId, code);
658
+ return this;
659
+ }
660
+ }
661
+ /** Turns a typed hook into the uniform answer the dispatcher runs. */
662
+ function hookAnswer(method, impl) {
663
+ switch (method.methodKind) {
664
+ case "unary": {
665
+ const run = impl;
666
+ return {
667
+ kind: "unary",
668
+ run: async (request, context) => {
669
+ const response = await run(request, context);
670
+ if (response === undefined) {
671
+ throw new ConnectError(`toshi: the hook for ${procedurePath(method)} returned no response`, Code.Internal);
672
+ }
673
+ return response;
674
+ },
675
+ };
676
+ }
677
+ case "server_streaming": {
678
+ return { kind: "server", run: impl };
679
+ }
680
+ case "bidi_streaming": {
681
+ return {
682
+ kind: "bidi",
683
+ run: impl,
684
+ };
685
+ }
686
+ case "client_streaming":
687
+ throw new Error(`toshi: ${procedurePath(method)} is a client stream; fake.on answers unary, server-stream and bidirectional methods`);
688
+ }
689
+ }
690
+ /** The answer for a procedure nothing covers: UNIMPLEMENTED, naming the hook to write. */
691
+ function missingAnswer(path) {
692
+ throw notImplemented(path);
693
+ }
694
+ /** UNIMPLEMENTED for a procedure the fake does not answer, naming the method and how to hook it. */
695
+ function notImplemented(path) {
696
+ const method = apiMethods.get(path);
697
+ const hook = method === undefined ? "fake.on" : `fake.on(${method.parent.name}.method.${method.localName})`;
698
+ return new ConnectError(`toshi: ${path} has no built-in behaviour; add a hook with ${hook}`, Code.Unimplemented);
699
+ }
700
+ /** error as a ConnectError: a handler's failure always reaches the client as one. */
701
+ function errorOf(error) {
702
+ return error instanceof ConnectError
703
+ ? error
704
+ : new ConnectError(error instanceof Error ? error.message : String(error), Code.Internal);
705
+ }
706
+ /** An internal error for a registered answer of the wrong kind; `on` refuses one at setup. */
707
+ function wrongKind(path, kind) {
708
+ return new ConnectError(`toshi: the ${kind} answer for ${path} does not fit its method`, Code.Internal);
709
+ }
710
+ /** NOT_FOUND for an id the fake does not hold. */
711
+ function notFound(kind, id) {
712
+ return new ConnectError(`toshi: no ${kind} "${id}"`, Code.NotFound);
713
+ }
714
+ /** OUT_OF_RANGE for a cursor past the last change. */
715
+ function staleCursor(position) {
716
+ return new ConnectError(`toshi: cursor ${position} is past the last change`, Code.OutOfRange);
717
+ }
718
+ /** Reads a watch's starting position; an empty cursor starts at the beginning. */
719
+ function parseCursor(cursor) {
720
+ if (cursor === "") {
721
+ return 0;
722
+ }
723
+ if (!/^\d+$/.test(cursor)) {
724
+ throw new ConnectError(`toshi: "${cursor}" is not a cursor of this workspace`, Code.OutOfRange);
725
+ }
726
+ return Number(cursor);
727
+ }
728
+ /** Waits for a change, or for the caller to abort; both resolve. */
729
+ function waitForChange(waiters, signal) {
730
+ return new Promise((resolve) => {
731
+ const wake = () => {
732
+ waiters.delete(wake);
733
+ signal.removeEventListener("abort", wake);
734
+ resolve();
735
+ };
736
+ waiters.add(wake);
737
+ signal.addEventListener("abort", wake, { once: true });
738
+ if (signal.aborted) {
739
+ wake();
740
+ }
741
+ });
742
+ }
743
+ /** A finite stream of items, without an async generator. */
744
+ function iterableOf(items) {
745
+ return {
746
+ [Symbol.asyncIterator]() {
747
+ let index = 0;
748
+ return {
749
+ next: () => {
750
+ const value = index < items.length ? items[index] : undefined;
751
+ index += 1;
752
+ return Promise.resolve(value === undefined ? { done: true, value: undefined } : { done: false, value });
753
+ },
754
+ };
755
+ },
756
+ };
757
+ }
758
+ /** A stream that ends at once with error. */
759
+ function failed(error) {
760
+ return {
761
+ [Symbol.asyncIterator]: () => ({
762
+ next: () => Promise.reject(error),
763
+ }),
764
+ };
765
+ }
766
+ /** The status of a job as the API reports it. */
767
+ function jobProto(record) {
768
+ const status = create(JobStatusSchema, { state: record.state });
769
+ if (record.state === JobState.ENDED) {
770
+ status.endReason = record.endReason;
771
+ if (record.endReason === EndReason.EXITED) {
772
+ status.exitCode = record.exitCode;
773
+ }
774
+ }
775
+ return create(JobSchema, {
776
+ id: record.id,
777
+ project: record.project,
778
+ environment: record.environment,
779
+ spec: record.spec,
780
+ labels: { ...record.labels },
781
+ recording: record.recording,
782
+ status,
783
+ });
784
+ }
785
+ /** The status of a sandbox as the API reports it. */
786
+ function sandboxProto(record) {
787
+ const status = create(SandboxStatusSchema, { state: record.state });
788
+ if (record.state === SandboxState.ENDED) {
789
+ status.endReason = record.endReason;
790
+ }
791
+ return create(SandboxSchema, {
792
+ id: record.id,
793
+ project: record.project,
794
+ environment: record.environment,
795
+ spec: record.spec,
796
+ labels: { ...record.labels },
797
+ recording: record.recording,
798
+ status,
799
+ });
800
+ }
801
+ function newJobRecord(id) {
802
+ return {
803
+ id,
804
+ project: "",
805
+ environment: "",
806
+ spec: undefined,
807
+ labels: {},
808
+ recording: RecordingPolicy.UNSPECIFIED,
809
+ state: JobState.RUNNING,
810
+ endReason: EndReason.UNSPECIFIED,
811
+ exitCode: 0,
812
+ stdout: "",
813
+ stderr: "",
814
+ created: false,
815
+ };
816
+ }
817
+ function newSandboxRecord(id) {
818
+ return {
819
+ id,
820
+ project: "",
821
+ environment: "",
822
+ spec: undefined,
823
+ labels: {},
824
+ recording: RecordingPolicy.UNSPECIFIED,
825
+ state: SandboxState.RUNNING,
826
+ endReason: EndReason.UNSPECIFIED,
827
+ created: false,
828
+ };
829
+ }
830
+ /** The part of bytes after offset. */
831
+ function fromOffset(bytes, offset) {
832
+ if (offset <= 0n) {
833
+ return bytes;
834
+ }
835
+ if (offset >= BigInt(bytes.length)) {
836
+ return new Uint8Array(0);
837
+ }
838
+ return bytes.slice(Number(offset));
839
+ }
840
+ /** UTF-8 bytes of text. */
841
+ function encode(text) {
842
+ return new TextEncoder().encode(text);
843
+ }
844
+ /** The methods the state machine does not answer, for the coverage test's comparison. */
845
+ export const hookOnlyProcedures = hookOnlyMethods;