Files
gongxue-base/scripts/production-launch-gate-test.js

235 lines
9.7 KiB
JavaScript

import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { gateChecks, requiredAttestations } from './production-launch-gate.js';
const repoRoot = process.cwd();
const scriptPath = path.join(repoRoot, 'scripts', 'production-launch-gate.js');
function isoNow() {
return new Date().toISOString();
}
function sampleSummaryValue(expectedValue) {
if (!expectedValue || typeof expectedValue !== 'object' || Array.isArray(expectedValue)) return expectedValue;
if (Object.prototype.hasOwnProperty.call(expectedValue, 'eq')) return expectedValue.eq;
if (Object.prototype.hasOwnProperty.call(expectedValue, 'oneOf')) return expectedValue.oneOf?.[0];
if (Object.prototype.hasOwnProperty.call(expectedValue, 'lte')) return expectedValue.lte;
if (Object.prototype.hasOwnProperty.call(expectedValue, 'lt')) return Number(expectedValue.lt) - 1;
if (Object.prototype.hasOwnProperty.call(expectedValue, 'gte')) return expectedValue.gte;
if (Object.prototype.hasOwnProperty.call(expectedValue, 'gt')) return Number(expectedValue.gt) + 1;
return expectedValue;
}
function sampleSummary(summarySpec) {
const result = {};
for (const [key, value] of Object.entries(summarySpec || {})) {
const parts = key.split('.');
let cursor = result;
for (const part of parts.slice(0, -1)) {
if (!cursor[part] || typeof cursor[part] !== 'object') cursor[part] = {};
cursor = cursor[part];
}
cursor[parts.at(-1)] = sampleSummaryValue(value);
}
return result;
}
function createEvidence(tempDir, overrides = {}) {
const artifactDir = path.join(tempDir, 'launch-artifacts');
fs.mkdirSync(artifactDir, { recursive: true });
const checks = gateChecks.map(spec => {
const artifact = `launch-artifacts/${spec.id}.log`;
fs.writeFileSync(path.join(tempDir, artifact), `[PASS] ${spec.id}\n`, 'utf8');
return {
id: spec.id,
status: 'pass',
command: `npm run ${spec.commandIncludes} -- recorded-for-launch-gate`,
completedAt: isoNow(),
artifact,
summary: sampleSummary(spec.summary),
};
});
const attestations = requiredAttestations.map(spec => ({
id: spec.id,
status: 'approved',
approver: 'test-owner',
approvedAt: isoNow(),
notes: spec.label,
}));
return {
schemaVersion: 1,
environment: 'production',
commit: '52cef9fabcd1234567890abcdef1234567890abc',
target: {
apiBaseUrl: 'https://api.gongxue100.com',
studentH5Url: 'https://www.gongxue100.com',
tenantAdminH5Url: 'https://admin.gongxue100.com',
platformAdminH5Url: 'https://console.gongxue100.com',
},
checks,
attestations,
...overrides,
};
}
function runGate(evidence, options = {}) {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tiku-launch-gate-'));
const evidencePath = path.join(tempDir, 'evidence.json');
const finalEvidence = typeof evidence === 'function' ? evidence(tempDir) : evidence;
fs.writeFileSync(evidencePath, JSON.stringify(finalEvidence, null, 2), 'utf8');
const result = spawnSync(process.execPath, [scriptPath, '--evidence', evidencePath, '--json', ...(options.args || [])], {
cwd: repoRoot,
encoding: 'utf8',
env: {
PATH: process.env.PATH || '',
Path: process.env.Path || '',
SystemRoot: process.env.SystemRoot || '',
ComSpec: process.env.ComSpec || '',
TEMP: process.env.TEMP || os.tmpdir(),
TMP: process.env.TMP || os.tmpdir(),
},
});
const payload = JSON.parse(result.stdout || '{}');
fs.rmSync(tempDir, { recursive: true, force: true });
return { ...result, payload };
}
const safe = runGate(tempDir => createEvidence(tempDir));
assert.equal(safe.status, 0, `complete launch evidence should pass: ${safe.stdout} ${safe.stderr}`);
assert.equal(safe.payload.summary?.blocker, 0, 'complete launch evidence should have no blockers');
const missingArtifact = runGate(tempDir => {
const evidence = createEvidence(tempDir);
const item = evidence.checks.find(check => check.id === 'auth.remote-smoke');
fs.rmSync(path.join(tempDir, item.artifact), { force: true });
return evidence;
});
assert.notEqual(missingArtifact.status, 0, 'missing artifact should fail launch gate');
assert.ok(
missingArtifact.payload.checks?.some(item => item.id === 'check.auth.remote-smoke.artifact' && item.status === 'blocker'),
'missing artifact should be reported as a blocker',
);
const wrongMigrationProfile = runGate(tempDir => {
const evidence = createEvidence(tempDir);
const item = evidence.checks.find(check => check.id === 'migration.pb-production-dry-run');
item.summary.migrationProfile = 'development';
return evidence;
});
assert.notEqual(wrongMigrationProfile.status, 0, 'development dry-run evidence should fail launch gate');
assert.ok(
wrongMigrationProfile.payload.checks?.some(item => item.id === 'check.migration.pb-production-dry-run.summary' && item.status === 'blocker'),
'migration profile mismatch should be reported as a blocker',
);
const missingPostgresTuning = runGate(tempDir => {
const evidence = createEvidence(tempDir);
evidence.checks = evidence.checks.filter(item => item.id !== 'postgres.tuning-evidence');
return evidence;
});
assert.notEqual(missingPostgresTuning.status, 0, 'missing PostgreSQL tuning evidence should fail launch gate');
assert.ok(
missingPostgresTuning.payload.checks?.some(item => item.id === 'check.postgres.tuning-evidence' && item.status === 'blocker'),
'missing PostgreSQL tuning evidence should be reported as a blocker',
);
const slowBenchmark = runGate(tempDir => {
const evidence = createEvidence(tempDir);
const item = evidence.checks.find(check => check.id === 'performance.api-real-data-read');
item.summary.p95Ms = 301;
return evidence;
});
assert.notEqual(slowBenchmark.status, 0, 'slow API benchmark should fail launch gate');
assert.ok(
slowBenchmark.payload.checks?.some(item => item.id === 'check.performance.api-real-data-read.summary' && item.status === 'blocker'),
'slow API benchmark should be reported as a blocker',
);
const missingMixedBenchmark = runGate(tempDir => {
const evidence = createEvidence(tempDir);
evidence.checks = evidence.checks.filter(item => item.id !== 'performance.api-real-data-mixed');
return evidence;
});
assert.notEqual(missingMixedBenchmark.status, 0, 'missing mixed read/write benchmark should fail launch gate');
assert.ok(
missingMixedBenchmark.payload.checks?.some(item => item.id === 'check.performance.api-real-data-mixed' && item.status === 'blocker'),
'missing mixed benchmark should be reported as a blocker',
);
const slowMixedBenchmark = runGate(tempDir => {
const evidence = createEvidence(tempDir);
const item = evidence.checks.find(check => check.id === 'performance.api-real-data-mixed');
item.summary.p95Ms = 501;
return evidence;
});
assert.notEqual(slowMixedBenchmark.status, 0, 'slow mixed API benchmark should fail launch gate');
assert.ok(
slowMixedBenchmark.payload.checks?.some(item => item.id === 'check.performance.api-real-data-mixed.summary' && item.status === 'blocker'),
'slow mixed benchmark should be reported as a blocker',
);
const missingBusinessSampling = runGate(tempDir => {
const evidence = createEvidence(tempDir);
evidence.checks = evidence.checks.filter(item => item.id !== 'migration.pb-import-sample');
return evidence;
});
assert.notEqual(missingBusinessSampling.status, 0, 'missing import business sampling should fail launch gate');
assert.ok(
missingBusinessSampling.payload.checks?.some(item => item.id === 'check.migration.pb-import-sample' && item.status === 'blocker'),
'missing import business sampling should be reported as a blocker',
);
const missingTaroStaticSmoke = runGate(tempDir => {
const evidence = createEvidence(tempDir);
evidence.checks = evidence.checks.filter(item => item.id !== 'taro.h5-static-smoke');
return evidence;
});
assert.notEqual(missingTaroStaticSmoke.status, 0, 'missing H5 static smoke should fail launch gate');
assert.ok(
missingTaroStaticSmoke.payload.checks?.some(item => item.id === 'check.taro.h5-static-smoke' && item.status === 'blocker'),
'missing H5 static smoke should be reported as a blocker',
);
const missingTaroInteractionSmoke = runGate(tempDir => {
const evidence = createEvidence(tempDir);
evidence.checks = evidence.checks.filter(item => item.id !== 'taro.h5-interaction-smoke');
return evidence;
});
assert.notEqual(missingTaroInteractionSmoke.status, 0, 'missing H5 interaction smoke should fail launch gate');
assert.ok(
missingTaroInteractionSmoke.payload.checks?.some(item => item.id === 'check.taro.h5-interaction-smoke' && item.status === 'blocker'),
'missing H5 interaction smoke should be reported as a blocker',
);
const missingRepoSecurityScan = runGate(tempDir => {
const evidence = createEvidence(tempDir);
evidence.checks = evidence.checks.filter(item => item.id !== 'security.repo-scan');
return evidence;
});
assert.notEqual(missingRepoSecurityScan.status, 0, 'missing repository security scan should fail launch gate');
assert.ok(
missingRepoSecurityScan.payload.checks?.some(item => item.id === 'check.security.repo-scan' && item.status === 'blocker'),
'missing repository security scan should be reported as a blocker',
);
const missingAttestation = runGate(tempDir => {
const evidence = createEvidence(tempDir);
evidence.attestations = evidence.attestations.filter(item => item.id !== 'backup.snapshot');
return evidence;
});
assert.notEqual(missingAttestation.status, 0, 'missing manual attestation should fail launch gate');
assert.ok(
missingAttestation.payload.checks?.some(item => item.id === 'attestation.backup.snapshot' && item.status === 'blocker'),
'missing attestation should be reported as a blocker',
);
console.log('[PASS] production launch gate');