test: source revision capacity 계측을 추가
This commit is contained in:
@@ -5,8 +5,9 @@
|
|||||||
phase에서 실제 game API tRPC query를 실행한다. HTTP latency p50/p95/p99, 성공/오류, SSE
|
phase에서 실제 game API tRPC query를 실행한다. HTTP latency p50/p95/p99, 성공/오류, SSE
|
||||||
open/close/reconnect/event와 public payload 금지 field 수, driver CPU/RSS/event-loop lag를 raw JSON에
|
open/close/reconnect/event와 public payload 금지 field 수, driver CPU/RSS/event-loop lag를 raw JSON에
|
||||||
남긴다. token 값, 사용자/장수/도시/국가 ID와 response/event payload는 출력하지 않는다.
|
남긴다. token 값, 사용자/장수/도시/국가 ID와 response/event payload는 출력하지 않는다.
|
||||||
dashboard query의 opaque revision은 viewer별 메모리에서만 다음 `known` 입력으로 이어서
|
dashboard query의 opaque content/source revision은 viewer별 메모리에서만 다음 `known`/`knownSource`
|
||||||
unchanged/snapshot/patch 경로를 구분하며 raw JSON에는 종류별 count만 남긴다.
|
입력으로 이어서 unchanged/snapshot/patch와 source-revision fast-path 조건을 구분한다. raw JSON에는
|
||||||
|
종류별 count와 source revision 관측/전송/일치-unchanged aggregate만 남긴다.
|
||||||
|
|
||||||
## 안전 경계
|
## 안전 경계
|
||||||
|
|
||||||
@@ -52,6 +53,10 @@ pnpm --filter @sammo-ts/load-tests seed \
|
|||||||
--tokens tools/load-tests/secrets/game-tokens.json
|
--tokens tools/load-tests/secrets/game-tokens.json
|
||||||
pnpm --filter @sammo-ts/load-tests verify-fixture \
|
pnpm --filter @sammo-ts/load-tests verify-fixture \
|
||||||
--config tools/load-tests/config/300-users-900-npcs-5m.json
|
--config tools/load-tests/config/300-users-900-npcs-5m.json
|
||||||
|
|
||||||
|
pnpm --filter @sammo-ts/load-tests activate-coverage \
|
||||||
|
--config tools/load-tests/config/300-users-900-npcs-5m.json \
|
||||||
|
--confirm load_capacity_300_900_5m
|
||||||
```
|
```
|
||||||
|
|
||||||
`seed`는 해당 `load_` schema에 migration을 적용하고 scenario 2601을 고정 seed/time으로 설치한 뒤 정확히
|
`seed`는 해당 `load_` schema에 migration을 적용하고 scenario 2601을 고정 seed/time으로 설치한 뒤 정확히
|
||||||
@@ -62,6 +67,11 @@ pnpm --filter @sammo-ts/load-tests verify-fixture \
|
|||||||
fixture와 같은 환경으로 API를 띄울 때 핵심 namespace는 다음과 같다. `capacity.env` 값을 다시 명령행에
|
fixture와 같은 환경으로 API를 띄울 때 핵심 namespace는 다음과 같다. `capacity.env` 값을 다시 명령행에
|
||||||
풀어 쓰지 않는다.
|
풀어 쓰지 않는다.
|
||||||
|
|
||||||
|
`activate-coverage`는 Redis의 전용 fixture manifest와 schema 확인 문자열을 모두 요구한 뒤, infra의
|
||||||
|
advisory-lock/CAS transaction을 그대로 호출해 coverage 1과 초기 shared revision head를 활성화한다.
|
||||||
|
공유 schema나 manifest가 없는 runtime에는 실행되지 않는다. activation 전후의 coverage/head/outbox는
|
||||||
|
`verify-fixture`의 비밀값 없는 aggregate로 확인할 수 있다.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
pnpm --filter @sammo-ts/common build
|
pnpm --filter @sammo-ts/common build
|
||||||
pnpm --filter @sammo-ts/logic build
|
pnpm --filter @sammo-ts/logic build
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"prepare:capacity": "pnpm -w exec tsx tools/load-tests/src/cli.ts prepare",
|
"prepare:capacity": "pnpm -w exec tsx tools/load-tests/src/cli.ts prepare",
|
||||||
"seed": "pnpm -w exec tsx tools/load-tests/src/cli.ts seed",
|
"seed": "pnpm -w exec tsx tools/load-tests/src/cli.ts seed",
|
||||||
"verify-fixture": "pnpm -w exec tsx tools/load-tests/src/cli.ts verify-fixture",
|
"verify-fixture": "pnpm -w exec tsx tools/load-tests/src/cli.ts verify-fixture",
|
||||||
|
"activate-coverage": "pnpm -w exec tsx tools/load-tests/src/cli.ts activate-coverage",
|
||||||
"materialize-calibration": "pnpm -w exec tsx tools/load-tests/src/cli.ts materialize-calibration",
|
"materialize-calibration": "pnpm -w exec tsx tools/load-tests/src/cli.ts materialize-calibration",
|
||||||
"cleanup": "pnpm -w exec tsx tools/load-tests/src/cli.ts cleanup",
|
"cleanup": "pnpm -w exec tsx tools/load-tests/src/cli.ts cleanup",
|
||||||
"test": "pnpm -w exec tsx --test tools/load-tests/test/*.test.ts",
|
"test": "pnpm -w exec tsx --test tools/load-tests/test/*.test.ts",
|
||||||
|
|||||||
+64
-12
@@ -3,6 +3,7 @@ import path from 'node:path';
|
|||||||
|
|
||||||
import { assertRuntimeMetadataFinalized, loadConfig, loadTokens } from './config.js';
|
import { assertRuntimeMetadataFinalized, loadConfig, loadTokens } from './config.js';
|
||||||
import {
|
import {
|
||||||
|
activateCapacityCoverage,
|
||||||
cleanupCapacityFixture,
|
cleanupCapacityFixture,
|
||||||
materializeCalibrationConfig,
|
materializeCalibrationConfig,
|
||||||
prepareCapacitySecrets,
|
prepareCapacitySecrets,
|
||||||
@@ -11,16 +12,42 @@ import {
|
|||||||
} from './fixture.js';
|
} from './fixture.js';
|
||||||
import { describeDryRun, runLoadTest } from './runner.js';
|
import { describeDryRun, runLoadTest } from './runner.js';
|
||||||
|
|
||||||
type Command = 'run' | 'dry-run' | 'validate' | 'prepare' | 'seed' | 'verify-fixture' | 'materialize-calibration' | 'cleanup';
|
type Command =
|
||||||
|
| 'run'
|
||||||
|
| 'dry-run'
|
||||||
|
| 'validate'
|
||||||
|
| 'prepare'
|
||||||
|
| 'seed'
|
||||||
|
| 'verify-fixture'
|
||||||
|
| 'activate-coverage'
|
||||||
|
| 'materialize-calibration'
|
||||||
|
| 'cleanup';
|
||||||
|
|
||||||
const usage = (): never => {
|
const usage = (): never => {
|
||||||
process.stderr.write('usage: cli.ts <validate|dry-run|run|prepare|seed|verify-fixture|materialize-calibration|cleanup> --config <file> [--tokens <0600-gitignored-file>] [--output <new-json-file>] [--confirm <load_schema>]\n');
|
process.stderr.write(
|
||||||
|
'usage: cli.ts <validate|dry-run|run|prepare|seed|verify-fixture|activate-coverage|materialize-calibration|cleanup> --config <file> [--tokens <0600-gitignored-file>] [--output <new-json-file>] [--confirm <load_schema>]\n'
|
||||||
|
);
|
||||||
process.exit(64);
|
process.exit(64);
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseArguments = (argv: readonly string[]): { command: Command; config: string; tokens?: string; output?: string; confirm?: string } => {
|
const parseArguments = (
|
||||||
|
argv: readonly string[]
|
||||||
|
): { command: Command; config: string; tokens?: string; output?: string; confirm?: string } => {
|
||||||
const command = argv[0];
|
const command = argv[0];
|
||||||
if (!['run', 'dry-run', 'validate', 'prepare', 'seed', 'verify-fixture', 'materialize-calibration', 'cleanup'].includes(command ?? '')) usage();
|
if (
|
||||||
|
![
|
||||||
|
'run',
|
||||||
|
'dry-run',
|
||||||
|
'validate',
|
||||||
|
'prepare',
|
||||||
|
'seed',
|
||||||
|
'verify-fixture',
|
||||||
|
'activate-coverage',
|
||||||
|
'materialize-calibration',
|
||||||
|
'cleanup',
|
||||||
|
].includes(command ?? '')
|
||||||
|
)
|
||||||
|
usage();
|
||||||
const values = new Map<string, string>();
|
const values = new Map<string, string>();
|
||||||
for (let index = 1; index < argv.length; index += 2) {
|
for (let index = 1; index < argv.length; index += 2) {
|
||||||
const flag = argv[index];
|
const flag = argv[index];
|
||||||
@@ -33,10 +60,22 @@ const parseArguments = (argv: readonly string[]): { command: Command; config: st
|
|||||||
if (command === 'run' && (!values.get('--tokens') || !values.get('--output'))) usage();
|
if (command === 'run' && (!values.get('--tokens') || !values.get('--output'))) usage();
|
||||||
if (command === 'seed' && (!values.get('--tokens') || values.has('--output') || values.has('--confirm'))) usage();
|
if (command === 'seed' && (!values.get('--tokens') || values.has('--output') || values.has('--confirm'))) usage();
|
||||||
if (command === 'prepare' && (values.has('--tokens') || values.has('--output') || values.has('--confirm'))) usage();
|
if (command === 'prepare' && (values.has('--tokens') || values.has('--output') || values.has('--confirm'))) usage();
|
||||||
if (command === 'cleanup' && (!values.get('--confirm') || values.has('--tokens') || values.has('--output'))) usage();
|
if (command === 'cleanup' && (!values.get('--confirm') || values.has('--tokens') || values.has('--output')))
|
||||||
if (command === 'verify-fixture' && (values.has('--tokens') || values.has('--output') || values.has('--confirm'))) usage();
|
usage();
|
||||||
if (command === 'materialize-calibration' && (!values.get('--output') || values.has('--tokens') || values.has('--confirm'))) usage();
|
if (command === 'verify-fixture' && (values.has('--tokens') || values.has('--output') || values.has('--confirm')))
|
||||||
if (command === 'validate' && (values.has('--tokens') || values.has('--output') || values.has('--confirm'))) usage();
|
usage();
|
||||||
|
if (
|
||||||
|
command === 'activate-coverage' &&
|
||||||
|
(!values.get('--confirm') || values.has('--tokens') || values.has('--output'))
|
||||||
|
)
|
||||||
|
usage();
|
||||||
|
if (
|
||||||
|
command === 'materialize-calibration' &&
|
||||||
|
(!values.get('--output') || values.has('--tokens') || values.has('--confirm'))
|
||||||
|
)
|
||||||
|
usage();
|
||||||
|
if (command === 'validate' && (values.has('--tokens') || values.has('--output') || values.has('--confirm')))
|
||||||
|
usage();
|
||||||
if (command === 'dry-run' && (values.has('--output') || values.has('--confirm'))) usage();
|
if (command === 'dry-run' && (values.has('--output') || values.has('--confirm'))) usage();
|
||||||
if (command === 'run' && values.has('--confirm')) usage();
|
if (command === 'run' && values.has('--confirm')) usage();
|
||||||
return {
|
return {
|
||||||
@@ -73,6 +112,10 @@ const main = async (): Promise<void> => {
|
|||||||
process.stdout.write(`${JSON.stringify(await verifyCapacityFixture(config))}\n`);
|
process.stdout.write(`${JSON.stringify(await verifyCapacityFixture(config))}\n`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (args.command === 'activate-coverage') {
|
||||||
|
process.stdout.write(`${JSON.stringify(await activateCapacityCoverage(config, args.confirm!))}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (args.command === 'materialize-calibration') {
|
if (args.command === 'materialize-calibration') {
|
||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
`${JSON.stringify(
|
`${JSON.stringify(
|
||||||
@@ -89,9 +132,13 @@ const main = async (): Promise<void> => {
|
|||||||
process.stdout.write(`${JSON.stringify(await cleanupCapacityFixture(config, args.confirm!))}\n`);
|
process.stdout.write(`${JSON.stringify(await cleanupCapacityFixture(config, args.confirm!))}\n`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const tokens = args.tokens ? await loadTokens(args.tokens, workspaceRoot, config.capacity.authenticatedViewers) : null;
|
const tokens = args.tokens
|
||||||
|
? await loadTokens(args.tokens, workspaceRoot, config.capacity.authenticatedViewers)
|
||||||
|
: null;
|
||||||
if (args.command === 'dry-run') {
|
if (args.command === 'dry-run') {
|
||||||
process.stdout.write(`${JSON.stringify({ valid: true, tokenFileValidated: tokens !== null, configSha256: sha256, plan: describeDryRun(config) }, null, 2)}\n`);
|
process.stdout.write(
|
||||||
|
`${JSON.stringify({ valid: true, tokenFileValidated: tokens !== null, configSha256: sha256, plan: describeDryRun(config) }, null, 2)}\n`
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
assertRuntimeMetadataFinalized(config);
|
assertRuntimeMetadataFinalized(config);
|
||||||
@@ -99,8 +146,13 @@ const main = async (): Promise<void> => {
|
|||||||
await mkdir(path.dirname(output), { recursive: true });
|
await mkdir(path.dirname(output), { recursive: true });
|
||||||
const result = await runLoadTest({ config, configSha256: sha256, tokens: tokens!, workspaceRoot });
|
const result = await runLoadTest({ config, configSha256: sha256, tokens: tokens!, workspaceRoot });
|
||||||
await writeFile(output, `${JSON.stringify(result, null, 2)}\n`, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
|
await writeFile(output, `${JSON.stringify(result, null, 2)}\n`, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
|
||||||
const failedRequests = result.phases.reduce((total, phase) => total + Object.values(phase.metrics.http.errors).reduce((sum, count) => sum + count, 0), 0);
|
const failedRequests = result.phases.reduce(
|
||||||
process.stdout.write(`${JSON.stringify({ completed: true, phases: result.phases.length, failedRequests, outputWritten: true })}\n`);
|
(total, phase) => total + Object.values(phase.metrics.http.errors).reduce((sum, count) => sum + count, 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
process.stdout.write(
|
||||||
|
`${JSON.stringify({ completed: true, phases: result.phases.length, failedRequests, outputWritten: true })}\n`
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
main().catch((error: unknown) => {
|
main().catch((error: unknown) => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { promisify } from 'node:util';
|
|||||||
|
|
||||||
import { seedScenarioToDatabase } from '@sammo-ts/game-engine';
|
import { seedScenarioToDatabase } from '@sammo-ts/game-engine';
|
||||||
import {
|
import {
|
||||||
|
activateReadModelRevisionCoverage,
|
||||||
createGamePostgresConnector,
|
createGamePostgresConnector,
|
||||||
createRedisConnector,
|
createRedisConnector,
|
||||||
GamePrisma,
|
GamePrisma,
|
||||||
@@ -264,7 +265,10 @@ const resizeSeededGenerals = async (db: GamePrismaClient, config: LoadConfig): P
|
|||||||
where: { id: world.id },
|
where: { id: world.id },
|
||||||
data: {
|
data: {
|
||||||
tickSeconds: Math.trunc(config.capacity.turnIntervalMs / 1_000),
|
tickSeconds: Math.trunc(config.capacity.turnIntervalMs / 1_000),
|
||||||
meta: { ...(world.meta as Record<string, unknown>), lastGeneralId: rows.length } as GamePrisma.InputJsonValue,
|
meta: {
|
||||||
|
...(world.meta as Record<string, unknown>),
|
||||||
|
lastGeneralId: rows.length,
|
||||||
|
} as GamePrisma.InputJsonValue,
|
||||||
config: {
|
config: {
|
||||||
...(world.config as Record<string, unknown>),
|
...(world.config as Record<string, unknown>),
|
||||||
maxUserCnt: expectedHuman,
|
maxUserCnt: expectedHuman,
|
||||||
@@ -335,8 +339,9 @@ export const seedCapacityFixture = async (options: {
|
|||||||
await deleteMatchingRedisKeys(redis.client, `${accessKeyPrefix(options.config)}ga_*`);
|
await deleteMatchingRedisKeys(redis.client, `${accessKeyPrefix(options.config)}ga_*`);
|
||||||
const issuedAt = new Date().toISOString();
|
const issuedAt = new Date().toISOString();
|
||||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1_000).toISOString();
|
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1_000).toISOString();
|
||||||
const tokens = Array.from({ length: options.config.capacity.authenticatedViewers }, () =>
|
const tokens = Array.from(
|
||||||
`ga_${randomUUID()}`
|
{ length: options.config.capacity.authenticatedViewers },
|
||||||
|
() => `ga_${randomUUID()}`
|
||||||
);
|
);
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
tokens.map((token, index) => {
|
tokens.map((token, index) => {
|
||||||
@@ -401,9 +406,16 @@ export const verifyCapacityFixture = async (config: LoadConfig, env: NodeJS.Proc
|
|||||||
const redis = createRedisConnector({ url: environment.redisUrl });
|
const redis = createRedisConnector({ url: environment.redisUrl });
|
||||||
await postgres.connect();
|
await postgres.connect();
|
||||||
try {
|
try {
|
||||||
const [state, postgresRows] = await Promise.all([
|
const [state, postgresRows, revisionMeta, revisionHeads, pendingOutbox] = await Promise.all([
|
||||||
projectFixtureState(postgres.prisma),
|
projectFixtureState(postgres.prisma),
|
||||||
postgres.prisma.$queryRaw<Array<{ version: string }>>(GamePrisma.sql`SELECT version()`),
|
postgres.prisma.$queryRaw<Array<{ version: string }>>(GamePrisma.sql`SELECT version()`),
|
||||||
|
postgres.prisma.readModelRevisionMeta.findUnique({ where: { id: 1 } }),
|
||||||
|
postgres.prisma.readModelRevision.findMany({
|
||||||
|
where: { domain: { in: ['dashboard.global', 'map.world'] }, entityId: 0 },
|
||||||
|
orderBy: { domain: 'asc' },
|
||||||
|
select: { domain: true, revision: true },
|
||||||
|
}),
|
||||||
|
postgres.prisma.readModelOutbox.count({ where: { deliveredAt: null } }),
|
||||||
]);
|
]);
|
||||||
const fixtureSha256 = `sha256:${sha256(canonicalJson(state))}`;
|
const fixtureSha256 = `sha256:${sha256(canonicalJson(state))}`;
|
||||||
await redis.connect();
|
await redis.connect();
|
||||||
@@ -413,8 +425,7 @@ export const verifyCapacityFixture = async (config: LoadConfig, env: NodeJS.Proc
|
|||||||
if (rawManifest) {
|
if (rawManifest) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(rawManifest) as Record<string, unknown>;
|
const parsed = JSON.parse(rawManifest) as Record<string, unknown>;
|
||||||
manifestFixtureSha256 =
|
manifestFixtureSha256 = typeof parsed.fixtureSha256 === 'string' ? parsed.fixtureSha256 : null;
|
||||||
typeof parsed.fixtureSha256 === 'string' ? parsed.fixtureSha256 : null;
|
|
||||||
} catch {
|
} catch {
|
||||||
manifestFixtureSha256 = null;
|
manifestFixtureSha256 = null;
|
||||||
}
|
}
|
||||||
@@ -423,9 +434,7 @@ export const verifyCapacityFixture = async (config: LoadConfig, env: NodeJS.Proc
|
|||||||
const redisInfo = await redis.client.info('server');
|
const redisInfo = await redis.client.info('server');
|
||||||
const redisVersion = /^redis_version:(.+)$/mu.exec(redisInfo)?.[1]?.trim() ?? 'unknown';
|
const redisVersion = /^redis_version:(.+)$/mu.exec(redisInfo)?.[1]?.trim() ?? 'unknown';
|
||||||
const npcGenerals = state.generals.filter((general) => general.npcState >= 2).length;
|
const npcGenerals = state.generals.filter((general) => general.npcState >= 2).length;
|
||||||
const humanGenerals = state.generals.filter(
|
const humanGenerals = state.generals.filter((general) => general.npcState === 0 && general.userId).length;
|
||||||
(general) => general.npcState === 0 && general.userId
|
|
||||||
).length;
|
|
||||||
const valid =
|
const valid =
|
||||||
state.generals.length === config.capacity.npcGenerals + config.capacity.humanGenerals &&
|
state.generals.length === config.capacity.npcGenerals + config.capacity.humanGenerals &&
|
||||||
npcGenerals === config.capacity.npcGenerals &&
|
npcGenerals === config.capacity.npcGenerals &&
|
||||||
@@ -443,6 +452,12 @@ export const verifyCapacityFixture = async (config: LoadConfig, env: NodeJS.Proc
|
|||||||
redisManifestMatches: manifestFixtureSha256 === fixtureSha256,
|
redisManifestMatches: manifestFixtureSha256 === fixtureSha256,
|
||||||
postgresVersion: postgresRows[0]?.version ?? 'unknown',
|
postgresVersion: postgresRows[0]?.version ?? 'unknown',
|
||||||
redisVersion,
|
redisVersion,
|
||||||
|
coverageVersion: revisionMeta?.coverageVersion ?? null,
|
||||||
|
revisionHeads: revisionHeads.map((head) => ({
|
||||||
|
domain: head.domain,
|
||||||
|
revision: head.revision.toString(),
|
||||||
|
})),
|
||||||
|
pendingOutbox,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
await redis.disconnect();
|
await redis.disconnect();
|
||||||
@@ -452,6 +467,32 @@ export const verifyCapacityFixture = async (config: LoadConfig, env: NodeJS.Proc
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const activateCapacityCoverage = async (
|
||||||
|
config: LoadConfig,
|
||||||
|
confirmation: string,
|
||||||
|
env: NodeJS.ProcessEnv = process.env
|
||||||
|
) => {
|
||||||
|
if (confirmation !== config.isolation.postgresSchema) {
|
||||||
|
throw new Error('coverage activation confirmation must exactly equal isolation.postgresSchema');
|
||||||
|
}
|
||||||
|
const environment = requireEnvironment(env);
|
||||||
|
assertFixtureIsolation(config, environment);
|
||||||
|
const fixture = await verifyCapacityFixture(config, env);
|
||||||
|
if (!fixture.valid) {
|
||||||
|
throw new Error('fixture verification failed; refusing coverage activation');
|
||||||
|
}
|
||||||
|
const postgres = createGamePostgresConnector({ url: environment.databaseUrl });
|
||||||
|
await postgres.connect();
|
||||||
|
try {
|
||||||
|
const result = await postgres.prisma.$transaction((transaction) =>
|
||||||
|
activateReadModelRevisionCoverage(transaction, 0)
|
||||||
|
);
|
||||||
|
return { activated: true, ...result };
|
||||||
|
} finally {
|
||||||
|
await postgres.disconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const materializeCalibrationConfig = async (options: {
|
export const materializeCalibrationConfig = async (options: {
|
||||||
config: LoadConfig;
|
config: LoadConfig;
|
||||||
outputPath: string;
|
outputPath: string;
|
||||||
@@ -473,9 +514,7 @@ export const materializeCalibrationConfig = async (options: {
|
|||||||
}
|
}
|
||||||
const verified = await verifyCapacityFixture(options.config, env);
|
const verified = await verifyCapacityFixture(options.config, env);
|
||||||
if (!verified.valid) throw new Error('fixture verification failed; refusing to materialize calibration config');
|
if (!verified.valid) throw new Error('fixture verification failed; refusing to materialize calibration config');
|
||||||
const gitCommit = (
|
const gitCommit = (await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: options.workspaceRoot })).stdout.trim();
|
||||||
await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: options.workspaceRoot })
|
|
||||||
).stdout.trim();
|
|
||||||
const runtimeConfig: LoadConfig = {
|
const runtimeConfig: LoadConfig = {
|
||||||
...options.config,
|
...options.config,
|
||||||
name: `${options.config.name}-calibration`,
|
name: `${options.config.name}-calibration`,
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ export class PhaseMetrics {
|
|||||||
sseReconnects = 0;
|
sseReconnects = 0;
|
||||||
sseFailures = 0;
|
sseFailures = 0;
|
||||||
ssePrivacyViolations = 0;
|
ssePrivacyViolations = 0;
|
||||||
|
httpSourceRevisionObserved = 0;
|
||||||
|
httpSourceRevisionKnownSent = 0;
|
||||||
|
httpSourceRevisionMatchedUnchanged = 0;
|
||||||
|
|
||||||
recordHttp(name: string, latencyMs: number, outcome: string | null): void {
|
recordHttp(name: string, latencyMs: number, outcome: string | null): void {
|
||||||
const values = this.httpLatencyMs.get(name) ?? [];
|
const values = this.httpLatencyMs.get(name) ?? [];
|
||||||
@@ -81,6 +84,11 @@ export interface PhaseMetricSummary {
|
|||||||
errors: Record<string, number>;
|
errors: Record<string, number>;
|
||||||
results: Record<string, number>;
|
results: Record<string, number>;
|
||||||
latencyMs: Record<string, DistributionSummary>;
|
latencyMs: Record<string, DistributionSummary>;
|
||||||
|
sourceRevision: {
|
||||||
|
observed: number;
|
||||||
|
knownSent: number;
|
||||||
|
matchedUnchanged: number;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
sse: {
|
sse: {
|
||||||
attempts: number;
|
attempts: number;
|
||||||
@@ -143,7 +151,10 @@ export class ProcessSampler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const summarizePhaseMetrics = (metrics: PhaseMetrics, processSummary: PhaseMetricSummary['process']): PhaseMetricSummary => ({
|
export const summarizePhaseMetrics = (
|
||||||
|
metrics: PhaseMetrics,
|
||||||
|
processSummary: PhaseMetricSummary['process']
|
||||||
|
): PhaseMetricSummary => ({
|
||||||
http: {
|
http: {
|
||||||
success: mapToObject(metrics.httpSuccess),
|
success: mapToObject(metrics.httpSuccess),
|
||||||
errors: mapToObject(metrics.httpErrors),
|
errors: mapToObject(metrics.httpErrors),
|
||||||
@@ -153,6 +164,11 @@ export const summarizePhaseMetrics = (metrics: PhaseMetrics, processSummary: Pha
|
|||||||
.sort(([left], [right]) => left.localeCompare(right))
|
.sort(([left], [right]) => left.localeCompare(right))
|
||||||
.map(([name, values]) => [name, summarizeDistribution(values)])
|
.map(([name, values]) => [name, summarizeDistribution(values)])
|
||||||
),
|
),
|
||||||
|
sourceRevision: {
|
||||||
|
observed: metrics.httpSourceRevisionObserved,
|
||||||
|
knownSent: metrics.httpSourceRevisionKnownSent,
|
||||||
|
matchedUnchanged: metrics.httpSourceRevisionMatchedUnchanged,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
sse: {
|
sse: {
|
||||||
attempts: metrics.sseAttempts,
|
attempts: metrics.sseAttempts,
|
||||||
|
|||||||
@@ -56,8 +56,13 @@ const runtimeAndHost = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const gitMetadata = async (workspaceRoot: string) => {
|
const gitMetadata = async (workspaceRoot: string) => {
|
||||||
const run = async (args: string[]): Promise<string> => (await execFileAsync('git', args, { cwd: workspaceRoot })).stdout.trim();
|
const run = async (args: string[]): Promise<string> =>
|
||||||
const [commit, tree, status] = await Promise.all([run(['rev-parse', 'HEAD']), run(['rev-parse', 'HEAD^{tree}']), run(['status', '--porcelain=v1'])]);
|
(await execFileAsync('git', args, { cwd: workspaceRoot })).stdout.trim();
|
||||||
|
const [commit, tree, status] = await Promise.all([
|
||||||
|
run(['rev-parse', 'HEAD']),
|
||||||
|
run(['rev-parse', 'HEAD^{tree}']),
|
||||||
|
run(['status', '--porcelain=v1']),
|
||||||
|
]);
|
||||||
return { commit, tree, dirty: status.length > 0 };
|
return { commit, tree, dirty: status.length > 0 };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -77,22 +82,28 @@ const runHttpViewers = async (options: {
|
|||||||
await wait(stagger, options.signal);
|
await wait(stagger, options.signal);
|
||||||
let iteration = 0;
|
let iteration = 0;
|
||||||
let dashboardRevisions: DashboardRevisions = {};
|
let dashboardRevisions: DashboardRevisions = {};
|
||||||
|
let dashboardSourceRevisions: DashboardRevisions = {};
|
||||||
while (!options.signal.aborted) {
|
while (!options.signal.aborted) {
|
||||||
const configuredOperation = schedule[(viewerIndex + iteration) % schedule.length]!;
|
const configuredOperation = schedule[(viewerIndex + iteration) % schedule.length]!;
|
||||||
const operation =
|
const operation =
|
||||||
configuredOperation.procedure === 'dashboard.getContextBundleDelta' && Object.keys(dashboardRevisions).length > 0
|
configuredOperation.procedure === 'dashboard.getContextBundleDelta' &&
|
||||||
|
Object.keys(dashboardRevisions).length > 0
|
||||||
? {
|
? {
|
||||||
...configuredOperation,
|
...configuredOperation,
|
||||||
input: {
|
input: {
|
||||||
...(typeof configuredOperation.input === 'object' && configuredOperation.input !== null
|
...(typeof configuredOperation.input === 'object' &&
|
||||||
|
configuredOperation.input !== null
|
||||||
? configuredOperation.input
|
? configuredOperation.input
|
||||||
: {}),
|
: {}),
|
||||||
known: dashboardRevisions,
|
known: dashboardRevisions,
|
||||||
|
...(Object.keys(dashboardSourceRevisions).length > 0
|
||||||
|
? { knownSource: dashboardSourceRevisions }
|
||||||
|
: {}),
|
||||||
forceSnapshot: false,
|
forceSnapshot: false,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: configuredOperation;
|
: configuredOperation;
|
||||||
const observedRevisions = await executeTrpcQuery({
|
const observation = await executeTrpcQuery({
|
||||||
baseUrl: options.config.target.baseUrl,
|
baseUrl: options.config.target.baseUrl,
|
||||||
trpcPath: options.config.target.trpcPath,
|
trpcPath: options.config.target.trpcPath,
|
||||||
operation,
|
operation,
|
||||||
@@ -100,7 +111,13 @@ const runHttpViewers = async (options: {
|
|||||||
signal: options.signal,
|
signal: options.signal,
|
||||||
metrics: options.metrics,
|
metrics: options.metrics,
|
||||||
});
|
});
|
||||||
if (observedRevisions) dashboardRevisions = { ...dashboardRevisions, ...observedRevisions };
|
if (observation) {
|
||||||
|
dashboardRevisions = { ...dashboardRevisions, ...observation.revisions };
|
||||||
|
dashboardSourceRevisions = {
|
||||||
|
...dashboardSourceRevisions,
|
||||||
|
...observation.sourceRevisions,
|
||||||
|
};
|
||||||
|
}
|
||||||
iteration += 1;
|
iteration += 1;
|
||||||
await wait(interval, options.signal);
|
await wait(interval, options.signal);
|
||||||
}
|
}
|
||||||
@@ -194,7 +211,11 @@ export const runLoadTest = async (options: {
|
|||||||
git,
|
git,
|
||||||
runtime: environment.runtime,
|
runtime: environment.runtime,
|
||||||
host: environment.host,
|
host: environment.host,
|
||||||
hashes: { configSha256: options.configSha256, runtimeSha256: environment.runtimeSha256, hostSha256: environment.hostSha256 },
|
hashes: {
|
||||||
|
configSha256: options.configSha256,
|
||||||
|
runtimeSha256: environment.runtimeSha256,
|
||||||
|
hostSha256: environment.hostSha256,
|
||||||
|
},
|
||||||
targetRuntime: options.config.runtimeMetadata,
|
targetRuntime: options.config.runtimeMetadata,
|
||||||
phases,
|
phases,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ export interface TrpcRequest {
|
|||||||
init: RequestInit;
|
init: RequestInit;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const buildTrpcQuery = (baseUrl: string, trpcPath: string, operation: LoadOperation, token: string): TrpcRequest => {
|
export const buildTrpcQuery = (
|
||||||
|
baseUrl: string,
|
||||||
|
trpcPath: string,
|
||||||
|
operation: LoadOperation,
|
||||||
|
token: string
|
||||||
|
): TrpcRequest => {
|
||||||
const normalizedPath = trpcPath.endsWith('/') ? trpcPath.slice(0, -1) : trpcPath;
|
const normalizedPath = trpcPath.endsWith('/') ? trpcPath.slice(0, -1) : trpcPath;
|
||||||
const url = new URL(`${normalizedPath}/${operation.procedure}`, baseUrl);
|
const url = new URL(`${normalizedPath}/${operation.procedure}`, baseUrl);
|
||||||
if (operation.input !== undefined) url.searchParams.set('input', JSON.stringify(operation.input));
|
if (operation.input !== undefined) url.searchParams.set('input', JSON.stringify(operation.input));
|
||||||
@@ -25,7 +30,12 @@ const classifyTrpcPayload = (payload: unknown): string | null => {
|
|||||||
const error = (payload as { error?: unknown }).error;
|
const error = (payload as { error?: unknown }).error;
|
||||||
if (typeof error === 'object' && error !== null && 'data' in error) {
|
if (typeof error === 'object' && error !== null && 'data' in error) {
|
||||||
const data = (error as { data?: unknown }).data;
|
const data = (error as { data?: unknown }).data;
|
||||||
if (typeof data === 'object' && data !== null && 'code' in data && typeof (data as { code?: unknown }).code === 'string') {
|
if (
|
||||||
|
typeof data === 'object' &&
|
||||||
|
data !== null &&
|
||||||
|
'code' in data &&
|
||||||
|
typeof (data as { code?: unknown }).code === 'string'
|
||||||
|
) {
|
||||||
const code = (data as { code: string }).code;
|
const code = (data as { code: string }).code;
|
||||||
return `trpc-${/^[A-Z_]+$/u.test(code) ? code.toLowerCase() : 'error'}`;
|
return `trpc-${/^[A-Z_]+$/u.test(code) ? code.toLowerCase() : 'error'}`;
|
||||||
}
|
}
|
||||||
@@ -50,11 +60,20 @@ export interface DashboardRevisions {
|
|||||||
boardAccess?: string;
|
boardAccess?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const extractDashboardRevisions = (payload: unknown): { revisions: DashboardRevisions; resultKinds: string[] } | null => {
|
export interface DashboardObservation {
|
||||||
|
revisions: DashboardRevisions;
|
||||||
|
sourceRevisions: DashboardRevisions;
|
||||||
|
resultKinds: string[];
|
||||||
|
resultKindsBySlice: Partial<Record<keyof DashboardRevisions, string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const extractDashboardRevisions = (payload: unknown): DashboardObservation | null => {
|
||||||
const data = asRecord(unwrapTrpcData(payload));
|
const data = asRecord(unwrapTrpcData(payload));
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
const revisions: DashboardRevisions = {};
|
const revisions: DashboardRevisions = {};
|
||||||
|
const sourceRevisions: DashboardRevisions = {};
|
||||||
const resultKinds: string[] = [];
|
const resultKinds: string[] = [];
|
||||||
|
const resultKindsBySlice: DashboardObservation['resultKindsBySlice'] = {};
|
||||||
for (const [wireName, outputName] of [
|
for (const [wireName, outputName] of [
|
||||||
['context', 'context'],
|
['context', 'context'],
|
||||||
['commandTable', 'commandTable'],
|
['commandTable', 'commandTable'],
|
||||||
@@ -62,11 +81,19 @@ export const extractDashboardRevisions = (payload: unknown): { revisions: Dashbo
|
|||||||
] as const) {
|
] as const) {
|
||||||
const slice = asRecord(data[wireName]);
|
const slice = asRecord(data[wireName]);
|
||||||
if (!slice) continue;
|
if (!slice) continue;
|
||||||
if (typeof slice.revision === 'string' && /^[A-Za-z0-9_-]{22}$/u.test(slice.revision)) revisions[outputName] = slice.revision;
|
if (typeof slice.revision === 'string' && /^[A-Za-z0-9_-]{22}$/u.test(slice.revision))
|
||||||
if (typeof slice.kind === 'string' && ['unchanged', 'snapshot', 'patch'].includes(slice.kind)) resultKinds.push(slice.kind);
|
revisions[outputName] = slice.revision;
|
||||||
else resultKinds.push('other');
|
if (typeof slice.sourceRevision === 'string' && /^[A-Za-z0-9_-]{22}$/u.test(slice.sourceRevision)) {
|
||||||
|
sourceRevisions[outputName] = slice.sourceRevision;
|
||||||
|
}
|
||||||
|
const resultKind =
|
||||||
|
typeof slice.kind === 'string' && ['unchanged', 'snapshot', 'patch'].includes(slice.kind)
|
||||||
|
? slice.kind
|
||||||
|
: 'other';
|
||||||
|
resultKinds.push(resultKind);
|
||||||
|
resultKindsBySlice[outputName] = resultKind;
|
||||||
}
|
}
|
||||||
return { revisions, resultKinds };
|
return { revisions, sourceRevisions, resultKinds, resultKindsBySlice };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const executeTrpcQuery = async (options: {
|
export const executeTrpcQuery = async (options: {
|
||||||
@@ -76,7 +103,7 @@ export const executeTrpcQuery = async (options: {
|
|||||||
token: string;
|
token: string;
|
||||||
signal: AbortSignal;
|
signal: AbortSignal;
|
||||||
metrics: PhaseMetrics;
|
metrics: PhaseMetrics;
|
||||||
}): Promise<DashboardRevisions | undefined> => {
|
}): Promise<DashboardObservation | undefined> => {
|
||||||
const started = performance.now();
|
const started = performance.now();
|
||||||
let outcome: string | null;
|
let outcome: string | null;
|
||||||
try {
|
try {
|
||||||
@@ -90,9 +117,24 @@ export const executeTrpcQuery = async (options: {
|
|||||||
outcome = classifyTrpcPayload(payload);
|
outcome = classifyTrpcPayload(payload);
|
||||||
if (outcome === null && options.operation.procedure === 'dashboard.getContextBundleDelta') {
|
if (outcome === null && options.operation.procedure === 'dashboard.getContextBundleDelta') {
|
||||||
const dashboard = extractDashboardRevisions(payload);
|
const dashboard = extractDashboardRevisions(payload);
|
||||||
for (const kind of dashboard?.resultKinds ?? []) options.metrics.recordHttpResult(options.operation.name, kind);
|
for (const kind of dashboard?.resultKinds ?? [])
|
||||||
|
options.metrics.recordHttpResult(options.operation.name, kind);
|
||||||
|
const input = asRecord(options.operation.input);
|
||||||
|
const knownSource = asRecord(input?.knownSource);
|
||||||
|
for (const slice of ['context', 'commandTable', 'boardAccess'] as const) {
|
||||||
|
const sourceRevision = dashboard?.sourceRevisions[slice];
|
||||||
|
if (sourceRevision !== undefined) options.metrics.httpSourceRevisionObserved += 1;
|
||||||
|
if (typeof knownSource?.[slice] === 'string') options.metrics.httpSourceRevisionKnownSent += 1;
|
||||||
|
if (
|
||||||
|
dashboard?.resultKindsBySlice[slice] === 'unchanged' &&
|
||||||
|
sourceRevision !== undefined &&
|
||||||
|
knownSource?.[slice] === sourceRevision
|
||||||
|
) {
|
||||||
|
options.metrics.httpSourceRevisionMatchedUnchanged += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
options.metrics.recordHttp(options.operation.name, performance.now() - started, outcome);
|
options.metrics.recordHttp(options.operation.name, performance.now() - started, outcome);
|
||||||
return dashboard?.revisions;
|
return dashboard ?? undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import path from 'node:path';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import { validateLoadConfig } from '../src/config.js';
|
import { validateLoadConfig } from '../src/config.js';
|
||||||
import { assertFixtureIsolation, prepareCapacitySecrets } from '../src/fixture.js';
|
import { activateCapacityCoverage, assertFixtureIsolation, prepareCapacitySecrets } from '../src/fixture.js';
|
||||||
|
|
||||||
const samplePath = new URL('../config/300-users-900-npcs-5m.json', import.meta.url);
|
const samplePath = new URL('../config/300-users-900-npcs-5m.json', import.meta.url);
|
||||||
|
|
||||||
@@ -40,13 +40,19 @@ void test('fixture refuses a shared schema, shared Redis database, and public ho
|
|||||||
assert.throws(
|
assert.throws(
|
||||||
() =>
|
() =>
|
||||||
assertFixtureIsolation(config, {
|
assertFixtureIsolation(config, {
|
||||||
databaseUrl: 'postgresql://fixture:secret@database.example.com:5432/sammo?schema=load_capacity_300_900_5m',
|
databaseUrl:
|
||||||
|
'postgresql://fixture:secret@database.example.com:5432/sammo?schema=load_capacity_300_900_5m',
|
||||||
redisUrl: 'redis://127.0.0.1:16379/15',
|
redisUrl: 'redis://127.0.0.1:16379/15',
|
||||||
}),
|
}),
|
||||||
/loopback or private/u
|
/loopback or private/u
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('coverage activation requires the exact dedicated schema confirmation before connecting', async () => {
|
||||||
|
const config = validateLoadConfig(JSON.parse(await readFile(samplePath, 'utf8')));
|
||||||
|
await assert.rejects(activateCapacityCoverage(config, 'load_wrong_schema', {}), /confirmation must exactly equal/u);
|
||||||
|
});
|
||||||
|
|
||||||
void test('prepare creates only three 0600 ignored-secret inputs without returning their values', async () => {
|
void test('prepare creates only three 0600 ignored-secret inputs without returning their values', async () => {
|
||||||
const workspaceRoot = await mkdtemp(path.join(tmpdir(), 'sammo-capacity-prepare-'));
|
const workspaceRoot = await mkdtemp(path.join(tmpdir(), 'sammo-capacity-prepare-'));
|
||||||
try {
|
try {
|
||||||
@@ -63,10 +69,7 @@ void test('prepare creates only three 0600 ignored-secret inputs without returni
|
|||||||
const info = await stat(path.join(workspaceRoot, 'tools/load-tests/secrets', name));
|
const info = await stat(path.join(workspaceRoot, 'tools/load-tests/secrets', name));
|
||||||
assert.equal(info.mode & 0o777, 0o600);
|
assert.equal(info.mode & 0o777, 0o600);
|
||||||
}
|
}
|
||||||
const envText = await readFile(
|
const envText = await readFile(path.join(workspaceRoot, 'tools/load-tests/secrets/capacity.env'), 'utf8');
|
||||||
path.join(workspaceRoot, 'tools/load-tests/secrets/capacity.env'),
|
|
||||||
'utf8'
|
|
||||||
);
|
|
||||||
assert.match(envText, /127\.0\.0\.1:25442/u);
|
assert.match(envText, /127\.0\.0\.1:25442/u);
|
||||||
assert.match(envText, /127\.0\.0\.1:26379\/15/u);
|
assert.match(envText, /127\.0\.0\.1:26379\/15/u);
|
||||||
assert.equal(JSON.stringify(result).includes('postgresql://'), false);
|
assert.equal(JSON.stringify(result).includes('postgresql://'), false);
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ void test('phase aggregation separates success, error, latency, and event counte
|
|||||||
metrics.recordSseEvent('ready');
|
metrics.recordSseEvent('ready');
|
||||||
metrics.recordSseEvent('ready');
|
metrics.recordSseEvent('ready');
|
||||||
metrics.recordHttpResult('own', 'unchanged');
|
metrics.recordHttpResult('own', 'unchanged');
|
||||||
|
metrics.httpSourceRevisionObserved = 6;
|
||||||
|
metrics.httpSourceRevisionKnownSent = 3;
|
||||||
|
metrics.httpSourceRevisionMatchedUnchanged = 3;
|
||||||
metrics.processRssBytes.push(100, 200);
|
metrics.processRssBytes.push(100, 200);
|
||||||
metrics.sseActiveConnections.push(0, 2);
|
metrics.sseActiveConnections.push(0, 2);
|
||||||
const summary = summarizePhaseMetrics(metrics, {
|
const summary = summarizePhaseMetrics(metrics, {
|
||||||
@@ -36,6 +39,7 @@ void test('phase aggregation separates success, error, latency, and event counte
|
|||||||
assert.deepEqual(summary.http.success, { own: 1 });
|
assert.deepEqual(summary.http.success, { own: 1 });
|
||||||
assert.deepEqual(summary.http.errors, { 'own:http-500': 1 });
|
assert.deepEqual(summary.http.errors, { 'own:http-500': 1 });
|
||||||
assert.deepEqual(summary.http.results, { 'own:unchanged': 1 });
|
assert.deepEqual(summary.http.results, { 'own:unchanged': 1 });
|
||||||
|
assert.deepEqual(summary.http.sourceRevision, { observed: 6, knownSent: 3, matchedUnchanged: 3 });
|
||||||
assert.equal(summary.http.latencyMs.own?.p50, 10);
|
assert.equal(summary.http.latencyMs.own?.p50, 10);
|
||||||
assert.deepEqual(summary.sse.events, { ready: 2 });
|
assert.deepEqual(summary.sse.events, { ready: 2 });
|
||||||
assert.equal(summary.sse.activeConnections.max, 2);
|
assert.equal(summary.sse.activeConnections.max, 2);
|
||||||
|
|||||||
@@ -8,7 +8,13 @@ void test('tRPC query uses bearer auth without putting the token in the URL', ()
|
|||||||
const request = buildTrpcQuery(
|
const request = buildTrpcQuery(
|
||||||
'http://127.0.0.1:15001',
|
'http://127.0.0.1:15001',
|
||||||
'/api/trpc',
|
'/api/trpc',
|
||||||
{ name: 'own', procedure: 'dashboard.getContextBundleDelta', type: 'query', weight: 1, input: { include: { context: true } } },
|
{
|
||||||
|
name: 'own',
|
||||||
|
procedure: 'dashboard.getContextBundleDelta',
|
||||||
|
type: 'query',
|
||||||
|
weight: 1,
|
||||||
|
input: { include: { context: true } },
|
||||||
|
},
|
||||||
token
|
token
|
||||||
);
|
);
|
||||||
assert.equal(new Headers(request.init.headers).get('authorization'), `Bearer ${token}`);
|
assert.equal(new Headers(request.init.headers).get('authorization'), `Bearer ${token}`);
|
||||||
@@ -25,11 +31,16 @@ void test('dashboard observations retain only opaque revisions and aggregate-saf
|
|||||||
data: {
|
data: {
|
||||||
json: {
|
json: {
|
||||||
context: { kind: 'unchanged', revision, data: { general: { id: 123 } } },
|
context: { kind: 'unchanged', revision, data: { general: { id: 123 } } },
|
||||||
commandTable: { kind: 'snapshot', revision },
|
commandTable: { kind: 'snapshot', revision, sourceRevision: revision },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
{ revisions: { context: revision, commandTable: revision }, resultKinds: ['unchanged', 'snapshot'] }
|
{
|
||||||
|
revisions: { context: revision, commandTable: revision },
|
||||||
|
sourceRevisions: { commandTable: revision },
|
||||||
|
resultKinds: ['unchanged', 'snapshot'],
|
||||||
|
resultKindsBySlice: { context: 'unchanged', commandTable: 'snapshot' },
|
||||||
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user