Publish release QA dashboard
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
|
||||||
const qaVersion = process.env.PUBLIC_QA_VERSION ?? makeQaVersion();
|
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 releaseShortCommit = gitValue(['rev-parse', '--short', 'HEAD'], 'unknown');
|
||||||
const qaReportFile = 'qa-early-report.json';
|
const qaReportFile = 'qa-early-report.json';
|
||||||
const qaRepeatReportFile = 'qa-early-repeat-report.json';
|
const qaRepeatReportFile = 'qa-early-repeat-report.json';
|
||||||
|
const releaseDashboardFile = 'release-dashboard.html';
|
||||||
const qaRepeatRuns = Number(process.env.PUBLIC_QA_REPEAT_RUNS ?? 2);
|
const qaRepeatRuns = Number(process.env.PUBLIC_QA_REPEAT_RUNS ?? 2);
|
||||||
|
|
||||||
assertNoTrackedChanges();
|
assertNoTrackedChanges();
|
||||||
@@ -18,7 +19,21 @@ if (qaRepeatRuns > 1) {
|
|||||||
QA_REPEAT_REPORT_PATH: join('dist', qaRepeatReportFile)
|
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('deploy:nas:dist');
|
||||||
runPnpmScript('verify:public-deploy', { PUBLIC_QA_VERSION: qaVersion, PUBLIC_QA_COMMIT: releaseCommit });
|
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 = {
|
const manifest = {
|
||||||
app: 'heros_web',
|
app: 'heros_web',
|
||||||
qaVersion,
|
qaVersion,
|
||||||
@@ -54,6 +69,7 @@ function writeReleaseManifest(qaVersion, commit, shortCommit, qaReport, qaRepeat
|
|||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
qaReport,
|
qaReport,
|
||||||
qaRepeatReport,
|
qaRepeatReport,
|
||||||
|
releaseDashboard,
|
||||||
verification: ['verify:local-release', 'qa:early', ...(qaRepeatReport ? ['qa:early:repeat'] : []), 'verify:public-deploy']
|
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})`);
|
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) {
|
function gitValue(args, fallback) {
|
||||||
const result = spawnSync('git', args, { cwd: process.cwd(), encoding: 'utf8' });
|
const result = spawnSync('git', args, { cwd: process.cwd(), encoding: 'utf8' });
|
||||||
if (result.status !== 0) {
|
if (result.status !== 0) {
|
||||||
|
|||||||
@@ -134,6 +134,12 @@ try {
|
|||||||
);
|
);
|
||||||
console.log(`Verified QA repeat report ${releaseManifest.qaRepeatReport} with ${qaRepeatReport.runs.length} runs`);
|
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(
|
const relevantLogs = consoleMessages.filter(
|
||||||
(message) => message.type === 'error' || (isWarning(message.type) && relevantLogPattern.test(message.text))
|
(message) => message.type === 'error' || (isWarning(message.type) && relevantLogPattern.test(message.text))
|
||||||
@@ -173,6 +179,14 @@ async function readJsonAsset(pageUrl, assetPath) {
|
|||||||
return response.json();
|
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) {
|
function assert(condition, message) {
|
||||||
if (!condition) {
|
if (!condition) {
|
||||||
throw new Error(message);
|
throw new Error(message);
|
||||||
|
|||||||
Reference in New Issue
Block a user