@phpsandbox/sdk 0.1.12 → 0.1.14

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 (38) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +7 -18
  3. package/dist/browser/phpsandbox-sdk.esm.js +7 -3
  4. package/dist/browser/phpsandbox-sdk.esm.min.js +7 -7
  5. package/dist/browser/phpsandbox-sdk.iife.js +7 -3
  6. package/dist/browser/phpsandbox-sdk.iife.min.js +7 -7
  7. package/dist/index.d.ts +21 -5
  8. package/dist/index.js +5 -2
  9. package/dist/{integrations.d.ts → integrations/account.d.ts} +1 -1
  10. package/dist/integrations/index.d.ts +2 -0
  11. package/dist/integrations/index.js +2 -0
  12. package/dist/{sandbox-integrations.d.ts → integrations/sandbox.d.ts} +2 -2
  13. package/dist/{sandbox-integrations.js → integrations/sandbox.js} +1 -1
  14. package/dist/log.d.ts +1 -1
  15. package/dist/log.js +1 -1
  16. package/dist/metrics.d.ts +1 -1
  17. package/dist/metrics.js +1 -1
  18. package/dist/ports.d.ts +1 -1
  19. package/dist/ports.js +1 -1
  20. package/dist/runtime.d.ts +1 -1
  21. package/dist/runtime.js +1 -1
  22. package/package.json +5 -9
  23. package/docs/api-summary.md +0 -181
  24. package/docs/cdn-usage.md +0 -68
  25. package/docs/getting-started.md +0 -169
  26. package/examples/README.md +0 -203
  27. package/examples/basic.ts +0 -15
  28. package/examples/get.ts +0 -22
  29. package/examples/git-merge-smoke.ts +0 -395
  30. package/examples/laravel-load-test.ts +0 -2800
  31. package/examples/notebook-readonly-smoke.ts +0 -806
  32. package/examples/publication-provider-label.ts +0 -11
  33. package/examples/publish-cloudflare-container.ts +0 -65
  34. package/examples/publish-laravel-cloud.ts +0 -123
  35. package/examples/publish-ssh-agent.ts +0 -211
  36. /package/dist/{integrations.js → integrations/account.js} +0 -0
  37. /package/dist/{runtime-telemetry.d.ts → telemetry.d.ts} +0 -0
  38. /package/dist/{runtime-telemetry.js → telemetry.js} +0 -0
@@ -1,2800 +0,0 @@
1
- import fs from 'node:fs/promises';
2
- import https from 'node:https';
3
- import {
4
- type Disposable,
5
- type FileChange,
6
- type NotebookInitProgress,
7
- type NotebookInitResult,
8
- type NotebookInitSuccessResult,
9
- type NotebookPreviewSessionData,
10
- type NotebookProvisioningAction,
11
- NotebookInstance,
12
- type PortInfo,
13
- PHPSandbox,
14
- RemoteError,
15
- } from '@phpsandbox/sdk';
16
- import axios from 'axios';
17
- import NodeWebSocket from 'ws';
18
-
19
- type Visibility = 'public' | 'private' | 'unlisted';
20
- type LoadTestMode = 'full' | 'init';
21
- type StepStatus = 'passed' | 'failed' | 'skipped';
22
- type ScenarioStatus = 'passed' | 'failed';
23
- type LiveScenarioStatus = 'running' | 'passed' | 'failed';
24
- type EventTone = 'info' | 'pass' | 'fail' | 'warn';
25
-
26
- const DEFAULT_PROD_API_URL = 'https://api.phpsandbox.io/v1';
27
- const DEFAULT_LOCAL_API_URL = 'https://api.phpsandbox.test/v1';
28
- const CLEANUP_DELETE_CONCURRENCY = 8;
29
- const SECRETS_STEP_CONCURRENCY = 8;
30
- const DELETE_RETRY_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000, 12_000];
31
- const SECRETS_RETRY_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000, 12_000];
32
- const TELEMETRY_RETRY_DELAYS_MS = [1_000];
33
- const PREVIEW_RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000];
34
-
35
- class AsyncLimiter {
36
- private active = 0;
37
- private readonly queue: Array<() => void> = [];
38
-
39
- constructor(private readonly concurrency: number) {}
40
-
41
- async run<T>(action: () => Promise<T>): Promise<T> {
42
- if (this.active >= this.concurrency) {
43
- await new Promise<void>((resolve) => {
44
- this.queue.push(resolve);
45
- });
46
- }
47
-
48
- this.active += 1;
49
- try {
50
- return await action();
51
- } finally {
52
- this.active -= 1;
53
- this.queue.shift()?.();
54
- }
55
- }
56
- }
57
-
58
- interface LoadTestOptions {
59
- token: string;
60
- local: boolean;
61
- apiUrl: string;
62
- runtimeOrigin?: string;
63
- runtimeUrl?: string;
64
- template: string;
65
- mode: LoadTestMode;
66
- stopOnFailureLevel: boolean;
67
- titlePrefix: string;
68
- visibility: Visibility;
69
- levels: number[];
70
- waves: number;
71
- pauseMs: number;
72
- cleanup: boolean;
73
- keepFailed: boolean;
74
- debug: boolean;
75
- stepTimeoutMs: number;
76
- streamTimeoutMs: number;
77
- telemetryTimeoutMs: number;
78
- maxStreamChars: number;
79
- includeContainerRestart: boolean;
80
- includeDownload: boolean;
81
- includePreview: boolean;
82
- includeLsp: boolean;
83
- requireInitMessages: string[];
84
- expectProvisioned?: boolean;
85
- expectProvisioningAction?: NotebookProvisioningAction;
86
- reauthorizationUrl?: string;
87
- json: boolean;
88
- jsonFile?: string;
89
- insecureTls: boolean;
90
- caFile?: string;
91
- }
92
-
93
- interface InitTraceEvent {
94
- source: 'init' | 'provision' | 'trace';
95
- atMs: number;
96
- kind?: string;
97
- step?: string;
98
- message: string;
99
- details?: string;
100
- durationMs?: number;
101
- }
102
-
103
- interface InitTrace {
104
- connectedAtMs?: number;
105
- initializedAtMs?: number;
106
- bootErrorAtMs?: number;
107
- bootError?: string;
108
- resultType?: NotebookInitResult['type'];
109
- provisioned?: boolean;
110
- provisioningAction?: NotebookProvisioningAction | null;
111
- events: InitTraceEvent[];
112
- }
113
-
114
- interface StepResult {
115
- name: string;
116
- status: StepStatus;
117
- durationMs: number;
118
- detail?: string;
119
- error?: string;
120
- }
121
-
122
- interface ScenarioResult {
123
- id: string;
124
- level: number;
125
- wave: number;
126
- slot: number;
127
- status: ScenarioStatus;
128
- durationMs: number;
129
- notebookId?: string;
130
- previewUrl?: string;
131
- logsCaptured: number;
132
- errors: string[];
133
- steps: StepResult[];
134
- initTrace?: InitTrace;
135
- }
136
-
137
- interface StageResult {
138
- level: number;
139
- durationMs: number;
140
- scenarios: ScenarioResult[];
141
- }
142
-
143
- interface Deferred<T> {
144
- promise: Promise<T>;
145
- resolve(value: T): void;
146
- reject(reason?: unknown): void;
147
- settled: boolean;
148
- }
149
-
150
- interface ParsedArgs {
151
- get(key: string): string | boolean | undefined;
152
- }
153
-
154
- interface ScenarioStartMeta {
155
- id: string;
156
- level: number;
157
- wave: number;
158
- slot: number;
159
- }
160
-
161
- interface ScenarioLiveState extends ScenarioStartMeta {
162
- status: LiveScenarioStatus;
163
- startedAt: number;
164
- durationMs: number;
165
- notebookId?: string;
166
- currentStep?: string;
167
- lastStep?: string;
168
- completedSteps: number;
169
- logsCaptured: number;
170
- error?: string;
171
- }
172
-
173
- interface LevelLiveStat {
174
- planned: number;
175
- completed: number;
176
- passed: number;
177
- failed: number;
178
- durations: number[];
179
- }
180
-
181
- interface StepLiveStat {
182
- passed: number;
183
- failed: number;
184
- skipped: number;
185
- durations: number[];
186
- }
187
-
188
- interface UiEvent {
189
- at: number;
190
- tone: EventTone;
191
- text: string;
192
- }
193
-
194
- class LoadTestInterruptedError extends Error {
195
- public constructor(message: string = 'Interrupted by SIGINT.') {
196
- super(message);
197
- this.name = 'LoadTestInterruptedError';
198
- }
199
- }
200
-
201
- class InterruptController {
202
- private readonly abortController = new AbortController();
203
-
204
- public request(): boolean {
205
- if (this.abortController.signal.aborted) {
206
- return false;
207
- }
208
-
209
- this.abortController.abort(new LoadTestInterruptedError());
210
- return true;
211
- }
212
-
213
- public get signal(): AbortSignal {
214
- return this.abortController.signal;
215
- }
216
-
217
- public get interrupted(): boolean {
218
- return this.abortController.signal.aborted;
219
- }
220
-
221
- public throwIfInterrupted(): void {
222
- if (this.abortController.signal.aborted) {
223
- const reason = this.abortController.signal.reason;
224
- throw reason instanceof Error ? reason : new LoadTestInterruptedError();
225
- }
226
- }
227
- }
228
-
229
- const ansi = {
230
- reset: '\x1b[0m',
231
- bold: '\x1b[1m',
232
- dim: '\x1b[2m',
233
- red: '\x1b[31m',
234
- green: '\x1b[32m',
235
- yellow: '\x1b[33m',
236
- blue: '\x1b[34m',
237
- magenta: '\x1b[35m',
238
- cyan: '\x1b[36m',
239
- gray: '\x1b[90m',
240
- hideCursor: '\x1b[?25l',
241
- showCursor: '\x1b[?25h',
242
- clearDown: '\x1b[0J',
243
- };
244
-
245
- const textDecoder = new TextDecoder();
246
- const cleanupDeleteLimiter = new AsyncLimiter(CLEANUP_DELETE_CONCURRENCY);
247
- const secretsStepLimiter = new AsyncLimiter(SECRETS_STEP_CONCURRENCY);
248
- const scenarioAuthor = 'PHPSandbox Load Test <load-test@phpsandbox.local>';
249
-
250
- let scenarioSequence = 0;
251
-
252
- async function main(): Promise<void> {
253
- const options = parseOptions(process.argv.slice(2));
254
- const transport = await createNodeTransport(options);
255
- const ui = new LoadTestUi(options);
256
- const interrupts = new InterruptController();
257
- const client = PHPSandbox.realtime(options.token, options.apiUrl, {
258
- debug: options.debug,
259
- fetch: transport.fetch,
260
- webSocket: transport.webSocket,
261
- });
262
- const removeSignalHandler = installSignalHandler(ui, interrupts);
263
-
264
- ui.start();
265
-
266
- try {
267
- const stages: StageResult[] = [];
268
- for (const level of options.levels) {
269
- if (interrupts.interrupted) {
270
- ui.note('Interrupt requested. No further levels will be launched.', 'warn');
271
- break;
272
- }
273
-
274
- const stage = await runStage(client, options, level, ui, interrupts);
275
- if (stage.scenarios.length > 0) {
276
- stages.push(stage);
277
- }
278
-
279
- if (options.stopOnFailureLevel && stage.scenarios.some((scenario) => scenario.status === 'failed')) {
280
- ui.note(`Stopping after level ${level} because at least one scenario failed.`, 'warn');
281
- break;
282
- }
283
-
284
- if (interrupts.interrupted) {
285
- ui.note(`Interrupt requested during level ${level}. Skipping remaining levels.`, 'warn');
286
- break;
287
- }
288
-
289
- if (options.pauseMs > 0 && level !== options.levels[options.levels.length - 1]) {
290
- ui.note(`Pausing for ${options.pauseMs}ms before the next level.`);
291
- try {
292
- await sleep(options.pauseMs, interrupts.signal);
293
- } catch (error) {
294
- if (isInterruptedError(error)) {
295
- ui.note('Interrupt requested during the inter-level pause.', 'warn');
296
- break;
297
- }
298
-
299
- throw error;
300
- }
301
- }
302
- }
303
-
304
- const summary = summarizeStages(stages);
305
- const payload = sanitizeArtifact({ options, stages, summary });
306
- ui.stop();
307
- if (interrupts.interrupted) {
308
- console.log('Load test interrupted by SIGINT. Partial summary follows.');
309
- }
310
- console.log(renderSummary(stages, summary));
311
-
312
- if (options.jsonFile) {
313
- await fs.mkdir(new URL('.', new URL(`file://${options.jsonFile}`)), { recursive: true }).catch(() => undefined);
314
- await fs.writeFile(options.jsonFile, JSON.stringify(payload, null, 2));
315
- }
316
-
317
- if (options.json) {
318
- console.log(JSON.stringify(payload, null, 2));
319
- }
320
-
321
- process.exitCode = interrupts.interrupted ? 130 : summary.failedScenarios > 0 ? 1 : 0;
322
- } finally {
323
- removeSignalHandler();
324
- ui.stop();
325
- }
326
- }
327
-
328
- async function runStage(
329
- client: PHPSandbox,
330
- options: LoadTestOptions,
331
- level: number,
332
- ui: LoadTestUi,
333
- interrupts: InterruptController
334
- ): Promise<StageResult> {
335
- ui.onStageStart(level);
336
- const stageStartedAt = performance.now();
337
- const scenarios: ScenarioResult[] = [];
338
-
339
- for (let wave = 1; wave <= options.waves; wave += 1) {
340
- if (interrupts.interrupted) {
341
- ui.note(`Level ${level}: stopping before wave ${wave} due to SIGINT.`, 'warn');
342
- break;
343
- }
344
-
345
- ui.onWaveStart(level, wave, level);
346
-
347
- const waveResults = await Promise.all(
348
- Array.from({ length: level }, (_, slot) => runScenario(client, options, level, wave, slot + 1, ui, interrupts))
349
- );
350
-
351
- scenarios.push(...waveResults);
352
- const succeeded = waveResults.filter((scenario) => scenario.status === 'passed').length;
353
- const failed = waveResults.length - succeeded;
354
- ui.note(
355
- `Level ${level}, wave ${wave}: ${succeeded}/${waveResults.length} passed, ${failed}/${waveResults.length} failed.`,
356
- failed > 0 ? 'warn' : 'pass'
357
- );
358
-
359
- if (interrupts.interrupted) {
360
- ui.note(`Level ${level}: interrupt requested. No further waves will be launched.`, 'warn');
361
- break;
362
- }
363
- }
364
-
365
- return {
366
- level,
367
- durationMs: performance.now() - stageStartedAt,
368
- scenarios,
369
- };
370
- }
371
-
372
- async function runScenario(
373
- client: PHPSandbox,
374
- options: LoadTestOptions,
375
- level: number,
376
- wave: number,
377
- slot: number,
378
- ui: LoadTestUi,
379
- interrupts: InterruptController
380
- ): Promise<ScenarioResult> {
381
- const id = `l${level}-w${wave}-s${slot}-n${++scenarioSequence}`;
382
- ui.onScenarioStart({ id, level, wave, slot });
383
- const startedAt = performance.now();
384
- const steps: StepResult[] = [];
385
- const errors: string[] = [];
386
-
387
- let notebook: NotebookInstance | null = null;
388
- let previewUrl: string | undefined;
389
- let logsCaptured = 0;
390
- let logReader: ReadableStreamDefaultReader<import('../src/log.js').RuntimeLogEntry> | null = null;
391
- let initTrace: InitTrace | undefined;
392
- let readyResult: NotebookInitSuccessResult | undefined;
393
- let initTraceDisposables: Disposable[] = [];
394
- let redisRunning = false;
395
-
396
- const routeSlug = id.toLowerCase();
397
- const routePath = 'routes/web.php';
398
- const routeFileName = routePath.split('/').pop() as string;
399
- const routeUrlPath = `load-test-${routeSlug}`;
400
- const secretName = `LOAD_TEST_${routeSlug.toUpperCase().replace(/[^A-Z0-9]/g, '_')}`;
401
- const gitMessage = `load test ${id}`;
402
-
403
- const noteFailure = (message: string) => {
404
- errors.push(message);
405
- };
406
-
407
- const runCheckedStep = async <T>(
408
- name: string,
409
- action: () => Promise<T>,
410
- optionsOverride: { continueOnError?: boolean; timeoutMs?: number; skipIf?: (error: unknown) => string | undefined } = {}
411
- ): Promise<T | undefined> => {
412
- interrupts.throwIfInterrupted();
413
- const continueOnError = optionsOverride.continueOnError ?? true;
414
- const timeoutMs = optionsOverride.timeoutMs ?? options.stepTimeoutMs;
415
- const startedAt = performance.now();
416
- ui.onStepStart(id, name);
417
-
418
- try {
419
- const value = await withTimeout(action(), timeoutMs, `${name} timed out after ${timeoutMs}ms`);
420
- const durationMs = performance.now() - startedAt;
421
- steps.push({
422
- name,
423
- status: 'passed',
424
- durationMs,
425
- detail: trimDetail(describeValue(value)),
426
- });
427
- ui.onStepResult(id, name, 'passed', durationMs);
428
-
429
- return value;
430
- } catch (error) {
431
- const skipDetail = optionsOverride.skipIf?.(error);
432
- if (skipDetail) {
433
- const durationMs = performance.now() - startedAt;
434
- steps.push({
435
- name,
436
- status: 'skipped',
437
- durationMs,
438
- detail: skipDetail,
439
- });
440
- ui.onStepResult(id, name, 'skipped', durationMs, skipDetail);
441
-
442
- return undefined;
443
- }
444
-
445
- const durationMs = performance.now() - startedAt;
446
- steps.push({
447
- name,
448
- status: 'failed',
449
- durationMs,
450
- error: formatError(error),
451
- });
452
- ui.onStepResult(id, name, 'failed', durationMs, formatError(error));
453
-
454
- noteFailure(`${name}: ${formatError(error)}`);
455
- if (!continueOnError) {
456
- throw error;
457
- }
458
-
459
- return undefined;
460
- }
461
- };
462
-
463
- try {
464
- notebook = await runCheckedStep(
465
- 'notebook.create',
466
- async () => {
467
- const created = await client.notebook.create(
468
- options.template,
469
- {
470
- title: `${options.titlePrefix} ${id}`,
471
- visibility: options.visibility,
472
- }
473
- );
474
-
475
- const data = {
476
- ...created.data,
477
- runtimeUrl: resolveRuntimeUrl(created.data.runtimeUrl, options),
478
- };
479
- created.dispose();
480
-
481
- return client.notebook.open(data);
482
- },
483
- { continueOnError: false }
484
- );
485
-
486
- if (!notebook) {
487
- throw new Error('Notebook creation returned no instance.');
488
- }
489
-
490
- ui.attachNotebook(id, notebook.data.id);
491
- initTrace = { events: [] };
492
- const initTraceOrigin = performance.now();
493
- initTraceDisposables = [
494
- notebook.onDidConnect(() => {
495
- initTrace!.connectedAtMs = performance.now() - initTraceOrigin;
496
- }),
497
- notebook.onDidInitialize((result) => {
498
- initTrace!.initializedAtMs = performance.now() - initTraceOrigin;
499
- initTrace!.resultType = result.type;
500
- if (result.type === 'success') {
501
- initTrace!.provisioned = result.data.provisioned;
502
- initTrace!.provisioningAction = result.data.provisioningAction;
503
- }
504
- }),
505
- notebook.onDidBootError((error) => {
506
- initTrace!.bootErrorAtMs = performance.now() - initTraceOrigin;
507
- initTrace!.bootError = formatError(error);
508
- }),
509
- notebook.listen('init.event', (event: NotebookInitProgress) => {
510
- initTrace!.events.push({
511
- source: 'init',
512
- atMs: performance.now() - initTraceOrigin,
513
- kind: event.kind,
514
- step: event.step,
515
- message: event.message,
516
- details: event.details ?? event.line ?? event.chunk ?? event.command,
517
- });
518
- }),
519
- notebook.listen(`Notebook.Provision.Status.${notebook.data.id}` as never, (event: any) => {
520
- const progress = event as NotebookInitProgress;
521
- initTrace!.events.push({
522
- source: 'provision',
523
- atMs: performance.now() - initTraceOrigin,
524
- kind: progress.kind,
525
- step: progress.step,
526
- message: progress.message,
527
- details: progress.details ?? progress.line ?? progress.chunk ?? progress.command,
528
- });
529
- }),
530
- ];
531
-
532
- await runCheckedStep(
533
- 'notebook.ready',
534
- async () => {
535
- const ready = await notebook!.ready();
536
- readyResult = ready;
537
- previewUrl = ready.data.previewUrl;
538
- assertValidUrl(ready.data.previewUrl, 'notebook.ready previewUrl');
539
- return `previewUrl=${redactUrl(ready.data.previewUrl)} ${describePorts(assertPortInfoList(ready.data.ports, 'notebook.ready ports'))}`;
540
- },
541
- { continueOnError: false, timeoutMs: options.stepTimeoutMs * 2 }
542
- );
543
-
544
- await runCheckedStep(
545
- 'notebook.init-trace',
546
- async () => validateInitTrace(initTrace, readyResult, options),
547
- { continueOnError: false }
548
- );
549
-
550
- if (options.mode === 'init') {
551
- if (options.includePreview) {
552
- await runCheckedStep('preview.fetch', async () => fetchDefaultPreview(options, notebook!, previewUrl));
553
- } else {
554
- recordSkippedStep(steps, 'preview.fetch', 'Enable with --include-preview.');
555
- }
556
- }
557
-
558
- if (options.mode !== 'init') {
559
- logReader = notebook.runtime.logs.follow().getReader();
560
- void (async () => {
561
- while (logReader) {
562
- const entry = await logReader.read();
563
- if (entry.done) {
564
- return;
565
- }
566
- if (entry.value.message.trim().length > 0) {
567
- logsCaptured += 1;
568
- ui.updateScenarioLogs(id, logsCaptured);
569
- }
570
- }
571
- })().catch(() => {});
572
-
573
- await runCheckedStep('notebook.secrets', async () => {
574
- return secretsStepLimiter.run(async () => {
575
- await retryWithBackoff(
576
- () => notebook!.secrets.set(secretName, `value-${id}`),
577
- SECRETS_RETRY_DELAYS_MS,
578
- shouldRetryRemoteRateLimitError
579
- );
580
-
581
- const secrets = await retryWithBackoff(
582
- () => notebook!.secrets.list(),
583
- SECRETS_RETRY_DELAYS_MS,
584
- shouldRetryRemoteRateLimitError
585
- );
586
-
587
- const found = secrets.find((secret) => secret.name === secretName);
588
- if (!found) {
589
- throw new Error(`Secret ${secretName} was not returned by secrets.list().`);
590
- }
591
-
592
- await retryWithBackoff(
593
- () => notebook!.secrets.delete(secretName),
594
- SECRETS_RETRY_DELAYS_MS,
595
- shouldRetryRemoteRateLimitError
596
- );
597
-
598
- return secrets.length;
599
- });
600
- });
601
-
602
- await runCheckedStep('container.ports', async () => {
603
- const ports = await notebook!.runtime.ports.list();
604
- return describePorts(assertPortInfoList(ports, 'ports.list'));
605
- });
606
-
607
- if (options.includeContainerRestart) {
608
- await runCheckedStep(
609
- 'container.restart',
610
- async () => {
611
- await notebook!.restart();
612
- const ports = await notebook!.runtime.ports.list();
613
- return describePorts(assertPortInfoList(ports, 'ports.list after restart'));
614
- },
615
- { timeoutMs: options.stepTimeoutMs * 3, continueOnError: false }
616
- );
617
- } else {
618
- recordSkippedStep(steps, 'container.restart', 'Enable with --include-container-restart.');
619
- }
620
-
621
- await runCheckedStep(
622
- 'container.telemetry',
623
- async () =>
624
- retryWithBackoff(
625
- async () => {
626
- const statsReader = notebook!.runtime.metrics.watch().getReader();
627
- const portsReader = notebook!.runtime.ports.watch().getReader();
628
- try {
629
- const statsPromise = withTimeout(statsReader.read(), options.telemetryTimeoutMs, 'metrics.watch')
630
- .then(({ value }) => {
631
- if (!value) throw new Error('metrics.watch ended before a sample was received.');
632
- return `cpu=${value.cpu.usage}/${value.cpu.limit} memory=${value.memory.usage}/${value.memory.limit}`;
633
- });
634
- const portsPromise = withTimeout(portsReader.read(), options.telemetryTimeoutMs, 'ports.watch')
635
- .then(({ value }) => describePorts(assertPortInfoList(value ?? [], 'ports.watch')));
636
- return await firstFulfilled([statsPromise, portsPromise]);
637
- } finally {
638
- await Promise.all([statsReader.cancel(), portsReader.cancel()]);
639
- }
640
- },
641
- TELEMETRY_RETRY_DELAYS_MS,
642
- shouldRetryTelemetryError
643
- ),
644
- { timeoutMs: options.telemetryTimeoutMs + 5_000 }
645
- );
646
-
647
- await runCheckedStep(
648
- 'filesystem.watch-and-write',
649
- async () => {
650
- const changeSeen = createDeferred<FileChange>();
651
- const watch = await notebook!.files.watch(
652
- 'routes',
653
- { recursive: true, excludes: ['vendor/**', 'node_modules/**'] },
654
- (change) => {
655
- if (!changeSeen.settled && change.path.endsWith(routeFileName)) {
656
- changeSeen.resolve(change);
657
- }
658
- }
659
- );
660
-
661
- try {
662
- const current = textDecoder.decode(await notebook!.files.read(routePath));
663
- const next = appendRouteDefinition(current, id, routeUrlPath);
664
- await notebook!.files.write(routePath, next);
665
-
666
- const change = await withTimeout(
667
- changeSeen.promise,
668
- options.stepTimeoutMs,
669
- `waiting for a filesystem event for ${routePath}`
670
- );
671
-
672
- return `${change.path} (${change.type})`;
673
- } finally {
674
- watch.dispose();
675
- }
676
- }
677
- );
678
-
679
- await runCheckedStep('filesystem.inspect', async () => {
680
- const [raw, stat, directory, tree, found, tail] = await Promise.all([
681
- notebook!.files.read(routePath),
682
- notebook!.files.stat(routePath),
683
- notebook!.files.list('routes'),
684
- notebook!.files.tree('routes'),
685
- notebook!.files.find(routeFileName, { includes: ['routes/**'], maxResults: 10 }),
686
- notebook!.files.tail(routePath, { lines: 20 }),
687
- ]);
688
-
689
- const text = textDecoder.decode(raw);
690
- if (!text.includes(routeUrlPath)) {
691
- throw new Error(`Expected ${routeUrlPath} in ${routePath}.`);
692
- }
693
-
694
- const [, matches] = await notebook!.files.search(
695
- { pattern: routeUrlPath },
696
- { includes: [routePath], excludes: [], maxResults: 10 }
697
- );
698
-
699
- if (matches.length === 0) {
700
- throw new Error(`file.search() did not find ${routeUrlPath}.`);
701
- }
702
-
703
- const exists = await notebook!.files.exists(routePath);
704
- if (!exists) {
705
- throw new Error(`${routePath} does not exist after write.`);
706
- }
707
-
708
- return `size=${stat.size} dir=${directory.length} tree-lines=${tree.split('\n').length} find=${found.length} tail=${tail.length}`;
709
- });
710
-
711
- if (options.includePreview) {
712
- await runCheckedStep('preview.fetch', async () => fetchScenarioPreview(options, notebook!, previewUrl, routeUrlPath, id));
713
- } else {
714
- recordSkippedStep(steps, 'preview.fetch', 'Enable with --include-preview.');
715
- }
716
-
717
- if (options.includeDownload) {
718
- await runCheckedStep('filesystem.download', async () => {
719
- const archive = await notebook!.files.download({
720
- exclude: ['vendor/**', 'node_modules/**'],
721
- include: [routePath, 'composer.json'],
722
- });
723
- if (archive.size === 0) {
724
- throw new Error('file.download() returned an empty blob.');
725
- }
726
-
727
- return `${archive.size} bytes`;
728
- });
729
- } else {
730
- recordSkippedStep(steps, 'filesystem.download', 'Enable with --include-download.');
731
- }
732
-
733
- await runCheckedStep('shell.run', async () => {
734
- const result = await notebook!.run(['php', 'artisan', '--version']).wait();
735
- if (result.exitCode !== 0) {
736
- throw new Error(result.stderr || result.output);
737
- }
738
- if (!result.output.toLowerCase().includes('laravel')) {
739
- throw new Error(`Unexpected artisan version output: ${trimDetail(result.output)}`);
740
- }
741
-
742
- return trimDetail(result.output);
743
- }, { skipIf: explainUnsupportedShell });
744
-
745
- await runCheckedStep(
746
- 'shell.run-stream',
747
- async () => {
748
- const execution = notebook!.run(
749
- ['php', 'artisan', 'route:list', `--path=${routeUrlPath}`],
750
- { tty: false },
751
- );
752
-
753
- const [streamOutput, result] = await Promise.all([
754
- readStreamToString(execution.stdout, options.streamTimeoutMs, options.maxStreamChars).catch((error) => {
755
- if (isStreamInactivityError(error)) {
756
- return '';
757
- }
758
-
759
- throw error;
760
- }),
761
- withTimeout(execution.wait(), options.stepTimeoutMs, 'waiting for process exit'),
762
- ]);
763
-
764
- if (result.exitCode !== 0) {
765
- throw new Error(result.stderr || result.output);
766
- }
767
- const combined = [streamOutput, result.output].filter(Boolean).join('\n');
768
- if (!combined.includes(routeUrlPath)) {
769
- throw new Error(`route:list output did not contain ${routeUrlPath}.`);
770
- }
771
-
772
- return `${combined.length} chars`;
773
- },
774
- { timeoutMs: options.stepTimeoutMs * 2, skipIf: explainUnsupportedShell }
775
- );
776
-
777
- await runCheckedStep(
778
- 'terminals.create',
779
- async () => {
780
- const process = await notebook!.terminals.create({
781
- command: 'php',
782
- args: ['artisan', 'route:list', `--path=${routeUrlPath}`],
783
- });
784
-
785
- try {
786
- const outputPromise = readStreamToString(process.output, options.streamTimeoutMs, options.maxStreamChars);
787
- const exitPromise = withTimeout(process.wait(), options.stepTimeoutMs, 'waiting for terminal process to exit');
788
- const [output, exitCode] = await Promise.all([outputPromise, exitPromise]);
789
-
790
- if (exitCode !== 0) {
791
- throw new Error(`terminals.create() exited with ${exitCode}: ${trimDetail(output)}`);
792
- }
793
-
794
- if (!output.includes(routeUrlPath)) {
795
- throw new Error(`terminal output did not contain ${routeUrlPath}.`);
796
- }
797
-
798
- return `${output.length} chars`;
799
- } finally {
800
- await process.kill().catch(() => false);
801
- }
802
- },
803
- { timeoutMs: options.stepTimeoutMs * 2, skipIf: explainUnsupportedShell }
804
- );
805
-
806
- await runCheckedStep(
807
- 'services.redis',
808
- async () => {
809
- await notebook!.services.list();
810
- const started = await notebook!.services.run('redis');
811
- redisRunning = started.running || started.state === 'running' || started.state === 'starting';
812
-
813
- const stream = notebook!.services.logs('redis', { follow: true, tail: 25 });
814
- const reader = stream.getReader();
815
-
816
- try {
817
- const [snapshot, chunk] = await Promise.all([
818
- sleep(1_500).then(() => notebook!.services.logs('redis', { tail: 25 })),
819
- readNextChunk(reader, options.streamTimeoutMs),
820
- ]);
821
-
822
- const combined = [snapshot, chunk].filter(Boolean).join('\n').trim();
823
- if (combined.length === 0) {
824
- throw new Error('Did not capture redis logs.');
825
- }
826
-
827
- return `${combined.length} chars`;
828
- } finally {
829
- await reader.cancel().catch(() => undefined);
830
- reader.releaseLock();
831
- await notebook!.services.stop('redis').catch(() => started);
832
- redisRunning = false;
833
- }
834
- },
835
- { timeoutMs: options.stepTimeoutMs * 2 }
836
- );
837
-
838
- await runCheckedStep('composer.metadata', async () => {
839
- let composerLogChunks = 0;
840
- const dump = notebook!.composer.run('dump-autoload');
841
- for await (const chunk of dump.output) {
842
- if (chunk.data.byteLength > 0) {
843
- composerLogChunks += 1;
844
- }
845
- }
846
-
847
- const result = await dump.wait();
848
- if (result.exitCode !== 0) {
849
- throw new Error(result.stderr || result.output);
850
- }
851
- const packages = await notebook!.composer.packages();
852
- if (packages.length === 0) {
853
- throw new Error('composer.packages() returned no packages.');
854
- }
855
-
856
- return `packages=${packages.length} composer-log-events=${composerLogChunks}`;
857
- }, { timeoutMs: Math.max(options.stepTimeoutMs * 2, 120_000) });
858
-
859
- await runCheckedStep('git.checkpoint', async () => {
860
- const status = await notebook!.git.status();
861
- if (!status.initialized) {
862
- throw new Error('Git repository is not initialized in this notebook.');
863
- }
864
-
865
- const branch = status.branch || 'main';
866
- const checkpoint = await notebook!.git.checkpoint(scenarioAuthor, gitMessage, branch);
867
- const history = await notebook!.git.log(branch);
868
- if (!history.some((entry) => entry.message === gitMessage)) {
869
- throw new Error(`git.log(${branch}) did not contain the checkpoint message.`);
870
- }
871
-
872
- return `${branch} -> ${checkpoint.ref}`;
873
- }, { skipIf: explainUnavailableGit });
874
-
875
- await runCheckedStep('repl.eval', async () => {
876
- let replOutputEvents = 0;
877
- const replDisposable = notebook!.listen('repl.output', () => {
878
- replOutputEvents += 1;
879
- });
880
-
881
- try {
882
- const result = await notebook!.repl.eval(`echo '${routeUrlPath}';`);
883
- if (result.exitCode !== 0) {
884
- throw new Error(`repl.eval() exited with ${result.exitCode}.`);
885
- }
886
-
887
- return `exit=${result.exitCode} output-events=${replOutputEvents}`;
888
- } finally {
889
- replDisposable.dispose();
890
- }
891
- });
892
-
893
- if (options.includeLsp) {
894
- await runCheckedStep(
895
- 'lsp.initialize',
896
- async () => {
897
- const lspId = `load-test-${routeSlug}`;
898
- const responsePromise = waitForNextEvent<unknown>(
899
- (handler) => notebook!.listen(`lsp.response.${lspId}` as never, handler as never),
900
- options.streamTimeoutMs,
901
- `lsp.response.${lspId}`
902
- );
903
-
904
- await notebook!.lsp.start(lspId);
905
- await notebook!.lsp.message(
906
- lspId,
907
- encodeLspPayload({
908
- jsonrpc: '2.0',
909
- id: 1,
910
- method: 'initialize',
911
- params: {
912
- processId: null,
913
- rootUri: 'file:///app',
914
- capabilities: {},
915
- workspaceFolders: [{ uri: 'file:///app', name: 'app' }],
916
- },
917
- })
918
- );
919
-
920
- const response = await responsePromise;
921
- await notebook!.lsp.close(lspId);
922
- return trimDetail(JSON.stringify(response));
923
- },
924
- {
925
- timeoutMs: options.streamTimeoutMs + 5_000,
926
- skipIf: (error) => explainMissingAction(error, 'lsp.start'),
927
- }
928
- );
929
- } else {
930
- recordSkippedStep(steps, 'lsp.initialize', 'Enable with --include-lsp.');
931
- }
932
-
933
- if (options.reauthorizationUrl) {
934
- await runCheckedStep('auth.login-logout', async () => {
935
- await notebook!.auth.login(options.reauthorizationUrl!);
936
- notebook!.dispose();
937
- return 'ok';
938
- });
939
- } else {
940
- recordSkippedStep(steps, 'auth.login-logout', 'Provide --reauthorization-url to exercise auth.');
941
- }
942
- }
943
- } catch (error) {
944
- noteFailure(formatError(error));
945
- } finally {
946
- if (notebook) {
947
- if (redisRunning) {
948
- await notebook.services.stop('redis').catch(() => undefined);
949
- }
950
-
951
- await logReader?.cancel().catch(() => undefined);
952
- logReader = null;
953
- for (const disposable of initTraceDisposables) {
954
- disposable.dispose();
955
- }
956
-
957
- const shouldDelete = options.cleanup && (!options.keepFailed || errors.length === 0);
958
- if (shouldDelete) {
959
- try {
960
- await cleanupNotebook(notebook, steps);
961
- } catch (error) {
962
- noteFailure(`notebook.delete: ${formatError(error)}`);
963
- }
964
- } else {
965
- recordSkippedStep(
966
- steps,
967
- 'notebook.delete',
968
- options.cleanup ? 'Notebook kept because the scenario failed.' : 'Cleanup disabled.'
969
- );
970
- notebook.dispose();
971
- }
972
- }
973
- }
974
-
975
- const status: ScenarioStatus = errors.length === 0 ? 'passed' : 'failed';
976
- const durationMs = performance.now() - startedAt;
977
- const result = {
978
- id,
979
- level,
980
- wave,
981
- slot,
982
- status,
983
- durationMs,
984
- notebookId: notebook?.data.id,
985
- previewUrl,
986
- logsCaptured,
987
- errors,
988
- steps,
989
- initTrace,
990
- };
991
-
992
- ui.onScenarioFinish(result);
993
-
994
- return result;
995
- }
996
-
997
- async function cleanupNotebook(notebook: NotebookInstance, steps: StepResult[]): Promise<void> {
998
- try {
999
- await recordStep(steps, 'notebook.delete', async () => {
1000
- const notebookId = notebook.data.id;
1001
- await cleanupDeleteLimiter.run(async () => {
1002
- try {
1003
- await retryWithBackoff(() => notebook.destroy(), DELETE_RETRY_DELAYS_MS, shouldRetryRemoteRateLimitError);
1004
- } catch (error) {
1005
- if (formatError(error).includes('Notebook has been deleted')) {
1006
- return;
1007
- }
1008
-
1009
- throw error;
1010
- }
1011
- });
1012
- notebook.dispose();
1013
- return notebookId;
1014
- }, 180_000);
1015
- } catch (error) {
1016
- notebook.dispose();
1017
- throw error;
1018
- }
1019
- }
1020
-
1021
- function summarizeStages(stages: StageResult[]) {
1022
- const scenarios = stages.flatMap((stage) => stage.scenarios);
1023
- const passedScenarios = scenarios.filter((scenario) => scenario.status === 'passed').length;
1024
- const failedScenarios = scenarios.length - passedScenarios;
1025
- const stepStats = new Map<string, { passed: number; failed: number; skipped: number; durations: number[] }>();
1026
-
1027
- for (const scenario of scenarios) {
1028
- for (const step of scenario.steps) {
1029
- const current = stepStats.get(step.name) ?? { passed: 0, failed: 0, skipped: 0, durations: [] };
1030
- current[step.status] += 1;
1031
- if (step.status === 'passed') {
1032
- current.durations.push(step.durationMs);
1033
- }
1034
- stepStats.set(step.name, current);
1035
- }
1036
- }
1037
-
1038
- return {
1039
- scenarios: scenarios.length,
1040
- passedScenarios,
1041
- failedScenarios,
1042
- stepStats,
1043
- };
1044
- }
1045
-
1046
- function renderConfiguration(options: LoadTestOptions): string {
1047
- const lines = [
1048
- 'Laravel sandbox load test configuration:',
1049
- ` target: ${options.local ? 'local' : 'prod'}`,
1050
- ` template: ${options.template}`,
1051
- ` mode: ${options.mode}`,
1052
- ` stop on failing level: ${options.stopOnFailureLevel}`,
1053
- ` api url: ${options.apiUrl}`,
1054
- options.runtimeOrigin ? ` runtime origin override: ${options.runtimeOrigin}` : undefined,
1055
- options.runtimeUrl ? ` runtime url override: ${redactUrl(options.runtimeUrl)}` : undefined,
1056
- ` levels: ${options.levels.join(', ')}`,
1057
- ` waves per level: ${options.waves}`,
1058
- ` visibility: ${options.visibility}`,
1059
- ` cleanup: ${options.cleanup} (keep failed: ${options.keepFailed})`,
1060
- ` include container restart: ${options.includeContainerRestart}`,
1061
- ` include download: ${options.includeDownload}`,
1062
- ` include preview: ${options.includePreview}`,
1063
- ` include lsp: ${options.includeLsp}`,
1064
- ` required init messages: ${options.requireInitMessages.length > 0 ? options.requireInitMessages.join(' -> ') : 'none'}`,
1065
- ` expect provisioned: ${options.expectProvisioned ?? 'not asserted'}`,
1066
- ` expect provisioning action: ${options.expectProvisioningAction ?? 'not asserted'}`,
1067
- ` include auth: ${Boolean(options.reauthorizationUrl)}`,
1068
- ` insecure tls: ${options.insecureTls}`,
1069
- ` ca file: ${options.caFile ?? 'none'}`,
1070
- ];
1071
-
1072
- return lines.filter(Boolean).join('\n');
1073
- }
1074
-
1075
- class LoadTestUi {
1076
- private readonly interactive = Boolean(process.stdout.isTTY);
1077
- private readonly useColor = this.interactive && process.env.NO_COLOR === undefined;
1078
- private readonly startedAt = performance.now();
1079
- private readonly totalPlanned: number;
1080
- private readonly levelStats = new Map<number, LevelLiveStat>();
1081
- private readonly stepStats = new Map<string, StepLiveStat>();
1082
- private readonly scenarios = new Map<string, ScenarioLiveState>();
1083
- private readonly recentEvents: UiEvent[] = [];
1084
- private currentLevel?: number;
1085
- private currentWave?: number;
1086
- private currentConcurrency?: number;
1087
- private frameLines = 0;
1088
- private started = false;
1089
- private stopped = false;
1090
- private dirty = false;
1091
- private renderTimer?: ReturnType<typeof setInterval>;
1092
-
1093
- public constructor(private readonly options: LoadTestOptions) {
1094
- this.totalPlanned = options.levels.reduce((sum, level) => sum + level * options.waves, 0);
1095
- for (const level of options.levels) {
1096
- this.levelStats.set(level, {
1097
- planned: level * options.waves,
1098
- completed: 0,
1099
- passed: 0,
1100
- failed: 0,
1101
- durations: [],
1102
- });
1103
- }
1104
- }
1105
-
1106
- public start(): void {
1107
- if (this.started) {
1108
- return;
1109
- }
1110
-
1111
- this.started = true;
1112
- if (!this.interactive) {
1113
- console.log(renderConfiguration(this.options));
1114
- return;
1115
- }
1116
-
1117
- process.stdout.write(ansi.hideCursor);
1118
- this.dirty = true;
1119
- this.render(true);
1120
- this.renderTimer = setInterval(() => this.flushRender(), 200);
1121
- this.renderTimer.unref?.();
1122
- }
1123
-
1124
- public stop(): void {
1125
- if (!this.started || this.stopped) {
1126
- return;
1127
- }
1128
-
1129
- this.stopped = true;
1130
- if (!this.interactive) {
1131
- return;
1132
- }
1133
-
1134
- if (this.renderTimer) {
1135
- clearInterval(this.renderTimer);
1136
- this.renderTimer = undefined;
1137
- }
1138
-
1139
- this.render(true);
1140
- process.stdout.write(`\n${ansi.showCursor}`);
1141
- }
1142
-
1143
- public runningScenarioCount(): number {
1144
- let running = 0;
1145
- for (const scenario of this.scenarios.values()) {
1146
- if (scenario.status === 'running') {
1147
- running += 1;
1148
- }
1149
- }
1150
-
1151
- return running;
1152
- }
1153
-
1154
- public note(message: string, tone: EventTone = 'info'): void {
1155
- this.pushEvent(tone, message);
1156
- if (!this.interactive) {
1157
- console.log(message);
1158
- return;
1159
- }
1160
-
1161
- this.markDirty();
1162
- }
1163
-
1164
- public onStageStart(level: number): void {
1165
- this.currentLevel = level;
1166
- this.currentWave = undefined;
1167
- this.currentConcurrency = undefined;
1168
- this.note(`=== Starting level ${level} (${this.options.waves} wave${this.options.waves === 1 ? '' : 's'}) ===`);
1169
- }
1170
-
1171
- public onWaveStart(level: number, wave: number, concurrency: number): void {
1172
- this.currentLevel = level;
1173
- this.currentWave = wave;
1174
- this.currentConcurrency = concurrency;
1175
- this.note(`Level ${level}, wave ${wave}: launching ${concurrency} scenario(s).`);
1176
- }
1177
-
1178
- public onScenarioStart(meta: ScenarioStartMeta): void {
1179
- this.scenarios.set(meta.id, {
1180
- ...meta,
1181
- status: 'running',
1182
- startedAt: performance.now(),
1183
- durationMs: 0,
1184
- completedSteps: 0,
1185
- logsCaptured: 0,
1186
- });
1187
- this.markDirty();
1188
- }
1189
-
1190
- public attachNotebook(id: string, notebookId: string): void {
1191
- const state = this.scenarios.get(id);
1192
- if (!state) {
1193
- return;
1194
- }
1195
-
1196
- state.notebookId = notebookId;
1197
- this.markDirty();
1198
- }
1199
-
1200
- public updateScenarioLogs(id: string, logsCaptured: number): void {
1201
- const state = this.scenarios.get(id);
1202
- if (!state) {
1203
- return;
1204
- }
1205
-
1206
- state.logsCaptured = logsCaptured;
1207
- this.markDirty();
1208
- }
1209
-
1210
- public onStepStart(id: string, stepName: string): void {
1211
- const state = this.scenarios.get(id);
1212
- if (!state) {
1213
- return;
1214
- }
1215
-
1216
- state.currentStep = stepName;
1217
- this.markDirty();
1218
- }
1219
-
1220
- public onStepResult(id: string, stepName: string, status: StepStatus, durationMs: number, detail?: string): void {
1221
- const state = this.scenarios.get(id);
1222
- if (!state) {
1223
- return;
1224
- }
1225
-
1226
- state.completedSteps += 1;
1227
- state.currentStep = undefined;
1228
- state.lastStep = `${stepName} ${status} ${formatDuration(durationMs)}`;
1229
- if (status === 'failed' && detail) {
1230
- state.error = detail;
1231
- }
1232
-
1233
- const stat = this.stepStats.get(stepName) ?? {
1234
- passed: 0,
1235
- failed: 0,
1236
- skipped: 0,
1237
- durations: [],
1238
- };
1239
- stat[status] += 1;
1240
- if (status === 'passed') {
1241
- stat.durations.push(durationMs);
1242
- }
1243
- this.stepStats.set(stepName, stat);
1244
-
1245
- this.markDirty();
1246
- }
1247
-
1248
- public onScenarioFinish(result: ScenarioResult): void {
1249
- const state = this.scenarios.get(result.id);
1250
- if (state) {
1251
- state.status = result.status;
1252
- state.durationMs = result.durationMs;
1253
- state.currentStep = undefined;
1254
- state.error = result.errors[0];
1255
- state.logsCaptured = result.logsCaptured;
1256
- state.notebookId = result.notebookId;
1257
- }
1258
-
1259
- const levelStat = this.levelStats.get(result.level);
1260
- if (levelStat) {
1261
- levelStat.completed += 1;
1262
- levelStat[result.status] += 1;
1263
- levelStat.durations.push(result.durationMs);
1264
- }
1265
-
1266
- const notebookPart = result.notebookId ? ` (${result.notebookId})` : '';
1267
- if (result.status === 'passed') {
1268
- this.pushEvent('pass', `[${result.id}] PASSED in ${formatDuration(result.durationMs)}${notebookPart}`);
1269
- } else {
1270
- this.pushEvent(
1271
- 'fail',
1272
- `[${result.id}] FAILED in ${formatDuration(result.durationMs)}${notebookPart}${result.errors.length > 0 ? ` :: ${result.errors[0]}` : ''}`
1273
- );
1274
- }
1275
-
1276
- if (!this.interactive) {
1277
- console.log(
1278
- `[${result.id}] ${result.status.toUpperCase()} in ${formatDuration(result.durationMs)}${notebookPart}`
1279
- );
1280
- return;
1281
- }
1282
-
1283
- this.markDirty();
1284
- }
1285
-
1286
- private pushEvent(tone: EventTone, text: string): void {
1287
- this.recentEvents.push({
1288
- at: performance.now(),
1289
- tone,
1290
- text,
1291
- });
1292
-
1293
- if (this.recentEvents.length > 200) {
1294
- this.recentEvents.splice(0, this.recentEvents.length - 200);
1295
- }
1296
- }
1297
-
1298
- private markDirty(): void {
1299
- this.dirty = true;
1300
- }
1301
-
1302
- private flushRender(): void {
1303
- if (!this.interactive || !this.started || this.stopped) {
1304
- return;
1305
- }
1306
-
1307
- if (!this.dirty && !this.hasRunningScenarios()) {
1308
- return;
1309
- }
1310
-
1311
- this.render();
1312
- }
1313
-
1314
- private hasRunningScenarios(): boolean {
1315
- for (const scenario of this.scenarios.values()) {
1316
- if (scenario.status === 'running') {
1317
- return true;
1318
- }
1319
- }
1320
-
1321
- return false;
1322
- }
1323
-
1324
- private render(force = false): void {
1325
- if (!this.interactive || !this.started) {
1326
- return;
1327
- }
1328
-
1329
- if (!force && !this.dirty && !this.hasRunningScenarios()) {
1330
- return;
1331
- }
1332
-
1333
- this.dirty = false;
1334
- const frame = this.buildFrame();
1335
- if (this.frameLines > 0) {
1336
- process.stdout.write(`\x1b[${this.frameLines}F`);
1337
- }
1338
- process.stdout.write(ansi.clearDown);
1339
- process.stdout.write(`${frame}\n`);
1340
- this.frameLines = frame.split('\n').length;
1341
- }
1342
-
1343
- private buildFrame(): string {
1344
- const width = Math.max(72, process.stdout.columns ?? 120);
1345
- const completed = Array.from(this.levelStats.values()).reduce((sum, stat) => sum + stat.completed, 0);
1346
- const passed = Array.from(this.levelStats.values()).reduce((sum, stat) => sum + stat.passed, 0);
1347
- const failed = Array.from(this.levelStats.values()).reduce((sum, stat) => sum + stat.failed, 0);
1348
- const runningScenarios = Array.from(this.scenarios.values()).filter((scenario) => scenario.status === 'running');
1349
- const running = runningScenarios.length;
1350
- const scenarioDurations = Array.from(this.levelStats.values()).flatMap((stat) => stat.durations);
1351
- const avgScenarioDuration = scenarioDurations.length > 0 ? average(scenarioDurations) : 0;
1352
- const p95ScenarioDuration = scenarioDurations.length > 0 ? percentile(scenarioDurations, 95) : 0;
1353
- const bottlenecks = this.getBottlenecks(6);
1354
- const primaryBottleneck = bottlenecks[0];
1355
- const health = this.describeHealth(failed, running, p95ScenarioDuration, primaryBottleneck?.p95 ?? 0);
1356
-
1357
- const lines: string[] = [];
1358
- lines.push(this.decorate('PHPSandbox Load Test', ansi.bold, ansi.cyan));
1359
- lines.push(
1360
- [
1361
- `${this.label('target')} ${this.options.local ? 'local' : 'prod'}`,
1362
- `${this.label('api')} ${this.options.apiUrl}`,
1363
- `${this.label('template')} ${this.options.template}`,
1364
- `${this.label('mode')} ${this.options.mode}`,
1365
- `${this.label('runtime')} ${formatDuration(performance.now() - this.startedAt)}`,
1366
- ].join(' ')
1367
- );
1368
- lines.push(
1369
- [
1370
- `${this.label('levels')} ${this.options.levels.join(', ')}`,
1371
- `${this.label('waves')} ${this.options.waves}`,
1372
- `${this.label('cleanup')} ${this.options.cleanup ? (this.options.keepFailed ? 'success-only' : 'all') : 'off'}`,
1373
- ].join(' ')
1374
- );
1375
- lines.push('');
1376
- lines.push(
1377
- ...this.box(
1378
- 'Run Snapshot',
1379
- width,
1380
- this.buildSnapshotLines({
1381
- width: width - 4,
1382
- completed,
1383
- passed,
1384
- failed,
1385
- running,
1386
- avgScenarioDuration,
1387
- p95ScenarioDuration,
1388
- primaryBottleneck,
1389
- health,
1390
- }),
1391
- ansi.cyan
1392
- )
1393
- );
1394
- lines.push('');
1395
-
1396
- if (width >= 118) {
1397
- const gap = 2;
1398
- const leftWidth = Math.floor((width - gap) / 2);
1399
- const rightWidth = width - gap - leftWidth;
1400
- lines.push(
1401
- ...this.combineColumns(
1402
- this.box('Levels', leftWidth, this.buildLevelLines(leftWidth - 4), ansi.blue),
1403
- leftWidth,
1404
- this.box('Step Bottlenecks', rightWidth, this.buildBottleneckLines(rightWidth - 4, bottlenecks), ansi.magenta),
1405
- rightWidth,
1406
- gap
1407
- )
1408
- );
1409
- } else {
1410
- lines.push(...this.box('Levels', width, this.buildLevelLines(width - 4), ansi.blue));
1411
- lines.push('');
1412
- lines.push(...this.box('Step Bottlenecks', width, this.buildBottleneckLines(width - 4, bottlenecks), ansi.magenta));
1413
- }
1414
-
1415
- lines.push('');
1416
-
1417
- if (width >= 140) {
1418
- const gap = 2;
1419
- const leftWidth = Math.floor((width - gap) * 0.62);
1420
- const rightWidth = width - gap - leftWidth;
1421
- lines.push(
1422
- ...this.combineColumns(
1423
- this.box('Active Scenarios', leftWidth, this.buildActiveScenarioLines(leftWidth - 4, runningScenarios), ansi.green),
1424
- leftWidth,
1425
- this.box('Recent Events', rightWidth, this.buildEventLines(rightWidth - 4), ansi.yellow),
1426
- rightWidth,
1427
- gap
1428
- )
1429
- );
1430
- } else {
1431
- lines.push(...this.box('Active Scenarios', width, this.buildActiveScenarioLines(width - 4, runningScenarios), ansi.green));
1432
- lines.push('');
1433
- lines.push(...this.box('Recent Events', width, this.buildEventLines(width - 4), ansi.yellow));
1434
- }
1435
-
1436
- return lines.join('\n');
1437
- }
1438
-
1439
- private buildSnapshotLines(input: {
1440
- width: number;
1441
- completed: number;
1442
- passed: number;
1443
- failed: number;
1444
- running: number;
1445
- avgScenarioDuration: number;
1446
- p95ScenarioDuration: number;
1447
- primaryBottleneck?: { name: string; avg: number; p95: number };
1448
- health: { label: string; tone: EventTone };
1449
- }): string[] {
1450
- const progressWidth = Math.max(12, Math.min(34, input.width - 50));
1451
- const lines: string[] = [];
1452
- lines.push(
1453
- `Overall ${this.progressBar(input.completed, this.totalPlanned, progressWidth)} ${input.completed}/${this.totalPlanned} done ${this.decorate(String(input.passed), ansi.green)} pass ${this.decorate(String(input.failed), ansi.red)} fail ${this.decorate(String(input.running), ansi.blue)} running`
1454
- );
1455
- lines.push(
1456
- `Current level ${this.currentLevel ?? '-'} wave ${this.currentWave ?? '-'} concurrency ${this.currentConcurrency ?? '-'} state ${this.decorateHealth(input.health)}`
1457
- );
1458
-
1459
- const avgLabel = input.completed > 0 ? this.decorateDuration(input.avgScenarioDuration) : this.decorate('-', ansi.dim);
1460
- const p95Label = input.completed > 0 ? this.decorateDuration(input.p95ScenarioDuration) : this.decorate('-', ansi.dim);
1461
- const bottleneck =
1462
- input.primaryBottleneck === undefined
1463
- ? 'waiting for step timings'
1464
- : `${truncate(input.primaryBottleneck.name, Math.max(12, input.width - 48))} p95 ${this.decorateDuration(input.primaryBottleneck.p95)}`;
1465
-
1466
- lines.push(`Scenarios avg ${avgLabel} p95 ${p95Label} bottleneck ${bottleneck}`);
1467
-
1468
- return lines;
1469
- }
1470
-
1471
- private buildLevelLines(contentWidth: number): string[] {
1472
- const header = `${padRight('Lvl', 4)} ${padRight('Done', 11)} ${padRight('P', 3)} ${padRight('F', 3)} ${padRight('Avg', 8)} ${padRight('P95', 8)} Load`;
1473
- const lines = [header];
1474
- const maxP95 = Math.max(
1475
- 1,
1476
- ...this.options.levels.map((level) => {
1477
- const stat = this.levelStats.get(level)!;
1478
- return stat.durations.length > 0 ? percentile(stat.durations, 95) : 0;
1479
- })
1480
- );
1481
- const barWidth = Math.max(6, Math.min(14, contentWidth - 48));
1482
-
1483
- for (const level of this.options.levels) {
1484
- const stat = this.levelStats.get(level)!;
1485
- const avgMs = stat.durations.length > 0 ? average(stat.durations) : 0;
1486
- const p95Ms = stat.durations.length > 0 ? percentile(stat.durations, 95) : 0;
1487
- const avg = stat.durations.length > 0 ? this.decorateDuration(avgMs) : this.decorate('-', ansi.dim);
1488
- const p95 = stat.durations.length > 0 ? this.decorateDuration(p95Ms) : this.decorate('-', ansi.dim);
1489
- const loadBar = this.metricBar(p95Ms, maxP95, barWidth);
1490
- lines.push(
1491
- `${padRight(`L${level}`, 4)} ${padRight(`${stat.completed}/${stat.planned}`, 11)} ${padLeft(String(stat.passed), 3)} ${padLeft(String(stat.failed), 3)} ${padVisibleLeft(avg, 8)} ${padVisibleLeft(p95, 8)} ${loadBar}`
1492
- );
1493
- }
1494
-
1495
- return lines;
1496
- }
1497
-
1498
- private buildBottleneckLines(
1499
- contentWidth: number,
1500
- bottlenecks: Array<{ name: string; avg: number; p95: number; passed: number; failed: number; skipped: number }>
1501
- ): string[] {
1502
- if (bottlenecks.length === 0) {
1503
- return [this.decorate('waiting for step timings', ansi.dim)];
1504
- }
1505
-
1506
- const nameWidth = Math.max(12, contentWidth - 28);
1507
- const barWidth = Math.max(4, Math.min(10, contentWidth - nameWidth - 20));
1508
- const header = `${padRight('Step', nameWidth)} ${padRight('Avg', 8)} ${padRight('P95', 8)} P/F/S`;
1509
- const lines = [header];
1510
- const maxP95 = Math.max(1, ...bottlenecks.map((entry) => entry.p95));
1511
-
1512
- for (const entry of bottlenecks.slice(0, 6)) {
1513
- const counts = `${entry.passed}/${entry.failed}/${entry.skipped}`;
1514
- lines.push(
1515
- `${padRight(truncate(entry.name, nameWidth), nameWidth)} ${padVisibleLeft(this.decorateDuration(entry.avg), 8)} ${padVisibleLeft(this.decorateDuration(entry.p95), 8)} ${padLeft(counts, 7)} ${this.metricBar(entry.p95, maxP95, barWidth)}`
1516
- );
1517
- }
1518
-
1519
- return lines;
1520
- }
1521
-
1522
- private buildActiveScenarioLines(contentWidth: number, runningScenarios: ScenarioLiveState[]): string[] {
1523
- const scenarios = [...runningScenarios]
1524
- .sort((left, right) => left.startedAt - right.startedAt)
1525
- .slice(0, 10);
1526
-
1527
- if (scenarios.length === 0) {
1528
- return [this.decorate('idle', ansi.dim)];
1529
- }
1530
-
1531
- const stepWidth = Math.max(18, contentWidth - 42);
1532
- const header = `${padRight('Scenario', 16)} ${padRight('Step', stepWidth)} ${padLeft('Age', 8)} ${padLeft('Logs', 4)} Nb`;
1533
- const lines = [header];
1534
-
1535
- for (const scenario of scenarios) {
1536
- const step = scenario.currentStep ?? scenario.lastStep ?? 'starting';
1537
- const notebook = scenario.notebookId ? truncate(scenario.notebookId, 8) : '-';
1538
- lines.push(
1539
- `${padRight(scenario.id, 16)} ${padRight(truncate(step, stepWidth), stepWidth)} ${padLeft(formatDuration(performance.now() - scenario.startedAt), 8)} ${padLeft(String(scenario.logsCaptured), 4)} ${padRight(notebook, 8)}`
1540
- );
1541
- }
1542
-
1543
- if (runningScenarios.length > scenarios.length) {
1544
- lines.push(this.decorate(`... ${runningScenarios.length - scenarios.length} more running scenario(s)`, ansi.dim));
1545
- }
1546
-
1547
- return lines;
1548
- }
1549
-
1550
- private buildEventLines(contentWidth: number): string[] {
1551
- const events = this.selectVisibleEvents(8);
1552
- if (events.length === 0) {
1553
- return [this.decorate('waiting for events', ansi.dim)];
1554
- }
1555
-
1556
- return events.map((event) => {
1557
- const age = `+${formatDuration(event.at - this.startedAt)}`;
1558
- return `${this.eventPrefix(event.tone)} ${padRight(age, 8)} ${truncate(event.text, Math.max(12, contentWidth - 14))}`;
1559
- });
1560
- }
1561
-
1562
- private selectVisibleEvents(limit: number): UiEvent[] {
1563
- const recent = this.recentEvents.slice(-24);
1564
- const prioritized: UiEvent[] = [];
1565
- const failures = recent
1566
- .filter((event) => event.tone === 'fail' || event.tone === 'warn')
1567
- .slice(-Math.max(2, Math.floor(limit / 2)));
1568
-
1569
- for (const event of failures) {
1570
- prioritized.push(event);
1571
- }
1572
-
1573
- for (const event of recent.slice(-limit * 2)) {
1574
- if (prioritized.includes(event)) {
1575
- continue;
1576
- }
1577
- prioritized.push(event);
1578
- if (prioritized.length >= limit) {
1579
- break;
1580
- }
1581
- }
1582
-
1583
- return prioritized.slice(-limit);
1584
- }
1585
-
1586
- private getBottlenecks(limit: number): Array<{ name: string; avg: number; p95: number; passed: number; failed: number; skipped: number }> {
1587
- return Array.from(this.stepStats.entries())
1588
- .map(([name, stat]) => ({
1589
- name,
1590
- avg: stat.durations.length > 0 ? average(stat.durations) : 0,
1591
- p95: stat.durations.length > 0 ? percentile(stat.durations, 95) : 0,
1592
- passed: stat.passed,
1593
- failed: stat.failed,
1594
- skipped: stat.skipped,
1595
- }))
1596
- .sort(
1597
- (left, right) =>
1598
- right.p95 - left.p95 || right.avg - left.avg || right.failed - left.failed || left.name.localeCompare(right.name)
1599
- )
1600
- .slice(0, limit);
1601
- }
1602
-
1603
- private describeHealth(
1604
- failed: number,
1605
- running: number,
1606
- p95ScenarioDuration: number,
1607
- bottleneckP95: number
1608
- ): { label: string; tone: EventTone } {
1609
- if (failed > 0) {
1610
- return { label: 'degraded', tone: 'fail' };
1611
- }
1612
-
1613
- if (p95ScenarioDuration >= 90_000 || bottleneckP95 >= 30_000) {
1614
- return { label: 'under pressure', tone: 'warn' };
1615
- }
1616
-
1617
- if (running > 0) {
1618
- return { label: 'steady', tone: 'pass' };
1619
- }
1620
-
1621
- return { label: 'complete', tone: 'info' };
1622
- }
1623
-
1624
- private decorateHealth(health: { label: string; tone: EventTone }): string {
1625
- if (health.tone === 'fail') {
1626
- return this.decorate(health.label, ansi.red, ansi.bold);
1627
- }
1628
- if (health.tone === 'warn') {
1629
- return this.decorate(health.label, ansi.yellow, ansi.bold);
1630
- }
1631
- if (health.tone === 'pass') {
1632
- return this.decorate(health.label, ansi.green, ansi.bold);
1633
- }
1634
-
1635
- return this.decorate(health.label, ansi.cyan, ansi.bold);
1636
- }
1637
-
1638
- private box(title: string, width: number, body: string[], accent: string): string[] {
1639
- const safeWidth = Math.max(28, width);
1640
- const innerWidth = safeWidth - 4;
1641
- const titleText = truncate(title, safeWidth - 6);
1642
- const topFill = Math.max(0, safeWidth - titleText.length - 5);
1643
- const top = `+ ${this.decorate(titleText, ansi.bold, accent)} ${'-'.repeat(topFill)}+`;
1644
- const lines = [top];
1645
-
1646
- for (const line of body) {
1647
- lines.push(`| ${padVisibleRight(line, innerWidth)} |`);
1648
- }
1649
-
1650
- lines.push(`+${'-'.repeat(safeWidth - 2)}+`);
1651
- return lines;
1652
- }
1653
-
1654
- private combineColumns(left: string[], leftWidth: number, right: string[], rightWidth: number, gap: number): string[] {
1655
- const height = Math.max(left.length, right.length);
1656
- const output: string[] = [];
1657
-
1658
- for (let index = 0; index < height; index += 1) {
1659
- output.push(
1660
- `${padVisibleRight(left[index] ?? '', leftWidth)}${' '.repeat(gap)}${padVisibleRight(right[index] ?? '', rightWidth)}`
1661
- );
1662
- }
1663
-
1664
- return output;
1665
- }
1666
-
1667
- private metricBar(value: number, max: number, width: number): string {
1668
- const safeWidth = Math.max(4, width);
1669
- if (max <= 0) {
1670
- return `[${'.'.repeat(safeWidth)}]`;
1671
- }
1672
-
1673
- const filled = Math.max(0, Math.min(safeWidth, Math.round((value / max) * safeWidth)));
1674
- const bar = `[${'#'.repeat(filled)}${'.'.repeat(Math.max(0, safeWidth - filled))}]`;
1675
- if (value >= 30_000) {
1676
- return this.decorate(bar, ansi.red);
1677
- }
1678
- if (value >= 10_000) {
1679
- return this.decorate(bar, ansi.yellow);
1680
- }
1681
-
1682
- return this.decorate(bar, ansi.green);
1683
- }
1684
-
1685
- private progressBar(completed: number, total: number, width: number): string {
1686
- if (total <= 0) {
1687
- return '[----------]';
1688
- }
1689
-
1690
- const safeWidth = Math.max(10, width);
1691
- const filled = Math.max(0, Math.min(safeWidth, Math.round((completed / total) * safeWidth)));
1692
- return `[${'='.repeat(filled)}${'-'.repeat(Math.max(0, safeWidth - filled))}]`;
1693
- }
1694
-
1695
- private decorateDuration(durationMs: number): string {
1696
- const value = formatDuration(durationMs);
1697
- if (durationMs >= 30_000) {
1698
- return this.decorate(value, ansi.red);
1699
- }
1700
- if (durationMs >= 10_000) {
1701
- return this.decorate(value, ansi.yellow);
1702
- }
1703
-
1704
- return this.decorate(value, ansi.green);
1705
- }
1706
-
1707
- private label(text: string): string {
1708
- return this.decorate(text, ansi.dim, ansi.gray);
1709
- }
1710
-
1711
- private eventPrefix(tone: EventTone): string {
1712
- if (tone === 'pass') {
1713
- return this.decorate('OK', ansi.green, ansi.bold);
1714
- }
1715
- if (tone === 'fail') {
1716
- return this.decorate('ER', ansi.red, ansi.bold);
1717
- }
1718
- if (tone === 'warn') {
1719
- return this.decorate('!!', ansi.yellow, ansi.bold);
1720
- }
1721
-
1722
- return this.decorate('..', ansi.cyan, ansi.bold);
1723
- }
1724
-
1725
- private decorate(text: string, ...codes: string[]): string {
1726
- if (!this.useColor) {
1727
- return text;
1728
- }
1729
-
1730
- return `${codes.join('')}${text}${ansi.reset}`;
1731
- }
1732
- }
1733
-
1734
- function renderSummary(
1735
- stages: StageResult[],
1736
- summary: ReturnType<typeof summarizeStages>
1737
- ): string {
1738
- const lines: string[] = [];
1739
- lines.push('\n=== Load test summary ===');
1740
-
1741
- for (const stage of stages) {
1742
- const stagePassed = stage.scenarios.filter((scenario) => scenario.status === 'passed').length;
1743
- const stageFailed = stage.scenarios.length - stagePassed;
1744
- const durations = stage.scenarios.map((scenario) => scenario.durationMs);
1745
- lines.push(
1746
- `Level ${stage.level}: ${stagePassed}/${stage.scenarios.length} passed, ${stageFailed}/${stage.scenarios.length} failed, ` +
1747
- `avg=${formatDuration(average(durations))}, p95=${formatDuration(percentile(durations, 95))}`
1748
- );
1749
- }
1750
-
1751
- lines.push(
1752
- `Overall: ${summary.passedScenarios}/${summary.scenarios} passed, ${summary.failedScenarios}/${summary.scenarios} failed`
1753
- );
1754
- lines.push('Step timings:');
1755
-
1756
- for (const [name, stat] of Array.from(summary.stepStats.entries()).sort(([a], [b]) => a.localeCompare(b))) {
1757
- const durationDetail =
1758
- stat.durations.length > 0
1759
- ? `avg=${formatDuration(average(stat.durations))}, p95=${formatDuration(percentile(stat.durations, 95))}`
1760
- : 'no successful timings';
1761
-
1762
- lines.push(
1763
- ` ${name}: pass=${stat.passed} fail=${stat.failed} skip=${stat.skipped} ${durationDetail}`
1764
- );
1765
- }
1766
-
1767
- const failedScenarios = stages
1768
- .flatMap((stage) => stage.scenarios)
1769
- .filter((scenario) => scenario.status === 'failed');
1770
-
1771
- if (failedScenarios.length > 0) {
1772
- lines.push('Failures:');
1773
- for (const scenario of failedScenarios) {
1774
- lines.push(` ${scenario.id}: ${scenario.errors.join(' | ')}`);
1775
- }
1776
- }
1777
-
1778
- return lines.join('\n');
1779
- }
1780
-
1781
- async function recordStep<T>(
1782
- steps: StepResult[],
1783
- name: string,
1784
- action: () => Promise<T>,
1785
- timeoutMs: number
1786
- ): Promise<T> {
1787
- const startedAt = performance.now();
1788
-
1789
- try {
1790
- const value = await withTimeout(action(), timeoutMs, `${name} timed out after ${timeoutMs}ms`);
1791
- steps.push({
1792
- name,
1793
- status: 'passed',
1794
- durationMs: performance.now() - startedAt,
1795
- detail: trimDetail(describeValue(value)),
1796
- });
1797
-
1798
- return value;
1799
- } catch (error) {
1800
- steps.push({
1801
- name,
1802
- status: 'failed',
1803
- durationMs: performance.now() - startedAt,
1804
- error: formatError(error),
1805
- });
1806
-
1807
- throw error;
1808
- }
1809
- }
1810
-
1811
- function recordSkippedStep(steps: StepResult[], name: string, detail: string): void {
1812
- steps.push({
1813
- name,
1814
- status: 'skipped',
1815
- durationMs: 0,
1816
- detail,
1817
- });
1818
- }
1819
-
1820
- function createDeferred<T>(): Deferred<T> {
1821
- let resolveFn: ((value: T) => void) | null = null;
1822
- let rejectFn: ((reason?: unknown) => void) | null = null;
1823
-
1824
- const deferred: Deferred<T> = {
1825
- settled: false,
1826
- promise: new Promise<T>((resolve, reject) => {
1827
- resolveFn = (value) => {
1828
- if (deferred.settled) {
1829
- return;
1830
- }
1831
-
1832
- deferred.settled = true;
1833
- resolve(value);
1834
- };
1835
-
1836
- rejectFn = (reason) => {
1837
- if (deferred.settled) {
1838
- return;
1839
- }
1840
-
1841
- deferred.settled = true;
1842
- reject(reason);
1843
- };
1844
- }),
1845
- resolve(value: T) {
1846
- resolveFn?.(value);
1847
- },
1848
- reject(reason?: unknown) {
1849
- rejectFn?.(reason);
1850
- },
1851
- };
1852
-
1853
- return deferred;
1854
- }
1855
-
1856
- function waitForNextEvent<T>(
1857
- subscribe: (handler: (data: T) => void) => Disposable,
1858
- timeoutMs: number,
1859
- label: string
1860
- ): Promise<T> {
1861
- return new Promise<T>((resolve, reject) => {
1862
- let disposable: Disposable | null = null;
1863
- const timer = setTimeout(() => {
1864
- disposable?.dispose();
1865
- reject(new Error(`Timed out waiting for ${label}.`));
1866
- }, timeoutMs);
1867
-
1868
- disposable = subscribe((data) => {
1869
- clearTimeout(timer);
1870
- disposable?.dispose();
1871
- resolve(data);
1872
- });
1873
- });
1874
- }
1875
-
1876
- function firstFulfilled<T>(promises: Array<Promise<T>>): Promise<T> {
1877
- return new Promise<T>((resolve, reject) => {
1878
- const errors: unknown[] = [];
1879
- let pending = promises.length;
1880
-
1881
- for (const [index, promise] of promises.entries()) {
1882
- void promise.then(resolve, (error) => {
1883
- errors[index] = error;
1884
- pending -= 1;
1885
- if (pending === 0) {
1886
- reject(errors[0] ?? new Error('All candidate promises rejected.'));
1887
- }
1888
- });
1889
- }
1890
- });
1891
- }
1892
-
1893
- async function readStreamToString(
1894
- stream: ReadableStream<string | Uint8Array>,
1895
- timeoutMs: number,
1896
- maxChars: number
1897
- ): Promise<string> {
1898
- const reader = stream.getReader();
1899
- let output = '';
1900
-
1901
- try {
1902
- while (output.length < maxChars) {
1903
- const chunk = await withTimeout(reader.read(), timeoutMs, 'waiting for stream output');
1904
- if (chunk.done) {
1905
- break;
1906
- }
1907
-
1908
- output += chunk.value instanceof Uint8Array ? new TextDecoder().decode(chunk.value) : (chunk.value ?? '');
1909
- }
1910
-
1911
- return output;
1912
- } finally {
1913
- reader.releaseLock();
1914
- }
1915
- }
1916
-
1917
- async function readNextChunk(
1918
- reader: ReadableStreamDefaultReader<string>,
1919
- timeoutMs: number
1920
- ): Promise<string> {
1921
- const chunk = await withTimeout(reader.read(), timeoutMs, 'waiting for the next stream chunk');
1922
- if (chunk.done || typeof chunk.value !== 'string') {
1923
- throw new Error('Stream ended before a chunk was received.');
1924
- }
1925
-
1926
- return chunk.value;
1927
- }
1928
-
1929
- function isStreamInactivityError(error: unknown): boolean {
1930
- return formatError(error).includes('waiting for stream output');
1931
- }
1932
-
1933
- function makeRouteContents(id: string, routeUrlPath: string): string {
1934
- return `// load-test:${id}
1935
- Route::get('/${routeUrlPath}', function () {
1936
- return response()->json([
1937
- 'scenario' => '${id}',
1938
- 'ok' => true,
1939
- ]);
1940
- });
1941
- `;
1942
- }
1943
-
1944
- function appendRouteDefinition(currentContents: string, id: string, routeUrlPath: string): string {
1945
- const snippet = makeRouteContents(id, routeUrlPath);
1946
- if (currentContents.includes(`load-test:${id}`)) {
1947
- return currentContents;
1948
- }
1949
-
1950
- const trimmed = currentContents.trimEnd();
1951
- return `${trimmed}\n\n${snippet}`;
1952
- }
1953
-
1954
- async function fetchDefaultPreview(
1955
- options: LoadTestOptions,
1956
- notebook: NotebookInstance,
1957
- previewUrl: string | undefined
1958
- ): Promise<string> {
1959
- if (!previewUrl) {
1960
- throw new Error('previewUrl was not captured from notebook.ready().');
1961
- }
1962
-
1963
- return retryWithBackoff(async () => {
1964
- const response = await requestPreviewWithSession(options, notebook, previewUrl);
1965
- if (response.status >= 500) {
1966
- throw new Error(`Preview returned HTTP ${response.status}: ${trimDetail(response.body)}`);
1967
- }
1968
-
1969
- return `status=${response.status} bytes=${response.body.length} url=${redactUrl(previewUrl)}`;
1970
- }, PREVIEW_RETRY_DELAYS_MS, () => true);
1971
- }
1972
-
1973
- async function fetchScenarioPreview(
1974
- options: LoadTestOptions,
1975
- notebook: NotebookInstance,
1976
- previewUrl: string | undefined,
1977
- routeUrlPath: string,
1978
- scenarioId: string
1979
- ): Promise<string> {
1980
- if (!previewUrl) {
1981
- throw new Error('previewUrl was not captured from notebook.ready().');
1982
- }
1983
-
1984
- const routeUrl = previewUrlForPath(previewUrl, routeUrlPath);
1985
- return retryWithBackoff(async () => {
1986
- const response = await requestPreviewWithSession(options, notebook, routeUrl);
1987
- if (response.status !== 200) {
1988
- throw new Error(`Preview route returned HTTP ${response.status}: ${trimDetail(response.body)}`);
1989
- }
1990
- if (!response.body.includes(scenarioId)) {
1991
- throw new Error(`Preview route response did not include ${scenarioId}.`);
1992
- }
1993
-
1994
- return `status=${response.status} bytes=${response.body.length} url=${redactUrl(routeUrl)}`;
1995
- }, PREVIEW_RETRY_DELAYS_MS, () => true);
1996
- }
1997
-
1998
- function previewUrlForPath(baseUrl: string, path: string): string {
1999
- const base = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
2000
- return new URL(path.replace(/^\/+/, ''), base).toString();
2001
- }
2002
-
2003
- async function requestPreviewWithSession(
2004
- options: LoadTestOptions,
2005
- notebook: NotebookInstance,
2006
- url: string
2007
- ): Promise<{ status: number; body: string }> {
2008
- let session: NotebookPreviewSessionData;
2009
- try {
2010
- session = await notebook.preview.createSession({ url });
2011
- } catch (error) {
2012
- if (formatError(error).includes('Preview access must be enabled')) {
2013
- return requestPreview(options, url);
2014
- }
2015
-
2016
- throw error;
2017
- }
2018
-
2019
- const bootstrapUrl = session.url;
2020
- const bootstrap = await requestPreview(options, bootstrapUrl, { maxRedirects: 0 });
2021
- const cookie = firstSetCookie(bootstrap.headers);
2022
-
2023
- if (bootstrap.status >= 300 && bootstrap.status < 400) {
2024
- const location = bootstrap.headers.location;
2025
- const nextUrl = typeof location === 'string' && location.length > 0
2026
- ? new URL(location, bootstrapUrl).toString()
2027
- : url;
2028
-
2029
- return requestPreview(options, nextUrl, {
2030
- headers: cookie ? { cookie } : undefined,
2031
- });
2032
- }
2033
-
2034
- if (cookie) {
2035
- return requestPreview(options, url, {
2036
- headers: { cookie },
2037
- });
2038
- }
2039
-
2040
- return {
2041
- status: bootstrap.status,
2042
- body: bootstrap.body,
2043
- };
2044
- }
2045
-
2046
- function firstSetCookie(headers: Record<string, unknown>): string | undefined {
2047
- const raw = headers['set-cookie'];
2048
- const value = Array.isArray(raw) ? raw[0] : raw;
2049
- if (typeof value !== 'string' || value.length === 0) {
2050
- return undefined;
2051
- }
2052
-
2053
- return value.split(';', 1)[0];
2054
- }
2055
-
2056
- async function requestPreview(
2057
- options: LoadTestOptions,
2058
- url: string,
2059
- requestOptions: { headers?: Record<string, string>; maxRedirects?: number } = {}
2060
- ): Promise<{ status: number; body: string; headers: Record<string, unknown> }> {
2061
- const ca = options.caFile ? await fs.readFile(options.caFile, 'utf8') : undefined;
2062
- const httpsAgent = url.startsWith('https://')
2063
- ? new https.Agent({
2064
- ca,
2065
- rejectUnauthorized: !options.insecureTls,
2066
- })
2067
- : undefined;
2068
- const response = await axios.request<string>({
2069
- url,
2070
- method: 'GET',
2071
- responseType: 'text',
2072
- validateStatus: () => true,
2073
- httpsAgent,
2074
- headers: requestOptions.headers,
2075
- maxRedirects: requestOptions.maxRedirects,
2076
- timeout: options.stepTimeoutMs,
2077
- });
2078
-
2079
- return {
2080
- status: response.status,
2081
- body: typeof response.data === 'string' ? response.data : String(response.data ?? ''),
2082
- headers: response.headers as Record<string, unknown>,
2083
- };
2084
- }
2085
-
2086
- function encodeLspPayload(payload: unknown): string {
2087
- const body = JSON.stringify(payload);
2088
- return `Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n${body}`;
2089
- }
2090
-
2091
- async function retryWithBackoff<T>(
2092
- action: () => Promise<T>,
2093
- delaysMs: number[],
2094
- shouldRetry: (error: unknown) => boolean
2095
- ): Promise<T> {
2096
- let attempt = 0;
2097
-
2098
- while (true) {
2099
- try {
2100
- return await action();
2101
- } catch (error) {
2102
- if (!shouldRetry(error) || attempt >= delaysMs.length) {
2103
- throw error;
2104
- }
2105
-
2106
- await sleep(delaysMs[attempt]);
2107
- attempt += 1;
2108
- }
2109
- }
2110
- }
2111
-
2112
- function formatError(error: unknown): string {
2113
- if (error instanceof Error) {
2114
- return error.message;
2115
- }
2116
-
2117
- return String(error);
2118
- }
2119
-
2120
- function shouldRetryRemoteRateLimitError(error: unknown): boolean {
2121
- return RemoteError.is(error, 'RateLimited');
2122
- }
2123
-
2124
- function shouldRetryTelemetryError(error: unknown): boolean {
2125
- const message = formatError(error);
2126
- return message.includes('container.stats') || message.includes('container.telemetry');
2127
- }
2128
-
2129
- function isInterruptedError(error: unknown): boolean {
2130
- return error instanceof LoadTestInterruptedError;
2131
- }
2132
-
2133
- function installSignalHandler(ui: LoadTestUi, interrupts: InterruptController): () => void {
2134
- const onSigint = () => {
2135
- if (interrupts.request()) {
2136
- const running = ui.runningScenarioCount();
2137
- ui.note(
2138
- `SIGINT received. Stopping after ${running} active scenario(s) finish their current step and cleanup. Press Ctrl+C again to force exit.`,
2139
- 'warn'
2140
- );
2141
- return;
2142
- }
2143
-
2144
- ui.note('Second SIGINT received. Exiting immediately.', 'fail');
2145
- ui.stop();
2146
- process.exit(130);
2147
- };
2148
-
2149
- process.on('SIGINT', onSigint);
2150
- return () => {
2151
- process.off('SIGINT', onSigint);
2152
- };
2153
- }
2154
-
2155
- function explainMissingAction(error: unknown, action: string): string | undefined {
2156
- const message = formatError(error);
2157
- if (message.includes(`Action ${action} not found`)) {
2158
- return `${action} is not implemented by this backend`;
2159
- }
2160
-
2161
- return undefined;
2162
- }
2163
-
2164
- function explainUnsupportedShell(error: unknown): string | undefined {
2165
- const message = formatError(error);
2166
- if (message.includes('/usr/bin/sh: no such file or directory')) {
2167
- return 'shell/terminal backend is unavailable in this environment';
2168
- }
2169
-
2170
- return undefined;
2171
- }
2172
-
2173
- function explainUnavailableGit(error: unknown): string | undefined {
2174
- const message = formatError(error);
2175
- if (message.includes('Git repository is not initialized in this notebook.')) {
2176
- return 'git service is available but this template is not a git repository';
2177
- }
2178
-
2179
- return undefined;
2180
- }
2181
-
2182
- function truncate(value: string, maxLength: number): string {
2183
- if (value.length <= maxLength) {
2184
- return value;
2185
- }
2186
-
2187
- if (maxLength <= 3) {
2188
- return value.slice(0, maxLength);
2189
- }
2190
-
2191
- return `${value.slice(0, maxLength - 3)}...`;
2192
- }
2193
-
2194
- function stripAnsi(value: string): string {
2195
- return value.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '');
2196
- }
2197
-
2198
- function visibleWidth(value: string): number {
2199
- return stripAnsi(value).length;
2200
- }
2201
-
2202
- function padVisibleRight(value: string, width: number): string {
2203
- const plainWidth = visibleWidth(value);
2204
- if (plainWidth > width) {
2205
- return truncate(stripAnsi(value), width);
2206
- }
2207
-
2208
- return `${value}${' '.repeat(width - plainWidth)}`;
2209
- }
2210
-
2211
- function padVisibleLeft(value: string, width: number): string {
2212
- const plainWidth = visibleWidth(value);
2213
- if (plainWidth > width) {
2214
- return truncate(stripAnsi(value), width);
2215
- }
2216
-
2217
- return `${' '.repeat(width - plainWidth)}${value}`;
2218
- }
2219
-
2220
- function padRight(value: string, width: number): string {
2221
- if (value.length >= width) {
2222
- return value;
2223
- }
2224
-
2225
- return `${value}${' '.repeat(width - value.length)}`;
2226
- }
2227
-
2228
- function padLeft(value: string, width: number): string {
2229
- if (value.length >= width) {
2230
- return value;
2231
- }
2232
-
2233
- return `${' '.repeat(width - value.length)}${value}`;
2234
- }
2235
-
2236
- function trimDetail(value: string, maxLength = 160): string {
2237
- const normalized = value.replace(/\s+/g, ' ').trim();
2238
- if (normalized.length <= maxLength) {
2239
- return normalized;
2240
- }
2241
-
2242
- return `${normalized.slice(0, maxLength - 3)}...`;
2243
- }
2244
-
2245
- function describeValue(value: unknown): string {
2246
- if (typeof value === 'string') {
2247
- return value;
2248
- }
2249
-
2250
- if (typeof value === 'number' || typeof value === 'boolean') {
2251
- return String(value);
2252
- }
2253
-
2254
- if (value === null || value === undefined) {
2255
- return String(value);
2256
- }
2257
-
2258
- try {
2259
- return JSON.stringify(value);
2260
- } catch {
2261
- return Object.prototype.toString.call(value);
2262
- }
2263
- }
2264
-
2265
- function sanitizeArtifact<T>(payload: T): T {
2266
- const cloned = JSON.parse(JSON.stringify(payload));
2267
-
2268
- if (cloned.options) {
2269
- if ('token' in cloned.options) {
2270
- cloned.options.token = '<redacted>';
2271
- }
2272
- if ('reauthorizationUrl' in cloned.options) {
2273
- cloned.options.reauthorizationUrl = '<redacted>';
2274
- }
2275
- }
2276
-
2277
- for (const stage of cloned.stages ?? []) {
2278
- for (const scenario of stage.scenarios ?? []) {
2279
- for (const step of scenario.steps ?? []) {
2280
- if (step.name === 'notebook.ready' && typeof step.detail === 'string') {
2281
- step.detail = 'notebook initialized successfully';
2282
- }
2283
- }
2284
- }
2285
- }
2286
-
2287
- return cloned;
2288
- }
2289
-
2290
- function average(values: number[]): number {
2291
- if (values.length === 0) {
2292
- return 0;
2293
- }
2294
-
2295
- return values.reduce((sum, value) => sum + value, 0) / values.length;
2296
- }
2297
-
2298
- function percentile(values: number[], percentileValue: number): number {
2299
- if (values.length === 0) {
2300
- return 0;
2301
- }
2302
-
2303
- const sorted = [...values].sort((a, b) => a - b);
2304
- const index = Math.min(
2305
- sorted.length - 1,
2306
- Math.max(0, Math.ceil((percentileValue / 100) * sorted.length) - 1)
2307
- );
2308
-
2309
- return sorted[index];
2310
- }
2311
-
2312
- function formatDuration(durationMs: number): string {
2313
- if (durationMs < 1000) {
2314
- return `${durationMs.toFixed(0)}ms`;
2315
- }
2316
-
2317
- return `${(durationMs / 1000).toFixed(2)}s`;
2318
- }
2319
-
2320
- function sleep(ms: number, signal?: AbortSignal): Promise<void> {
2321
- return new Promise((resolve, reject) => {
2322
- if (signal?.aborted) {
2323
- reject(signal.reason instanceof Error ? signal.reason : new LoadTestInterruptedError());
2324
- return;
2325
- }
2326
-
2327
- const timer = setTimeout(() => {
2328
- signal?.removeEventListener('abort', onAbort);
2329
- resolve();
2330
- }, ms);
2331
-
2332
- const onAbort = () => {
2333
- clearTimeout(timer);
2334
- signal?.removeEventListener('abort', onAbort);
2335
- reject(signal?.reason instanceof Error ? signal.reason : new LoadTestInterruptedError());
2336
- };
2337
-
2338
- signal?.addEventListener('abort', onAbort, { once: true });
2339
- });
2340
- }
2341
-
2342
- function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
2343
- return new Promise<T>((resolve, reject) => {
2344
- const timer = setTimeout(() => reject(new Error(label)), timeoutMs);
2345
-
2346
- promise.then(
2347
- (value) => {
2348
- clearTimeout(timer);
2349
- resolve(value);
2350
- },
2351
- (error) => {
2352
- clearTimeout(timer);
2353
- reject(error);
2354
- }
2355
- );
2356
- });
2357
- }
2358
-
2359
- function parseOptions(argv: string[]): LoadTestOptions {
2360
- const args = parseArgs(argv);
2361
- const token = stringArg(args, 'token') ?? process.env.PHPSANDBOX_TOKEN;
2362
-
2363
- if (!token) {
2364
- throw new Error('Missing PHPSANDBOX_TOKEN. Pass it via the environment or --token.');
2365
- }
2366
-
2367
- const levels = resolveLevels(args);
2368
- const local = booleanArg(args, 'local') ?? false;
2369
- const caFile = stringArg(args, 'ca-file');
2370
- const apiUrl =
2371
- stringArg(args, 'api-url') ??
2372
- (local ? DEFAULT_LOCAL_API_URL : undefined) ??
2373
- process.env.PHPSANDBOX_API_URL ??
2374
- DEFAULT_PROD_API_URL;
2375
- const insecureTls = booleanArg(args, 'insecure-tls') ?? (local && !caFile);
2376
-
2377
- return {
2378
- token,
2379
- local,
2380
- apiUrl,
2381
- runtimeOrigin: stringArg(args, 'runtime-origin'),
2382
- runtimeUrl: stringArg(args, 'runtime-url'),
2383
- template: stringArg(args, 'template') ?? 'laravel',
2384
- mode: resolveMode(args),
2385
- stopOnFailureLevel: booleanArg(args, 'stop-on-failure-level') ?? false,
2386
- titlePrefix: stringArg(args, 'title-prefix') ?? 'Laravel SDK Load Test',
2387
- visibility: visibilityArg(args, 'visibility') ?? 'private',
2388
- levels,
2389
- waves: numberArg(args, 'waves') ?? 1,
2390
- pauseMs: numberArg(args, 'pause-ms') ?? 0,
2391
- cleanup: booleanArg(args, 'cleanup') ?? true,
2392
- keepFailed: booleanArg(args, 'keep-failed') ?? false,
2393
- debug: booleanArg(args, 'debug') ?? false,
2394
- stepTimeoutMs: numberArg(args, 'step-timeout-ms') ?? 60_000,
2395
- streamTimeoutMs: numberArg(args, 'stream-timeout-ms') ?? 20_000,
2396
- telemetryTimeoutMs: numberArg(args, 'telemetry-timeout-ms') ?? 15_000,
2397
- maxStreamChars: numberArg(args, 'max-stream-chars') ?? 200_000,
2398
- includeContainerRestart: booleanArg(args, 'include-container-restart') ?? false,
2399
- includeDownload: booleanArg(args, 'include-download') ?? false,
2400
- includePreview: booleanArg(args, 'include-preview') ?? false,
2401
- includeLsp: booleanArg(args, 'include-lsp') ?? false,
2402
- requireInitMessages: listArg(args, 'require-init-messages'),
2403
- expectProvisioned: booleanArg(args, 'expect-provisioned'),
2404
- expectProvisioningAction: provisioningActionArg(args, 'expect-provisioning-action'),
2405
- reauthorizationUrl: stringArg(args, 'reauthorization-url'),
2406
- json: booleanArg(args, 'json') ?? false,
2407
- jsonFile: stringArg(args, 'json-file'),
2408
- insecureTls,
2409
- caFile,
2410
- };
2411
- }
2412
-
2413
- function resolveRuntimeUrl(original: string, options: LoadTestOptions): string {
2414
- if (options.runtimeUrl) {
2415
- return options.runtimeUrl;
2416
- }
2417
-
2418
- if (!options.runtimeOrigin) {
2419
- return original;
2420
- }
2421
-
2422
- const source = new URL(original);
2423
- const target = new URL(options.runtimeOrigin);
2424
- source.protocol = target.protocol;
2425
- source.host = target.host;
2426
- return source.toString();
2427
- }
2428
-
2429
- function assertValidUrl(value: unknown, label: string): string {
2430
- if (typeof value !== 'string' || value.length === 0) {
2431
- throw new Error(`${label} must be a non-empty string, got ${JSON.stringify(value)}`);
2432
- }
2433
-
2434
- try {
2435
- new URL(value);
2436
- } catch {
2437
- throw new Error(`${label} must be a valid URL, got ${JSON.stringify(value)}`);
2438
- }
2439
-
2440
- return value;
2441
- }
2442
-
2443
- function assertPortInfoList(value: unknown, label: string): PortInfo[] {
2444
- if (!Array.isArray(value)) {
2445
- throw new Error(`${label} must be an array, got ${JSON.stringify(value)}`);
2446
- }
2447
-
2448
- value.forEach((rawPort, index) => {
2449
- if (!rawPort || typeof rawPort !== 'object') {
2450
- throw new Error(`${label}[${index}] must be an object, got ${JSON.stringify(rawPort)}`);
2451
- }
2452
-
2453
- const port = rawPort as Partial<PortInfo>;
2454
- if (!Number.isInteger(port.port) || (port.port ?? 0) <= 0) {
2455
- throw new Error(`${label}[${index}].port must be a positive integer, got ${JSON.stringify(port.port)}`);
2456
- }
2457
- assertValidUrl(port.url, `${label}[${index}].url`);
2458
- if (typeof port.subdomain !== 'string' || port.subdomain.length === 0) {
2459
- throw new Error(`${label}[${index}].subdomain must be a non-empty string, got ${JSON.stringify(port.subdomain)}`);
2460
- }
2461
- if (typeof port.default !== 'boolean') {
2462
- throw new Error(`${label}[${index}].default must be boolean, got ${JSON.stringify(port.default)}`);
2463
- }
2464
- });
2465
-
2466
- return value as PortInfo[];
2467
- }
2468
-
2469
- function validateInitTrace(
2470
- trace: InitTrace | undefined,
2471
- ready: NotebookInitSuccessResult | undefined,
2472
- options: LoadTestOptions
2473
- ): string {
2474
- if (!trace) {
2475
- throw new Error('Notebook initialization trace was not captured.');
2476
- }
2477
-
2478
- if (!ready) {
2479
- throw new Error('Notebook ready result was not captured.');
2480
- }
2481
-
2482
- const initMessages = trace.events
2483
- .filter((event) => event.source === 'init')
2484
- .map((event) => event.message)
2485
- .filter(Boolean);
2486
- const provisionMessages = trace.events
2487
- .filter((event) => event.source === 'provision')
2488
- .map((event) => event.message)
2489
- .filter(Boolean);
2490
-
2491
- if (options.requireInitMessages.length > 0) {
2492
- assertMessagesInOrder(initMessages, options.requireInitMessages);
2493
- }
2494
-
2495
- if (options.expectProvisioned !== undefined && ready.data.provisioned !== options.expectProvisioned) {
2496
- throw new Error(
2497
- `Expected notebook.ready data.provisioned=${options.expectProvisioned}, got ${ready.data.provisioned}.`
2498
- );
2499
- }
2500
-
2501
- if (
2502
- options.expectProvisioningAction !== undefined &&
2503
- ready.data.provisioningAction !== options.expectProvisioningAction
2504
- ) {
2505
- throw new Error(
2506
- `Expected notebook.ready data.provisioningAction=${options.expectProvisioningAction}, got ${ready.data.provisioningAction}.`
2507
- );
2508
- }
2509
-
2510
- return [
2511
- `result=${trace.resultType ?? 'unknown'}`,
2512
- `init=${formatMessageTrace(initMessages)}`,
2513
- provisionMessages.length > 0 ? `provision=${formatMessageTrace(provisionMessages)}` : 'provision=none',
2514
- `provisioned=${ready.data.provisioned}`,
2515
- `action=${ready.data.provisioningAction ?? 'none'}`,
2516
- ].join(' ');
2517
- }
2518
-
2519
- function assertMessagesInOrder(observed: readonly string[], required: readonly string[]): void {
2520
- let searchFrom = 0;
2521
-
2522
- for (const expected of required) {
2523
- const foundAt = observed.findIndex((message, index) => index >= searchFrom && message === expected);
2524
- if (foundAt === -1) {
2525
- throw new Error(
2526
- `Missing required init message ${JSON.stringify(expected)} in order. Observed: ${formatMessageTrace(observed)}`
2527
- );
2528
- }
2529
-
2530
- searchFrom = foundAt + 1;
2531
- }
2532
- }
2533
-
2534
- function formatMessageTrace(messages: readonly string[]): string {
2535
- if (messages.length === 0) {
2536
- return 'none';
2537
- }
2538
-
2539
- return uniqueInOrder(messages)
2540
- .map((message) => trimDetail(message, 60))
2541
- .join(' -> ');
2542
- }
2543
-
2544
- function uniqueInOrder(values: readonly string[]): string[] {
2545
- const seen = new Set<string>();
2546
- const unique: string[] = [];
2547
-
2548
- for (const value of values) {
2549
- if (seen.has(value)) {
2550
- continue;
2551
- }
2552
-
2553
- seen.add(value);
2554
- unique.push(value);
2555
- }
2556
-
2557
- return unique;
2558
- }
2559
-
2560
- function describePorts(ports: readonly PortInfo[]): string {
2561
- const defaultPorts = ports.filter((port) => port.default).map((port) => port.port);
2562
- return `ports=${ports.length} defaultPorts=${defaultPorts.length > 0 ? defaultPorts.join(',') : 'none'}`;
2563
- }
2564
-
2565
- function redactUrl(value: string): string {
2566
- try {
2567
- const url = new URL(value);
2568
- if (url.search) {
2569
- url.search = '?redacted=1';
2570
- }
2571
- return url.toString();
2572
- } catch {
2573
- return '<invalid-url>';
2574
- }
2575
- }
2576
-
2577
- async function createNodeTransport(options: LoadTestOptions): Promise<{
2578
- fetch?: typeof globalThis.fetch;
2579
- webSocket?: typeof NodeWebSocket;
2580
- }> {
2581
- if (!options.insecureTls && !options.caFile) {
2582
- return {};
2583
- }
2584
-
2585
- if (!options.apiUrl.startsWith('https://')) {
2586
- return {};
2587
- }
2588
-
2589
- const ca = options.caFile ? await fs.readFile(options.caFile, 'utf8') : undefined;
2590
- const agentOptions = {
2591
- ca,
2592
- rejectUnauthorized: !options.insecureTls,
2593
- };
2594
- const httpsAgent = new https.Agent(agentOptions);
2595
-
2596
- class ConfiguredWebSocket extends NodeWebSocket {
2597
- public constructor(address: string | URL, protocols?: string | string[]) {
2598
- super(address, protocols, agentOptions);
2599
- }
2600
- }
2601
-
2602
- const fetch: typeof globalThis.fetch = async (input, init) => {
2603
- const request = input instanceof Request ? input : new Request(input, init);
2604
- const headers: Record<string, string> = {};
2605
- request.headers.forEach((value, key) => {
2606
- headers[key] = value;
2607
- });
2608
- const body =
2609
- request.method === 'GET' || request.method === 'HEAD'
2610
- ? undefined
2611
- : Buffer.from(await request.arrayBuffer());
2612
-
2613
- const response = await axios.request<ArrayBuffer>({
2614
- url: request.url,
2615
- method: request.method as any,
2616
- headers,
2617
- data: body,
2618
- responseType: 'arraybuffer',
2619
- validateStatus: () => true,
2620
- httpsAgent,
2621
- signal: request.signal,
2622
- });
2623
-
2624
- const responseBody =
2625
- response.status === 204 || response.status === 205 || response.status === 304
2626
- ? null
2627
- : response.data;
2628
-
2629
- return new Response(responseBody, {
2630
- status: response.status,
2631
- statusText: response.statusText,
2632
- headers: response.headers as HeadersInit,
2633
- });
2634
- };
2635
-
2636
- return {
2637
- fetch,
2638
- webSocket: ConfiguredWebSocket as unknown as typeof NodeWebSocket,
2639
- };
2640
- }
2641
-
2642
- function parseArgs(argv: string[]): ParsedArgs {
2643
- const values = new Map<string, string | boolean>();
2644
-
2645
- for (let index = 0; index < argv.length; index += 1) {
2646
- const arg = argv[index];
2647
- if (!arg.startsWith('--')) {
2648
- continue;
2649
- }
2650
-
2651
- if (arg.startsWith('--no-')) {
2652
- values.set(arg.slice(5), false);
2653
- continue;
2654
- }
2655
-
2656
- const trimmed = arg.slice(2);
2657
- const separatorIndex = trimmed.indexOf('=');
2658
- if (separatorIndex !== -1) {
2659
- values.set(trimmed.slice(0, separatorIndex), trimmed.slice(separatorIndex + 1));
2660
- continue;
2661
- }
2662
-
2663
- const next = argv[index + 1];
2664
- if (next && !next.startsWith('--')) {
2665
- values.set(trimmed, next);
2666
- index += 1;
2667
- continue;
2668
- }
2669
-
2670
- values.set(trimmed, true);
2671
- }
2672
-
2673
- return {
2674
- get(key: string) {
2675
- return values.get(key);
2676
- },
2677
- };
2678
- }
2679
-
2680
- function resolveLevels(args: ParsedArgs): number[] {
2681
- const levelsArg = stringArg(args, 'levels');
2682
- if (levelsArg) {
2683
- return levelsArg
2684
- .split(',')
2685
- .map((value) => Number.parseInt(value.trim(), 10))
2686
- .filter((value) => Number.isFinite(value) && value > 0);
2687
- }
2688
-
2689
- const maxConcurrency = numberArg(args, 'max-concurrency');
2690
- if (maxConcurrency) {
2691
- const step = Math.max(1, numberArg(args, 'step') ?? 1);
2692
- const levels: number[] = [];
2693
- for (let value = 1; value <= maxConcurrency; value += step) {
2694
- levels.push(value);
2695
- }
2696
-
2697
- if (levels[levels.length - 1] !== maxConcurrency) {
2698
- levels.push(maxConcurrency);
2699
- }
2700
-
2701
- return levels;
2702
- }
2703
-
2704
- const concurrency = numberArg(args, 'concurrency');
2705
- if (concurrency) {
2706
- return [concurrency];
2707
- }
2708
-
2709
- return [1];
2710
- }
2711
-
2712
- function resolveMode(args: ParsedArgs): LoadTestMode {
2713
- if (booleanArg(args, 'init-only') === true) {
2714
- return 'init';
2715
- }
2716
-
2717
- const value = stringArg(args, 'mode');
2718
- if (value === 'init' || value === 'full') {
2719
- return value;
2720
- }
2721
-
2722
- return 'full';
2723
- }
2724
-
2725
- function stringArg(args: ParsedArgs, key: string): string | undefined {
2726
- const value = args.get(key);
2727
- return typeof value === 'string' ? value : undefined;
2728
- }
2729
-
2730
- function numberArg(args: ParsedArgs, key: string): number | undefined {
2731
- const value = args.get(key);
2732
- if (typeof value !== 'string') {
2733
- return undefined;
2734
- }
2735
-
2736
- const parsed = Number.parseInt(value, 10);
2737
- return Number.isFinite(parsed) ? parsed : undefined;
2738
- }
2739
-
2740
- function booleanArg(args: ParsedArgs, key: string): boolean | undefined {
2741
- const value = args.get(key);
2742
- if (typeof value === 'boolean') {
2743
- return value;
2744
- }
2745
-
2746
- if (typeof value === 'string') {
2747
- if (value === 'true') {
2748
- return true;
2749
- }
2750
- if (value === 'false') {
2751
- return false;
2752
- }
2753
- }
2754
-
2755
- return undefined;
2756
- }
2757
-
2758
- function listArg(args: ParsedArgs, key: string): string[] {
2759
- const value = stringArg(args, key);
2760
- if (!value) {
2761
- return [];
2762
- }
2763
-
2764
- return value
2765
- .split(/[|,]/)
2766
- .map((item) => item.trim())
2767
- .filter(Boolean);
2768
- }
2769
-
2770
- function provisioningActionArg(args: ParsedArgs, key: string): NotebookProvisioningAction | undefined {
2771
- const value = stringArg(args, key);
2772
- if (
2773
- value === 'github_import' ||
2774
- value === 'imported_project' ||
2775
- value === 'forked' ||
2776
- value === 'template'
2777
- ) {
2778
- return value;
2779
- }
2780
-
2781
- if (value) {
2782
- throw new Error(`Invalid --${key}: ${value}`);
2783
- }
2784
-
2785
- return undefined;
2786
- }
2787
-
2788
- function visibilityArg(args: ParsedArgs, key: string): Visibility | undefined {
2789
- const value = stringArg(args, key);
2790
- if (value === 'public' || value === 'private' || value === 'unlisted') {
2791
- return value;
2792
- }
2793
-
2794
- return undefined;
2795
- }
2796
-
2797
- void main().catch((error) => {
2798
- console.error(formatError(error));
2799
- process.exitCode = 1;
2800
- });