Publish release QA dashboard
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const qaVersion = process.env.PUBLIC_QA_VERSION ?? makeQaVersion();
|
||||
@@ -7,6 +7,7 @@ const releaseCommit = gitValue(['rev-parse', 'HEAD'], 'unknown');
|
||||
const releaseShortCommit = gitValue(['rev-parse', '--short', 'HEAD'], 'unknown');
|
||||
const qaReportFile = 'qa-early-report.json';
|
||||
const qaRepeatReportFile = 'qa-early-repeat-report.json';
|
||||
const releaseDashboardFile = 'release-dashboard.html';
|
||||
const qaRepeatRuns = Number(process.env.PUBLIC_QA_REPEAT_RUNS ?? 2);
|
||||
|
||||
assertNoTrackedChanges();
|
||||
@@ -18,7 +19,21 @@ if (qaRepeatRuns > 1) {
|
||||
QA_REPEAT_REPORT_PATH: join('dist', qaRepeatReportFile)
|
||||
});
|
||||
}
|
||||
writeReleaseManifest(qaVersion, releaseCommit, releaseShortCommit, qaReportFile, qaRepeatRuns > 1 ? qaRepeatReportFile : undefined);
|
||||
writeReleaseDashboard({
|
||||
qaVersion,
|
||||
commit: releaseCommit,
|
||||
shortCommit: releaseShortCommit,
|
||||
qaReport: qaReportFile,
|
||||
qaRepeatReport: qaRepeatRuns > 1 ? qaRepeatReportFile : undefined
|
||||
});
|
||||
writeReleaseManifest(
|
||||
qaVersion,
|
||||
releaseCommit,
|
||||
releaseShortCommit,
|
||||
qaReportFile,
|
||||
qaRepeatRuns > 1 ? qaRepeatReportFile : undefined,
|
||||
releaseDashboardFile
|
||||
);
|
||||
runPnpmScript('deploy:nas:dist');
|
||||
runPnpmScript('verify:public-deploy', { PUBLIC_QA_VERSION: qaVersion, PUBLIC_QA_COMMIT: releaseCommit });
|
||||
|
||||
@@ -44,7 +59,7 @@ function assertNoTrackedChanges() {
|
||||
}
|
||||
}
|
||||
|
||||
function writeReleaseManifest(qaVersion, commit, shortCommit, qaReport, qaRepeatReport) {
|
||||
function writeReleaseManifest(qaVersion, commit, shortCommit, qaReport, qaRepeatReport, releaseDashboard) {
|
||||
const manifest = {
|
||||
app: 'heros_web',
|
||||
qaVersion,
|
||||
@@ -54,6 +69,7 @@ function writeReleaseManifest(qaVersion, commit, shortCommit, qaReport, qaRepeat
|
||||
generatedAt: new Date().toISOString(),
|
||||
qaReport,
|
||||
qaRepeatReport,
|
||||
releaseDashboard,
|
||||
verification: ['verify:local-release', 'qa:early', ...(qaRepeatReport ? ['qa:early:repeat'] : []), 'verify:public-deploy']
|
||||
};
|
||||
|
||||
@@ -62,6 +78,132 @@ function writeReleaseManifest(qaVersion, commit, shortCommit, qaReport, qaRepeat
|
||||
console.log(`Wrote dist/release-manifest.json for ${manifest.shortCommit} (${qaVersion})`);
|
||||
}
|
||||
|
||||
function writeReleaseDashboard({ qaVersion, commit, shortCommit, qaReport, qaRepeatReport }) {
|
||||
const qa = readJsonFromDist(qaReport);
|
||||
const repeat = qaRepeatReport ? readJsonFromDist(qaRepeatReport) : undefined;
|
||||
const missedObjectives = repeat?.missedObjectives ?? qa?.missedObjectives ?? [];
|
||||
const runRows = repeat?.runs ?? [];
|
||||
|
||||
const html = `<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Heros Web Release QA ${escapeHtml(shortCommit)}</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; font-family: "Segoe UI", sans-serif; background: #131815; color: #f1eadb; }
|
||||
body { margin: 0; padding: 28px; background: #131815; }
|
||||
main { max-width: 1120px; margin: 0 auto; }
|
||||
h1, h2 { margin: 0; font-weight: 700; }
|
||||
h1 { font-size: 28px; }
|
||||
h2 { font-size: 18px; margin-top: 28px; }
|
||||
.meta, .grid, table { margin-top: 16px; }
|
||||
.meta { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; }
|
||||
.tile { border: 1px solid #665941; background: #221f18; border-radius: 8px; padding: 12px; }
|
||||
.label { color: #b9ad91; font-size: 12px; }
|
||||
.value { margin-top: 5px; font-size: 18px; font-weight: 700; word-break: break-word; }
|
||||
table { width: 100%; border-collapse: collapse; border: 1px solid #665941; background: #1b1c17; }
|
||||
th, td { padding: 10px 12px; border-bottom: 1px solid #3e382b; text-align: left; font-size: 14px; }
|
||||
th { color: #e8d39a; background: #252116; }
|
||||
tr:last-child td { border-bottom: 0; }
|
||||
.ok { color: #a9db8f; }
|
||||
.warn { color: #f3c96b; }
|
||||
a { color: #9cc6ff; }
|
||||
@media (max-width: 820px) { .meta { grid-template-columns: 1fr 1fr; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Heros Web Release QA</h1>
|
||||
<section class="meta">
|
||||
${metricTile('Version', qaVersion)}
|
||||
${metricTile('Commit', shortCommit)}
|
||||
${metricTile('Early QA', `${qa?.summary?.victories ?? 0}/${qa?.battleCount ?? 0} victories`)}
|
||||
${metricTile('Repeat QA', repeat ? `${repeat.summary.runs} runs` : 'not run')}
|
||||
</section>
|
||||
|
||||
<h2>Artifacts</h2>
|
||||
<table>
|
||||
<thead><tr><th>Type</th><th>File</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Manifest</td><td><a href="release-manifest.json">release-manifest.json</a></td></tr>
|
||||
<tr><td>Early QA</td><td><a href="${escapeAttribute(qaReport)}">${escapeHtml(qaReport)}</a></td></tr>
|
||||
${
|
||||
qaRepeatReport
|
||||
? `<tr><td>Repeat QA</td><td><a href="${escapeAttribute(qaRepeatReport)}">${escapeHtml(qaRepeatReport)}</a></td></tr>`
|
||||
: ''
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Repeated Objective Misses</h2>
|
||||
<table>
|
||||
<thead><tr><th>Battle</th><th>Objective</th><th>Misses</th><th>Rate</th><th>Latest Reason</th></tr></thead>
|
||||
<tbody>
|
||||
${
|
||||
missedObjectives.length > 0
|
||||
? missedObjectives
|
||||
.map((miss) => {
|
||||
const latest = miss.examples?.at(-1);
|
||||
return `<tr><td>${escapeHtml(String(miss.battleNo))}</td><td>${escapeHtml(miss.objectiveId)}</td><td class="warn">${escapeHtml(String(miss.count ?? 1))}</td><td>${formatRate(miss.missRate)}</td><td>${escapeHtml(latest?.failureReason ?? miss.failureReason ?? '')}</td></tr>`;
|
||||
})
|
||||
.join('')
|
||||
: '<tr><td colspan="5" class="ok">No missed objectives recorded.</td></tr>'
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Repeat Runs</h2>
|
||||
<table>
|
||||
<thead><tr><th>Run</th><th>Victories</th><th>Missed Objectives</th><th>Battles With Misses</th></tr></thead>
|
||||
<tbody>
|
||||
${
|
||||
runRows.length > 0
|
||||
? runRows
|
||||
.map(
|
||||
(run) =>
|
||||
`<tr><td>${escapeHtml(String(run.run))}</td><td>${escapeHtml(String(run.victories))}</td><td>${escapeHtml(String(run.missedObjectiveCount))}</td><td>${escapeHtml((run.battlesWithMissedObjectives ?? []).join(', ') || '-')}</td></tr>`
|
||||
)
|
||||
.join('')
|
||||
: '<tr><td colspan="4">Repeat QA was not run for this release.</td></tr>'
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p class="label">Full commit: ${escapeHtml(commit)}</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
writeFileSync(join('dist', releaseDashboardFile), html, 'utf8');
|
||||
console.log(`Wrote dist/${releaseDashboardFile}`);
|
||||
}
|
||||
|
||||
function readJsonFromDist(fileName) {
|
||||
return JSON.parse(readFileSync(join('dist', fileName), 'utf8'));
|
||||
}
|
||||
|
||||
function metricTile(label, value) {
|
||||
return `<div class="tile"><div class="label">${escapeHtml(label)}</div><div class="value">${escapeHtml(String(value))}</div></div>`;
|
||||
}
|
||||
|
||||
function formatRate(value) {
|
||||
return typeof value === 'number' ? `${Math.round(value * 100)}%` : '-';
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function escapeAttribute(value) {
|
||||
return escapeHtml(value).replaceAll('`', '`');
|
||||
}
|
||||
|
||||
function gitValue(args, fallback) {
|
||||
const result = spawnSync('git', args, { cwd: process.cwd(), encoding: 'utf8' });
|
||||
if (result.status !== 0) {
|
||||
|
||||
@@ -134,6 +134,12 @@ try {
|
||||
);
|
||||
console.log(`Verified QA repeat report ${releaseManifest.qaRepeatReport} with ${qaRepeatReport.runs.length} runs`);
|
||||
}
|
||||
if (releaseManifest.releaseDashboard) {
|
||||
const dashboard = await readTextAsset(targetUrl, releaseManifest.releaseDashboard);
|
||||
assert(dashboard.includes('Heros Web Release QA'), `Expected deployed release dashboard title: ${dashboard.slice(0, 200)}`);
|
||||
assert(dashboard.includes(releaseManifest.shortCommit ?? releaseManifest.commit), `Expected deployed release dashboard commit: ${dashboard.slice(0, 400)}`);
|
||||
console.log(`Verified release dashboard ${releaseManifest.releaseDashboard}`);
|
||||
}
|
||||
|
||||
const relevantLogs = consoleMessages.filter(
|
||||
(message) => message.type === 'error' || (isWarning(message.type) && relevantLogPattern.test(message.text))
|
||||
@@ -173,6 +179,14 @@ async function readJsonAsset(pageUrl, assetPath) {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function readTextAsset(pageUrl, assetPath) {
|
||||
const assetUrl = new URL(assetPath, pageUrl);
|
||||
assetUrl.searchParams.set('v', expectedQaVersion ?? String(Date.now()));
|
||||
const response = await fetch(assetUrl, { signal: AbortSignal.timeout(90000) });
|
||||
assert(response.ok, `Expected deployed text asset at ${assetUrl.href}: ${response.status} ${response.statusText}`);
|
||||
return response.text();
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
|
||||
Reference in New Issue
Block a user