perf: 300명 실시간 부하 측정 도구를 추가
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { chmod, readFile, symlink, unlink, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { assertRuntimeMetadataFinalized, canonicalJson, expandWeightedOperations, loadTokens, validateLoadConfig } from '../src/config.js';
|
||||
|
||||
const samplePath = new URL('../config/300-users-900-npcs-5m.json', import.meta.url);
|
||||
|
||||
void test('the 300 viewer, 900 NPC, five-minute sample validates', async () => {
|
||||
const config = validateLoadConfig(JSON.parse(await readFile(samplePath, 'utf8')));
|
||||
assert.equal(config.capacity.authenticatedViewers, 300);
|
||||
assert.equal(config.capacity.npcGenerals, 900);
|
||||
assert.equal(config.capacity.turnIntervalMs, 300_000);
|
||||
assert.deepEqual(new Set(config.phases.map((phase) => phase.kind)), new Set(['idle', 'own', 'global', 'mixed']));
|
||||
});
|
||||
|
||||
void test('validation rejects public, non-allowlisted, and mutating targets', async () => {
|
||||
const raw = JSON.parse(await readFile(samplePath, 'utf8')) as Record<string, any>;
|
||||
raw.target.publicProfile = true;
|
||||
raw.target.baseUrl = 'https://public.example.invalid';
|
||||
raw.phases[1].operations[0].type = 'mutation';
|
||||
assert.throws(() => validateLoadConfig(raw), /publicProfile must be false/u);
|
||||
assert.throws(() => validateLoadConfig(raw), /hostname is not explicitly allowlisted/u);
|
||||
assert.throws(() => validateLoadConfig(raw), /read-only query/u);
|
||||
});
|
||||
|
||||
void test('a measurement run rejects sample metadata placeholders', async () => {
|
||||
const config = validateLoadConfig(JSON.parse(await readFile(samplePath, 'utf8')));
|
||||
assert.throws(() => assertRuntimeMetadataFinalized(config), /fixtureSha256, imageDigest, postgresVersion, redisVersion/u);
|
||||
});
|
||||
|
||||
void test('canonical JSON and weighted scheduling do not depend on object insertion order', () => {
|
||||
assert.equal(canonicalJson({ b: 2, a: 1 }), canonicalJson({ a: 1, b: 2 }));
|
||||
const expanded = expandWeightedOperations([
|
||||
{ name: 'a', procedure: 'a.read', type: 'query', weight: 2 },
|
||||
{ name: 'b', procedure: 'b.read', type: 'query', weight: 1 },
|
||||
]);
|
||||
assert.deepEqual(expanded.map((operation) => operation.name), ['a', 'a', 'b']);
|
||||
});
|
||||
|
||||
void test('token loading requires an ignored 0600 file and returns no identity metadata', async () => {
|
||||
const workspaceRoot = path.resolve(import.meta.dirname, '../../..');
|
||||
const tokenPath = path.join(workspaceRoot, 'tools/load-tests/secrets/unit-test-tokens.json');
|
||||
await writeFile(tokenPath, JSON.stringify({ tokens: ['ga_test_token_00000001'] }), { mode: 0o600 });
|
||||
await chmod(tokenPath, 0o600);
|
||||
try {
|
||||
assert.deepEqual(await loadTokens(tokenPath, workspaceRoot, 1), ['ga_test_token_00000001']);
|
||||
await chmod(tokenPath, 0o644);
|
||||
await assert.rejects(loadTokens(tokenPath, workspaceRoot, 1), /0600/u);
|
||||
} finally {
|
||||
await unlink(tokenPath).catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
void test('token loading rejects a symlink even when its link path is ignored', async () => {
|
||||
const workspaceRoot = path.resolve(import.meta.dirname, '../../..');
|
||||
const tokenPath = path.join(workspaceRoot, 'tools/load-tests/secrets/unit-test-token-target.json');
|
||||
const linkPath = path.join(workspaceRoot, 'tools/load-tests/secrets/unit-test-token-link.json');
|
||||
await writeFile(tokenPath, JSON.stringify({ tokens: ['ga_test_token_00000001'] }), { mode: 0o600 });
|
||||
await chmod(tokenPath, 0o600);
|
||||
await symlink(tokenPath, linkPath);
|
||||
try {
|
||||
await assert.rejects(loadTokens(linkPath, workspaceRoot, 1), /symbolic link/u);
|
||||
} finally {
|
||||
await unlink(linkPath).catch(() => undefined);
|
||||
await unlink(tokenPath).catch(() => undefined);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { PhaseMetrics, percentile, summarizeDistribution, summarizePhaseMetrics } from '../src/metrics.js';
|
||||
|
||||
void test('nearest-rank percentiles and summaries are deterministic', () => {
|
||||
const values = [100, 1, 5, 3, 2, 4];
|
||||
const sorted = [...values].sort((left, right) => left - right);
|
||||
assert.equal(percentile(sorted, 50), 3);
|
||||
assert.equal(percentile(sorted, 95), 100);
|
||||
assert.deepEqual(summarizeDistribution(values), {
|
||||
count: 6,
|
||||
min: 1,
|
||||
max: 100,
|
||||
mean: 19.167,
|
||||
p50: 3,
|
||||
p95: 100,
|
||||
p99: 100,
|
||||
});
|
||||
});
|
||||
|
||||
void test('phase aggregation separates success, error, latency, and event counters', () => {
|
||||
const metrics = new PhaseMetrics();
|
||||
metrics.recordHttp('own', 10, null);
|
||||
metrics.recordHttp('own', 20, 'http-500');
|
||||
metrics.recordSseEvent('ready');
|
||||
metrics.recordSseEvent('ready');
|
||||
metrics.recordHttpResult('own', 'unchanged');
|
||||
metrics.processRssBytes.push(100, 200);
|
||||
metrics.sseActiveConnections.push(0, 2);
|
||||
const summary = summarizePhaseMetrics(metrics, {
|
||||
cpuPercentOfOneCore: 5,
|
||||
rssBytes: summarizeDistribution(metrics.processRssBytes),
|
||||
eventLoopLagMs: { min: 1, max: 2, mean: 1.5, p50: 1, p95: 2, p99: 2 },
|
||||
});
|
||||
assert.deepEqual(summary.http.success, { own: 1 });
|
||||
assert.deepEqual(summary.http.errors, { 'own:http-500': 1 });
|
||||
assert.deepEqual(summary.http.results, { 'own:unchanged': 1 });
|
||||
assert.equal(summary.http.latencyMs.own?.p50, 10);
|
||||
assert.deepEqual(summary.sse.events, { ready: 2 });
|
||||
assert.equal(summary.sse.activeConnections.max, 2);
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { containsForbiddenPublicField, SseParser } from '../src/sse.js';
|
||||
|
||||
void test('SSE parser handles chunk boundaries, CRLF, comments, and multiline data', () => {
|
||||
const events: Array<{ event: string; data: string }> = [];
|
||||
const parser = new SseParser((event) => events.push(event));
|
||||
parser.push(': keepalive\r\nevent: rea');
|
||||
parser.push('dy\r\ndata: {"ok":\r\ndata: true}\r\n\r\n');
|
||||
parser.finish();
|
||||
assert.deepEqual(events, [{ event: 'ready', data: '{"ok":\ntrue}' }]);
|
||||
});
|
||||
|
||||
void test('public payload privacy scan checks nested forbidden identifiers and timing fields', () => {
|
||||
assert.equal(containsForbiddenPublicField({ type: 'readModelInvalidated', context: true }), false);
|
||||
assert.equal(containsForbiddenPublicField({ nested: { generalId: 3 } }), true);
|
||||
assert.equal(containsForbiddenPublicField([{ lastTurnTime: 'secret' }]), true);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { buildTrpcQuery, extractDashboardRevisions } from '../src/trpc.js';
|
||||
|
||||
void test('tRPC query uses bearer auth without putting the token in the URL', () => {
|
||||
const token = 'ga_example_secret_token';
|
||||
const request = buildTrpcQuery(
|
||||
'http://127.0.0.1:15001',
|
||||
'/api/trpc',
|
||||
{ name: 'own', procedure: 'dashboard.getContextBundleDelta', type: 'query', weight: 1, input: { include: { context: true } } },
|
||||
token
|
||||
);
|
||||
assert.equal(new Headers(request.init.headers).get('authorization'), `Bearer ${token}`);
|
||||
assert.equal(request.url.includes(token), false);
|
||||
assert.equal(new URL(request.url).pathname, '/api/trpc/dashboard.getContextBundleDelta');
|
||||
assert.deepEqual(JSON.parse(new URL(request.url).searchParams.get('input')!), { json: { include: { context: true } } });
|
||||
});
|
||||
|
||||
void test('dashboard observations retain only opaque revisions and aggregate-safe result kinds', () => {
|
||||
const revision = 'Abcdefghijklmnopqrstuv';
|
||||
assert.deepEqual(
|
||||
extractDashboardRevisions({
|
||||
result: {
|
||||
data: {
|
||||
json: {
|
||||
context: { kind: 'unchanged', revision, data: { general: { id: 123 } } },
|
||||
commandTable: { kind: 'snapshot', revision },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ revisions: { context: revision, commandTable: revision }, resultKinds: ['unchanged', 'snapshot'] }
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user