@bitclaw/loadtest 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +112 -0
  3. package/dist/__fixtures__/configs.d.ts +8 -0
  4. package/dist/__fixtures__/configs.d.ts.map +1 -0
  5. package/dist/__fixtures__/configs.js +75 -0
  6. package/dist/__fixtures__/results.d.ts +13 -0
  7. package/dist/__fixtures__/results.d.ts.map +1 -0
  8. package/dist/__fixtures__/results.js +121 -0
  9. package/dist/auth/session.d.ts +18 -0
  10. package/dist/auth/session.d.ts.map +1 -0
  11. package/dist/auth/session.js +58 -0
  12. package/dist/cli/commands/report.d.ts +6 -0
  13. package/dist/cli/commands/report.d.ts.map +1 -0
  14. package/dist/cli/commands/report.js +46 -0
  15. package/dist/cli/commands/run.d.ts +6 -0
  16. package/dist/cli/commands/run.d.ts.map +1 -0
  17. package/dist/cli/commands/run.js +97 -0
  18. package/dist/cli.d.ts +3 -0
  19. package/dist/cli.d.ts.map +1 -0
  20. package/dist/cli.js +12 -0
  21. package/dist/config/defaults.d.ts +77 -0
  22. package/dist/config/defaults.d.ts.map +1 -0
  23. package/dist/config/defaults.js +60 -0
  24. package/dist/config/loader.d.ts +14 -0
  25. package/dist/config/loader.d.ts.map +1 -0
  26. package/dist/config/loader.js +56 -0
  27. package/dist/index.d.ts +14 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +16 -0
  30. package/dist/reports/formatter.d.ts +18 -0
  31. package/dist/reports/formatter.d.ts.map +1 -0
  32. package/dist/reports/formatter.js +99 -0
  33. package/dist/runner/bun-runner.d.ts +21 -0
  34. package/dist/runner/bun-runner.d.ts.map +1 -0
  35. package/dist/runner/bun-runner.js +140 -0
  36. package/dist/runner/k6-runner.d.ts +12 -0
  37. package/dist/runner/k6-runner.d.ts.map +1 -0
  38. package/dist/runner/k6-runner.js +80 -0
  39. package/dist/types.d.ts +97 -0
  40. package/dist/types.d.ts.map +1 -0
  41. package/dist/types.js +6 -0
  42. package/package.json +44 -0
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Default thresholds per Hetzner VPS tier.
3
+ *
4
+ * These are application-level targets (full HTTP stack), not raw
5
+ * SQLite pool benchmarks. Expect lower throughput than pool.exec().
6
+ */
7
+ export declare const HETZNER_TIERS: {
8
+ readonly CPX11: {
9
+ readonly vcpu: 2;
10
+ readonly ram: 2048;
11
+ readonly monthly: 4.15;
12
+ };
13
+ readonly CPX21: {
14
+ readonly vcpu: 3;
15
+ readonly ram: 4096;
16
+ readonly monthly: 7.49;
17
+ };
18
+ readonly CPX31: {
19
+ readonly vcpu: 4;
20
+ readonly ram: 8192;
21
+ readonly monthly: 15.49;
22
+ };
23
+ readonly CPX41: {
24
+ readonly vcpu: 8;
25
+ readonly ram: 16384;
26
+ readonly monthly: 30.99;
27
+ };
28
+ };
29
+ export declare const DEFAULT_THRESHOLDS: {
30
+ /** Default (no tier specified) */
31
+ readonly default: {
32
+ readonly p95MaxMs: 500;
33
+ readonly minSuccessRate: 95;
34
+ readonly minThroughput: 50;
35
+ };
36
+ /** Per-tier overrides */
37
+ readonly tiers: {
38
+ readonly CPX11: {
39
+ readonly p95MaxMs: 800;
40
+ readonly minSuccessRate: 90;
41
+ readonly minThroughput: 30;
42
+ };
43
+ readonly CPX21: {
44
+ readonly p95MaxMs: 500;
45
+ readonly minSuccessRate: 95;
46
+ readonly minThroughput: 50;
47
+ };
48
+ readonly CPX31: {
49
+ readonly p95MaxMs: 300;
50
+ readonly minSuccessRate: 98;
51
+ readonly minThroughput: 100;
52
+ };
53
+ readonly CPX41: {
54
+ readonly p95MaxMs: 200;
55
+ readonly minSuccessRate: 99;
56
+ readonly minThroughput: 200;
57
+ };
58
+ };
59
+ };
60
+ export declare const DEFAULT_MODES: {
61
+ readonly quick: {
62
+ readonly concurrencyLevels: readonly [1, 10];
63
+ readonly durationSec: 5;
64
+ readonly warmupRequests: 3;
65
+ };
66
+ readonly full: {
67
+ readonly concurrencyLevels: readonly [10, 50, 100];
68
+ readonly durationSec: 10;
69
+ readonly warmupRequests: 5;
70
+ };
71
+ readonly stress: {
72
+ readonly concurrencyLevels: readonly [50, 100, 200, 500];
73
+ readonly durationSec: 15;
74
+ readonly warmupRequests: 10;
75
+ };
76
+ };
77
+ //# sourceMappingURL=defaults.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"defaults.d.ts","sourceRoot":"","sources":["../../src/config/defaults.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,eAAO,MAAM,aAAa;;;;;;;;;;;;;;;;;;;;;CAKhB,CAAC;AAEX,eAAO,MAAM,kBAAkB;IAC7B,kCAAkC;;;;;;IAMlC,yBAAyB;;;;;;;;;;;;;;;;;;;;;;;CAuBjB,CAAC;AAEX,eAAO,MAAM,aAAa;;;;;;;;;;;;;;;;CAgBhB,CAAC"}
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Default thresholds per Hetzner VPS tier.
3
+ *
4
+ * These are application-level targets (full HTTP stack), not raw
5
+ * SQLite pool benchmarks. Expect lower throughput than pool.exec().
6
+ */
7
+ export const HETZNER_TIERS = {
8
+ CPX11: { vcpu: 2, ram: 2048, monthly: 4.15 },
9
+ CPX21: { vcpu: 3, ram: 4096, monthly: 7.49 },
10
+ CPX31: { vcpu: 4, ram: 8192, monthly: 15.49 },
11
+ CPX41: { vcpu: 8, ram: 16384, monthly: 30.99 }
12
+ };
13
+ export const DEFAULT_THRESHOLDS = {
14
+ /** Default (no tier specified) */
15
+ default: {
16
+ p95MaxMs: 500,
17
+ minSuccessRate: 95,
18
+ minThroughput: 50
19
+ },
20
+ /** Per-tier overrides */
21
+ tiers: {
22
+ CPX11: {
23
+ p95MaxMs: 800,
24
+ minSuccessRate: 90,
25
+ minThroughput: 30
26
+ },
27
+ CPX21: {
28
+ p95MaxMs: 500,
29
+ minSuccessRate: 95,
30
+ minThroughput: 50
31
+ },
32
+ CPX31: {
33
+ p95MaxMs: 300,
34
+ minSuccessRate: 98,
35
+ minThroughput: 100
36
+ },
37
+ CPX41: {
38
+ p95MaxMs: 200,
39
+ minSuccessRate: 99,
40
+ minThroughput: 200
41
+ }
42
+ }
43
+ };
44
+ export const DEFAULT_MODES = {
45
+ quick: {
46
+ concurrencyLevels: [1, 10],
47
+ durationSec: 5,
48
+ warmupRequests: 3
49
+ },
50
+ full: {
51
+ concurrencyLevels: [10, 50, 100],
52
+ durationSec: 10,
53
+ warmupRequests: 5
54
+ },
55
+ stress: {
56
+ concurrencyLevels: [50, 100, 200, 500],
57
+ durationSec: 15,
58
+ warmupRequests: 10
59
+ }
60
+ };
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Config loader — resolves per-app loadtest.config.ts files.
3
+ */
4
+ import type { AppLoadTestConfig } from '../types';
5
+ /**
6
+ * Load the loadtest config for a given app name.
7
+ * Expects the file at `apps/{appName}/loadtest.config.ts`.
8
+ */
9
+ export declare function loadConfig(appName: string): Promise<AppLoadTestConfig>;
10
+ /**
11
+ * List all available app names that have loadtest configs.
12
+ */
13
+ export declare function listConfiguredApps(): Promise<string[]>;
14
+ //# sourceMappingURL=loader.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"loader.d.ts","sourceRoot":"","sources":["../../src/config/loader.ts"],"names":[],"mappings":"AAAA;;GAEG;AAIH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,UAAU,CAAC;AAKlD;;;GAGG;AACH,wBAAsB,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAuC5E;AAED;;GAEG;AACH,wBAAsB,kBAAkB,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC,CAe5D"}
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Config loader — resolves per-app loadtest.config.ts files.
3
+ */
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+ const REPO_ROOT = resolve(__dirname, '..', '..', '..', '..');
8
+ /**
9
+ * Load the loadtest config for a given app name.
10
+ * Expects the file at `apps/{appName}/loadtest.config.ts`.
11
+ */
12
+ export async function loadConfig(appName) {
13
+ const configPath = join(REPO_ROOT, 'apps', appName, 'loadtest.config.ts');
14
+ try {
15
+ const mod = await import(configPath);
16
+ const config = mod.default ?? mod.config;
17
+ if (!config) {
18
+ throw new Error(`No default export or named "config" export found in ${configPath}`);
19
+ }
20
+ if (config.appName !== appName) {
21
+ console.warn(`Warning: Config appName "${config.appName}" doesn't match requested "${appName}"`);
22
+ }
23
+ return config;
24
+ }
25
+ catch (error) {
26
+ const message = error instanceof Error
27
+ ? error.message
28
+ : typeof error === 'object' && error !== null && 'message' in error
29
+ ? String(error.message)
30
+ : '';
31
+ if (message.includes('Cannot find module') ||
32
+ message.includes('ERR_MODULE_NOT_FOUND')) {
33
+ throw new Error(`No loadtest config found for app "${appName}".\n` +
34
+ `Expected: apps/${appName}/loadtest.config.ts\n` +
35
+ `Create one following the pattern in apps/runmist/loadtest.config.ts`);
36
+ }
37
+ throw error;
38
+ }
39
+ }
40
+ /**
41
+ * List all available app names that have loadtest configs.
42
+ */
43
+ export async function listConfiguredApps() {
44
+ const { readdirSync, existsSync } = await import('node:fs');
45
+ const appsDir = join(REPO_ROOT, 'apps');
46
+ const apps = [];
47
+ for (const entry of readdirSync(appsDir, { withFileTypes: true })) {
48
+ if (entry.isDirectory()) {
49
+ const configPath = join(appsDir, entry.name, 'loadtest.config.ts');
50
+ if (existsSync(configPath)) {
51
+ apps.push(entry.name);
52
+ }
53
+ }
54
+ }
55
+ return apps;
56
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * @bitclaw/loadtest — Load testing infrastructure.
3
+ *
4
+ * Public API for programmatic usage from app-specific test files
5
+ * and the CLI.
6
+ */
7
+ export { authenticateSession, createSessionPool } from './auth/session';
8
+ export { DEFAULT_MODES, DEFAULT_THRESHOLDS, HETZNER_TIERS } from './config/defaults';
9
+ export { listConfiguredApps, loadConfig } from './config/loader';
10
+ export { checkThresholds, formatJson, formatReport } from './reports/formatter';
11
+ export { runAppLoadTest } from './runner/bun-runner';
12
+ export { runK6Test } from './runner/k6-runner';
13
+ export type { AppLoadTestConfig, AuthConfig, EndpointConfig, Engine, K6RunOptions, LoadTestResults, ModeConfig, RunOptions, ScenarioResult, SessionState, TestMode, ThresholdConfig, ThresholdResult, ThresholdViolation } from './types';
14
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AACxE,OAAO,EACL,aAAa,EACb,kBAAkB,EAClB,aAAa,EACd,MAAM,mBAAmB,CAAC;AAE3B,OAAO,EAAE,kBAAkB,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAEjE,OAAO,EAAE,eAAe,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAEhF,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAE/C,YAAY,EACV,iBAAiB,EACjB,UAAU,EACV,cAAc,EACd,MAAM,EACN,YAAY,EACZ,eAAe,EACf,UAAU,EACV,UAAU,EACV,cAAc,EACd,YAAY,EACZ,QAAQ,EACR,eAAe,EACf,eAAe,EACf,kBAAkB,EACnB,MAAM,SAAS,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * @bitclaw/loadtest — Load testing infrastructure.
3
+ *
4
+ * Public API for programmatic usage from app-specific test files
5
+ * and the CLI.
6
+ */
7
+ // Auth
8
+ export { authenticateSession, createSessionPool } from './auth/session';
9
+ export { DEFAULT_MODES, DEFAULT_THRESHOLDS, HETZNER_TIERS } from './config/defaults';
10
+ // Config
11
+ export { listConfiguredApps, loadConfig } from './config/loader';
12
+ // Reports
13
+ export { checkThresholds, formatJson, formatReport } from './reports/formatter';
14
+ // Runners
15
+ export { runAppLoadTest } from './runner/bun-runner';
16
+ export { runK6Test } from './runner/k6-runner';
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Report formatting and threshold checking.
3
+ */
4
+ import type { AppLoadTestConfig, LoadTestResults, ThresholdConfig, ThresholdResult } from '../types';
5
+ /**
6
+ * Format load test results as a human-readable report.
7
+ * Extends the upstream formatResults() with threshold checks.
8
+ */
9
+ export declare function formatReport(results: LoadTestResults, config?: AppLoadTestConfig, tier?: string): string;
10
+ /**
11
+ * Format results as JSON for machine consumption.
12
+ */
13
+ export declare function formatJson(results: LoadTestResults, config?: AppLoadTestConfig, tier?: string): string;
14
+ /**
15
+ * Check all scenarios against the given thresholds.
16
+ */
17
+ export declare function checkThresholds(results: LoadTestResults, thresholds: Omit<ThresholdConfig, 'tiers'>): ThresholdResult;
18
+ //# sourceMappingURL=formatter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"formatter.d.ts","sourceRoot":"","sources":["../../src/reports/formatter.ts"],"names":[],"mappings":"AAAA;;GAEG;AAGH,OAAO,KAAK,EACV,iBAAiB,EACjB,eAAe,EACf,eAAe,EACf,eAAe,EAEhB,MAAM,UAAU,CAAC;AAElB;;;GAGG;AACH,wBAAgB,YAAY,CAC1B,OAAO,EAAE,eAAe,EACxB,MAAM,CAAC,EAAE,iBAAiB,EAC1B,IAAI,CAAC,EAAE,MAAM,GACZ,MAAM,CAqCR;AAED;;GAEG;AACH,wBAAgB,UAAU,CACxB,OAAO,EAAE,eAAe,EACxB,MAAM,CAAC,EAAE,iBAAiB,EAC1B,IAAI,CAAC,EAAE,MAAM,GACZ,MAAM,CAYR;AAED;;GAEG;AACH,wBAAgB,eAAe,CAC7B,OAAO,EAAE,eAAe,EACxB,UAAU,EAAE,IAAI,CAAC,eAAe,EAAE,OAAO,CAAC,GACzC,eAAe,CAsCjB"}
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Report formatting and threshold checking.
3
+ */
4
+ import { formatResults as formatTable } from '@bitclaw/sqlite/load-test-utils';
5
+ /**
6
+ * Format load test results as a human-readable report.
7
+ * Extends the upstream formatResults() with threshold checks.
8
+ */
9
+ export function formatReport(results, config, tier) {
10
+ const lines = [];
11
+ // Use upstream table formatter
12
+ lines.push(formatTable(results));
13
+ // Add threshold results if config provided
14
+ if (config) {
15
+ const thresholds = resolveThresholds(config.thresholds, tier);
16
+ const check = checkThresholds(results, thresholds);
17
+ lines.push('');
18
+ lines.push('='.repeat(70));
19
+ lines.push(check.passed ? ' THRESHOLDS: ALL PASSED' : ' THRESHOLDS: FAILED');
20
+ if (tier) {
21
+ lines.push(` Tier: ${tier}`);
22
+ }
23
+ lines.push(` P95 max: ${thresholds.p95MaxMs}ms | Min success: ${thresholds.minSuccessRate}% | Min throughput: ${thresholds.minThroughput} req/s`);
24
+ lines.push('='.repeat(70));
25
+ if (!check.passed) {
26
+ lines.push('');
27
+ lines.push(' Violations:');
28
+ for (const v of check.violations) {
29
+ lines.push(` ${v.scenario}: ${v.metric} = ${v.actual.toFixed(1)} (threshold: ${v.threshold})`);
30
+ }
31
+ }
32
+ lines.push('');
33
+ }
34
+ return lines.join('\n');
35
+ }
36
+ /**
37
+ * Format results as JSON for machine consumption.
38
+ */
39
+ export function formatJson(results, config, tier) {
40
+ const output = {
41
+ ...results,
42
+ thresholds: undefined
43
+ };
44
+ if (config) {
45
+ const thresholds = resolveThresholds(config.thresholds, tier);
46
+ output.thresholds = checkThresholds(results, thresholds);
47
+ }
48
+ return JSON.stringify(output, null, 2);
49
+ }
50
+ /**
51
+ * Check all scenarios against the given thresholds.
52
+ */
53
+ export function checkThresholds(results, thresholds) {
54
+ const violations = [];
55
+ for (const scenario of results.scenarios) {
56
+ const label = `${scenario.label} @${scenario.concurrency}`;
57
+ if (scenario.p95 > thresholds.p95MaxMs) {
58
+ violations.push({
59
+ scenario: label,
60
+ metric: 'p95',
61
+ actual: scenario.p95,
62
+ threshold: thresholds.p95MaxMs
63
+ });
64
+ }
65
+ if (scenario.successRate < thresholds.minSuccessRate) {
66
+ violations.push({
67
+ scenario: label,
68
+ metric: 'successRate',
69
+ actual: scenario.successRate,
70
+ threshold: thresholds.minSuccessRate
71
+ });
72
+ }
73
+ if (scenario.throughput < thresholds.minThroughput) {
74
+ violations.push({
75
+ scenario: label,
76
+ metric: 'throughput',
77
+ actual: scenario.throughput,
78
+ threshold: thresholds.minThroughput
79
+ });
80
+ }
81
+ }
82
+ return {
83
+ passed: violations.length === 0,
84
+ violations
85
+ };
86
+ }
87
+ /**
88
+ * Resolve the effective thresholds for a given tier.
89
+ */
90
+ function resolveThresholds(config, tier) {
91
+ if (tier && config.tiers?.[tier]) {
92
+ return config.tiers[tier];
93
+ }
94
+ return {
95
+ p95MaxMs: config.p95MaxMs,
96
+ minSuccessRate: config.minSuccessRate,
97
+ minThroughput: config.minThroughput
98
+ };
99
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Enhanced Bun-native load test runner.
3
+ *
4
+ * Wraps the existing runLoadTest() from load-test-utils.ts and adds
5
+ * authentication support, per-app config, and threshold checking.
6
+ */
7
+ import { type LoadTestResults } from '@bitclaw/sqlite/load-test-utils';
8
+ import type { AppLoadTestConfig, TestMode } from '../types';
9
+ /**
10
+ * Run a full load test for an app using the Bun-native runner.
11
+ *
12
+ * 1. Tests public endpoints (no auth)
13
+ * 2. Authenticates session pool (if auth configured)
14
+ * 3. Tests authenticated endpoints with session cookies
15
+ * 4. Merges results
16
+ */
17
+ export declare function runAppLoadTest(config: AppLoadTestConfig, mode: TestMode, options?: {
18
+ publicOnly?: boolean;
19
+ baseUrl?: string;
20
+ }): Promise<LoadTestResults>;
21
+ //# sourceMappingURL=bun-runner.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bun-runner.d.ts","sourceRoot":"","sources":["../../src/runner/bun-runner.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAGL,KAAK,eAAe,EAErB,MAAM,iCAAiC,CAAC;AAEzC,OAAO,KAAK,EAAE,iBAAiB,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AAE5D;;;;;;;GAOG;AACH,wBAAsB,cAAc,CAClC,MAAM,EAAE,iBAAiB,EACzB,IAAI,EAAE,QAAQ,EACd,OAAO,GAAE;IAAE,UAAU,CAAC,EAAE,OAAO,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAO,GACvD,OAAO,CAAC,eAAe,CAAC,CA+H1B"}
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Enhanced Bun-native load test runner.
3
+ *
4
+ * Wraps the existing runLoadTest() from load-test-utils.ts and adds
5
+ * authentication support, per-app config, and threshold checking.
6
+ */
7
+ import { runLoadTest } from '@bitclaw/sqlite/load-test-utils';
8
+ import { createSessionPool } from '../auth/session';
9
+ /**
10
+ * Run a full load test for an app using the Bun-native runner.
11
+ *
12
+ * 1. Tests public endpoints (no auth)
13
+ * 2. Authenticates session pool (if auth configured)
14
+ * 3. Tests authenticated endpoints with session cookies
15
+ * 4. Merges results
16
+ */
17
+ export async function runAppLoadTest(config, mode, options = {}) {
18
+ const modeConfig = config.modes[mode];
19
+ const baseUrl = options.baseUrl ?? config.baseUrl;
20
+ // Verify the app is reachable
21
+ await verifyApp(baseUrl, config);
22
+ const directUrl = config.directUrl;
23
+ const allScenarios = [];
24
+ let startedAt = new Date().toISOString();
25
+ // 1. Public endpoints (CDN/main URL)
26
+ if (config.publicEndpoints.length > 0) {
27
+ const publicConfig = {
28
+ baseUrl,
29
+ endpoints: config.publicEndpoints,
30
+ concurrencyLevels: [...modeConfig.concurrencyLevels],
31
+ durationSec: modeConfig.durationSec,
32
+ warmupRequests: modeConfig.warmupRequests
33
+ };
34
+ const publicResults = await runLoadTest(publicConfig);
35
+ startedAt = publicResults.startedAt;
36
+ if (directUrl) {
37
+ for (const s of publicResults.scenarios) {
38
+ s.via = 'cdn';
39
+ s.label = `${s.label} (CDN)`;
40
+ }
41
+ }
42
+ allScenarios.push(...publicResults.scenarios);
43
+ }
44
+ // 2. Direct origin pass — run public endpoints against directUrl and interleave
45
+ if (directUrl && config.publicEndpoints.length > 0) {
46
+ await verifyApp(directUrl, config);
47
+ const directConfig = {
48
+ baseUrl: directUrl,
49
+ endpoints: config.publicEndpoints,
50
+ concurrencyLevels: [...modeConfig.concurrencyLevels],
51
+ durationSec: modeConfig.durationSec,
52
+ warmupRequests: modeConfig.warmupRequests
53
+ };
54
+ const directResults = await runLoadTest(directConfig);
55
+ for (const s of directResults.scenarios) {
56
+ s.via = 'direct';
57
+ s.label = `${s.label} (Direct)`;
58
+ }
59
+ // Interleave CDN and Direct: CDN@50, Direct@50, CDN@100, Direct@100...
60
+ const cdnScenarios = allScenarios.splice(0);
61
+ const directScenarios = directResults.scenarios;
62
+ for (let i = 0; i < cdnScenarios.length; i++) {
63
+ allScenarios.push(cdnScenarios[i]);
64
+ if (i < directScenarios.length) {
65
+ allScenarios.push(directScenarios[i]);
66
+ }
67
+ }
68
+ if (directScenarios.length > cdnScenarios.length) {
69
+ allScenarios.push(...directScenarios.slice(cdnScenarios.length));
70
+ }
71
+ }
72
+ // 2. Authenticated endpoints
73
+ if (!options.publicOnly &&
74
+ config.auth &&
75
+ config.authenticatedEndpoints.length > 0) {
76
+ const email = config.auth.credentials?.email ?? process.env[config.auth.emailEnvVar];
77
+ const password = config.auth.credentials?.password ??
78
+ process.env[config.auth.passwordEnvVar];
79
+ if (!email || !password) {
80
+ console.warn(`Skipping ${config.authenticatedEndpoints.length} authenticated endpoint(s) — no credentials provided. ` +
81
+ `Set ${config.auth.emailEnvVar} and ${config.auth.passwordEnvVar} to include them.`);
82
+ }
83
+ else {
84
+ // Determine session pool size (1 per 20 max concurrent workers)
85
+ const maxConcurrency = Math.max(...modeConfig.concurrencyLevels);
86
+ const poolSize = Math.max(1, Math.ceil(maxConcurrency / 20));
87
+ const sessions = await createSessionPool(baseUrl, config.auth, poolSize);
88
+ // Inject Cookie header into each authenticated endpoint
89
+ // Round-robin across session pool
90
+ const authedEndpoints = config.authenticatedEndpoints.map((ep, i) => ({
91
+ ...ep,
92
+ headers: {
93
+ ...ep.headers,
94
+ Cookie: sessions[i % sessions.length].cookies
95
+ }
96
+ }));
97
+ const authedConfig = {
98
+ baseUrl,
99
+ endpoints: authedEndpoints,
100
+ concurrencyLevels: [...modeConfig.concurrencyLevels],
101
+ durationSec: modeConfig.durationSec,
102
+ warmupRequests: modeConfig.warmupRequests
103
+ };
104
+ const authedResults = await runLoadTest(authedConfig);
105
+ allScenarios.push(...authedResults.scenarios);
106
+ }
107
+ }
108
+ else if (!options.publicOnly &&
109
+ config.authenticatedEndpoints.length > 0 &&
110
+ !config.auth) {
111
+ console.warn(`Skipping ${config.authenticatedEndpoints.length} authenticated endpoint(s) — no auth config provided`);
112
+ }
113
+ return {
114
+ baseUrl,
115
+ startedAt,
116
+ completedAt: new Date().toISOString(),
117
+ scenarios: allScenarios
118
+ };
119
+ }
120
+ async function verifyApp(baseUrl, config) {
121
+ const checkPath = config.publicEndpoints[0]?.path ??
122
+ config.authenticatedEndpoints[0]?.path ??
123
+ '/';
124
+ try {
125
+ const response = await fetch(`${baseUrl}${checkPath}`, {
126
+ signal: AbortSignal.timeout(5000),
127
+ redirect: 'manual'
128
+ });
129
+ if (response.status >= 500) {
130
+ throw new Error(`App returned ${response.status} at ${baseUrl}${checkPath}`);
131
+ }
132
+ }
133
+ catch (error) {
134
+ if (error instanceof Error && error.message.includes('App returned')) {
135
+ throw error;
136
+ }
137
+ throw new Error(`Cannot reach ${baseUrl}\n` +
138
+ `Make sure ${config.appName} is running (e.g., bun run dev)`);
139
+ }
140
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * k6 runner — spawns k6 as a subprocess.
3
+ *
4
+ * k6 scripts live in packages/loadtest/k6/{appName}.js and are
5
+ * hand-written JavaScript (k6 doesn't support TypeScript natively).
6
+ */
7
+ import type { K6RunOptions } from '../types';
8
+ /**
9
+ * Run a k6 load test script for the given app.
10
+ */
11
+ export declare function runK6Test(appName: string, options: K6RunOptions): Promise<void>;
12
+ //# sourceMappingURL=k6-runner.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"k6-runner.d.ts","sourceRoot":"","sources":["../../src/runner/k6-runner.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAKH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AAoB7C;;GAEG;AACH,wBAAsB,SAAS,CAC7B,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,YAAY,GACpB,OAAO,CAAC,IAAI,CAAC,CAmDf"}
@@ -0,0 +1,80 @@
1
+ /**
2
+ * k6 runner — spawns k6 as a subprocess.
3
+ *
4
+ * k6 scripts live in packages/loadtest/k6/{appName}.js and are
5
+ * hand-written JavaScript (k6 doesn't support TypeScript natively).
6
+ */
7
+ import { existsSync, readdirSync } from 'node:fs';
8
+ import { dirname, join, resolve } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+ const K6_SCRIPTS_DIR = resolve(__dirname, '..', '..', 'k6');
12
+ /**
13
+ * Check if k6 is installed.
14
+ */
15
+ async function checkK6() {
16
+ try {
17
+ const proc = Bun.spawn(['k6', 'version'], {
18
+ stdio: ['pipe', 'pipe', 'pipe']
19
+ });
20
+ const code = await proc.exited;
21
+ return code === 0;
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ }
27
+ /**
28
+ * Run a k6 load test script for the given app.
29
+ */
30
+ export async function runK6Test(appName, options) {
31
+ // Verify k6 is installed
32
+ const hasK6 = await checkK6();
33
+ if (!hasK6) {
34
+ throw new Error('k6 is not installed.\n' +
35
+ 'Install: https://grafana.com/docs/k6/latest/set-up/install-k6/\n' +
36
+ ' macOS: brew install k6\n' +
37
+ ' Linux: sudo apt install k6 (or snap install k6)\n' +
38
+ ' Docker: docker run -i grafana/k6 run -');
39
+ }
40
+ // Find the script
41
+ const scriptPath = join(K6_SCRIPTS_DIR, `${appName}.js`);
42
+ if (!existsSync(scriptPath)) {
43
+ throw new Error(`No k6 script found for "${appName}".\n` +
44
+ `Expected: packages/loadtest/k6/${appName}.js\n` +
45
+ `Available scripts: ${listK6Scripts().join(', ') || '(none)'}`);
46
+ }
47
+ // Build k6 args
48
+ const args = ['run'];
49
+ // Pass config via env vars
50
+ const env = {
51
+ ...process.env,
52
+ BASE_URL: options.baseUrl,
53
+ ...(options.email ? { LOADTEST_EMAIL: options.email } : {}),
54
+ ...(options.password ? { LOADTEST_PASSWORD: options.password } : {})
55
+ };
56
+ // JSON summary output
57
+ if (options.jsonOutput) {
58
+ args.push('--summary-export', options.jsonOutput);
59
+ }
60
+ args.push(scriptPath);
61
+ // Spawn k6 and stream output
62
+ const child = Bun.spawn(['k6', ...args], {
63
+ env,
64
+ stdio: ['inherit', 'inherit', 'inherit']
65
+ });
66
+ const exitCode = await child.exited;
67
+ if (exitCode !== 0) {
68
+ throw new Error(`k6 exited with code ${exitCode}`);
69
+ }
70
+ }
71
+ function listK6Scripts() {
72
+ try {
73
+ return readdirSync(K6_SCRIPTS_DIR)
74
+ .filter(f => f.endsWith('.js'))
75
+ .map(f => f.replace('.js', ''));
76
+ }
77
+ catch {
78
+ return [];
79
+ }
80
+ }