diff --git a/.github/scripts/measure-backend-memory-comparison.mts b/.github/scripts/backend-diagnostics.inspect.mts similarity index 96% rename from .github/scripts/measure-backend-memory-comparison.mts rename to .github/scripts/backend-diagnostics.inspect.mts index a4e93b3ce4..5bf0f3ee99 100644 --- a/.github/scripts/measure-backend-memory-comparison.mts +++ b/.github/scripts/backend-diagnostics.inspect.mts @@ -99,7 +99,7 @@ function summarizeSamples(samples: MemoryReport['samples']) { return summary; } -async function measureRepo(label: string, repoDir: string, round: number, options: { heapSnapshotSavePath?: string } = {}) { +async function genSample(label: string, repoDir: string, round: number, options: { heapSnapshotSavePath?: string } = {}) { process.stderr.write(`[${label}] Resetting database and Redis\n`); await resetState(repoDir); @@ -188,7 +188,7 @@ async function main() { for (let round = 1; round <= warmupRounds; round++) { process.stderr.write(`Starting warmup round ${round}/${warmupRounds}\n`); for (const label of heapSnapshotLabels) { - await measureRepo(label, reports[label].dir, -round); + await genSample(label, reports[label].dir, -round); } } @@ -198,7 +198,7 @@ async function main() { for (const label of order) { const options = { heapSnapshotSavePath: heapSnapshotPath(label, round) }; - const sample = await measureRepo(label, reports[label].dir, round, options); + const sample = await genSample(label, reports[label].dir, round, options); reports[label].samples.push({ ...sample, round, diff --git a/.github/scripts/backend-diagnostics.render-md.mts b/.github/scripts/backend-diagnostics.render-md.mts new file mode 100644 index 0000000000..e73c22a722 --- /dev/null +++ b/.github/scripts/backend-diagnostics.render-md.mts @@ -0,0 +1,183 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { readFile, writeFile } from 'node:fs/promises'; +import * as util from './utility.mts'; +import * as heapSnapshotUtil from './heap-snapshot-util.mts'; +import type { MemoryReport } from './backend-diagnostics.inspect.mts'; + +const [baseFile, headFile, outputFile] = process.argv.slice(2); + +const memoryReportPhases = [ + { + key: 'afterGc', + title: 'After GC', + }, +] as const; + +const memoryMetrics = [ + 'HeapUsed', + 'Pss', + 'USS', + 'External', +] as const; + +function formatMemoryMb(valueKiB: number | null | undefined) { + if (valueKiB == null) return '-'; + return `${util.formatNumber(valueKiB / 1000)} MB`; +} + +function formatMemoryMetricName(metric: typeof memoryMetrics[number]) { + return metric === 'Pss' ? 'PSS' : metric; +} + +function getMemoryValueFromSample(sample: MemoryReport['samples'][number], phase: typeof memoryReportPhases[number]['key'], metric: typeof memoryMetrics[number]) { + const memoryUsage = sample.phases[phase].memoryUsage; + if (metric !== 'USS') return memoryUsage[metric]; + return memoryUsage.Private_Clean + memoryUsage.Private_Dirty; +} + +function getSampleValues(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof memoryMetrics[number]) { + return report.samples.map(sample => getMemoryValueFromSample(sample, phase, metric)); +} + +function getMemoryValue(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof memoryMetrics[number]) { + if (metric !== 'USS') return report.summary[phase].memoryUsage[metric]; + return util.median(getSampleValues(report, phase, metric)); +} + +function getSampleSpread(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof memoryMetrics[number]) { + const values = getSampleValues(report, phase, metric); + if (values.length < 2) return null; + + const center = util.median(values); + return util.median(values.map(value => Math.abs(value - center))); +} + +function renderMainTableForPhase(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key']) { + const lines = [ + '| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |', + '| --- | ---: | ---: | ---: | ---: | ---: | ---: |', + ]; + + function formatDeltaMemory(deltaKiB: number) { + return util.formatColoredDelta(deltaKiB, v => formatMemoryMb(v), 100); // 0.1 MB threshold + } + + for (const metric of memoryMetrics) { + const baseValue = getMemoryValue(base, phase, metric); + const headValue = getMemoryValue(head, phase, metric); + + const baseSpread = getSampleSpread(base, phase, metric); + const headSpread = getSampleSpread(head, phase, metric); + const summary = util.pairedDeltaSummary(base.samples, head.samples, (sample) => getMemoryValueFromSample(sample, phase, metric)); + const percent = summary.median * 100 / baseValue; + const deltaMedian = `${formatDeltaMemory(summary.median)}
${util.formatDeltaPercent(percent, 0.1).replaceAll('\\%', '\\\\%')}`; + + lines.push(`| **${formatMemoryMetricName(metric)}** | ${formatMemoryMb(baseValue)}
± ${formatMemoryMb(baseSpread)} | ${formatMemoryMb(headValue)}
± ${formatMemoryMb(headSpread)} | ${deltaMedian} | ${formatMemoryMb(summary.mad)} | ${formatDeltaMemory(summary.min)} | ${formatDeltaMemory(summary.max)} |`); + } + + return lines.join('\n'); +} + +function renderHeapSnapshotSection(base: MemoryReport, head: MemoryReport) { + const baseHeapSnapshotReport = { + summary: base.summary.afterGc.heapSnapshot!, + samples: base.samples.map(sample => ({ + round: sample.round, + data: sample.phases.afterGc.heapSnapshot!, + })), + }; + + const headHeapSnapshotReport = { + summary: head.summary.afterGc.heapSnapshot!, + samples: head.samples.map(sample => ({ + round: sample.round, + data: sample.phases.afterGc.heapSnapshot!, + })), + }; + + const table = heapSnapshotUtil.renderHeapSnapshotTable(baseHeapSnapshotReport, headHeapSnapshotReport); + if (table == null) return null; + + const lines = [ + '### V8 Heap Snapshot Statistics', + '', + table, + '', + ]; + + for (const graph of [ + //heapSnapshotUtil.renderHeapSnapshotSankey(baseHeapSnapshotReport, 'Base'), + heapSnapshotUtil.renderHeapSnapshotSankey(headHeapSnapshotReport, 'Head'), + ]) { + if (graph == null) continue; + lines.push(graph); + lines.push(''); + } + + return lines.join('\n'); +} + +const base = JSON.parse(await readFile(baseFile, 'utf8')) as MemoryReport; +const head = JSON.parse(await readFile(headFile, 'utf8')) as MemoryReport; + +const lines = [ + '## ⚙️ Backend Diagnostics Report', + '', +]; + +//const summary = measurementSummary(base, head); +//if (summary != null) { +// lines.push(summary); +// lines.push(''); +//} + +for (const phase of memoryReportPhases) { + lines.push(`### Memory: ${phase.title}`); + lines.push(renderMainTableForPhase(base, head, phase.key)); + lines.push(''); +} + +const heapSnapshotSection = renderHeapSnapshotSection(base, head); +if (heapSnapshotSection != null) { + lines.push(heapSnapshotSection); + lines.push(''); +} + +const baseHeapSnapshotArtifactUrl = process.env.MK_MEMORY_HEAP_SNAPSHOT_ARTIFACT_URL_BASE!.trim(); +const headHeapSnapshotArtifactUrl = process.env.MK_MEMORY_HEAP_SNAPSHOT_ARTIFACT_URL_HEAD!.trim(); +lines.push(`You can download the representative heap snapshot: [base](${baseHeapSnapshotArtifactUrl}) / [head](${headHeapSnapshotArtifactUrl})`); +lines.push(''); + +function getDiffPercent(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof memoryMetrics[number]) { + const baseValue = getMemoryValue(base, phase, metric); + const headValue = getMemoryValue(head, phase, metric); + return ((headValue - baseValue) * 100) / baseValue; +} + +function isBeyondSampleNoise(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof memoryMetrics[number]) { + const baseValue = getMemoryValue(base, phase, metric); + const headValue = getMemoryValue(head, phase, metric); + + const delta = headValue - baseValue; + if (delta <= 0) return false; + + const baseSpread = getSampleSpread(base, phase, metric); + const headSpread = getSampleSpread(head, phase, metric); + if (baseSpread == null || headSpread == null) return true; + + const combinedSpread = Math.hypot(baseSpread, headSpread); + return delta > combinedSpread * 3; +} + +const warningMetric = 'Pss'; +const warningDiffPercent = getDiffPercent(base, head, 'afterGc', warningMetric); +if (warningDiffPercent > 5 && isBeyondSampleNoise(base, head, 'afterGc', warningMetric)) { + lines.push(`⚠️ **Warning**: Memory usage (${formatMemoryMetricName(warningMetric)}) has increased by more than 5% and exceeds the observed sample noise. Please verify this is not an unintended change.`); + lines.push(''); +} + +await writeFile(outputFile, `${lines.join('\n')}\n`); diff --git a/.github/scripts/backend-js-footprint-loader.mjs b/.github/scripts/backend-js-footprint-loader.mjs deleted file mode 100644 index cd2c0af2e6..0000000000 --- a/.github/scripts/backend-js-footprint-loader.mjs +++ /dev/null @@ -1,46 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { appendFileSync, statSync } from 'node:fs'; -import { extname } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const traceFile = process.env.MK_BACKEND_JS_FOOTPRINT_TRACE; -const jsExtensions = new Set(['.js', '.mjs', '.cjs']); - -function recordLoadedFile(kind, url, format) { - if (traceFile == null || !url.startsWith('file:')) return; - - let filePath; - try { - filePath = fileURLToPath(url); - } catch { - return; - } - - const extension = extname(filePath); - if (!jsExtensions.has(extension)) return; - - let size = null; - try { - size = statSync(filePath).size; - } catch { - return; - } - - appendFileSync(traceFile, `${JSON.stringify({ - kind, - format, - path: filePath, - size, - timestamp: Date.now(), - })}\n`); -} - -export async function load(url, context, nextLoad) { - const result = await nextLoad(url, context); - recordLoadedFile('esm', url, result.format ?? context.format ?? null); - return result; -} diff --git a/.github/scripts/backend-js-footprint-require.cjs b/.github/scripts/backend-js-footprint-require.cjs deleted file mode 100644 index 1adab8bc05..0000000000 --- a/.github/scripts/backend-js-footprint-require.cjs +++ /dev/null @@ -1,46 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -'use strict'; - -const { appendFileSync, statSync } = require('node:fs'); -const Module = require('node:module'); -const { extname } = require('node:path'); - -const traceFile = process.env.MK_BACKEND_JS_FOOTPRINT_TRACE; -const jsExtensions = new Set(['.js', '.mjs', '.cjs']); - -function recordLoadedFile(kind, filePath, request) { - if (traceFile == null || typeof filePath !== 'string') return; - - const extension = extname(filePath); - if (!jsExtensions.has(extension) && extension !== '.node') return; - - let size = null; - try { - size = statSync(filePath).size; - } catch { - return; - } - - appendFileSync(traceFile, `${JSON.stringify({ - kind, - format: extension === '.node' ? 'native' : 'commonjs', - path: filePath, - request, - size, - timestamp: Date.now(), - })}\n`); -} - -const originalLoad = Module._load; -const originalResolveFilename = Module._resolveFilename; - -Module._load = function load(request, parent, isMain) { - const resolved = originalResolveFilename.call(this, request, parent, isMain); - const result = originalLoad.apply(this, arguments); - recordLoadedFile('cjs', resolved, request); - return result; -}; diff --git a/.github/scripts/backend-js-footprint.mjs b/.github/scripts/backend-js-footprint.mjs deleted file mode 100644 index 9c98357ad6..0000000000 --- a/.github/scripts/backend-js-footprint.mjs +++ /dev/null @@ -1,473 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { fork, spawn } from 'node:child_process'; -import { createRequire } from 'node:module'; -import { cpus, tmpdir } from 'node:os'; -import { dirname, extname, join, relative, resolve, sep } from 'node:path'; -import { setTimeout } from 'node:timers/promises'; -import { fileURLToPath, pathToFileURL } from 'node:url'; -import { gzipSync } from 'node:zlib'; -import * as fs from 'node:fs/promises'; -import * as fsSync from 'node:fs'; -import * as http from 'node:http'; -import * as util from './utility.mts'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -const [repoDirArg, outputFileArg] = process.argv.slice(2); - -const STARTUP_TIMEOUT = util.readIntegerEnv('MK_JS_FOOTPRINT_STARTUP_TIMEOUT_MS', 120000, 1); -const SETTLE_TIME = util.readIntegerEnv('MK_JS_FOOTPRINT_SETTLE_TIME_MS', 10000, 0); -const REQUEST_COUNT = util.readIntegerEnv('MK_JS_FOOTPRINT_REQUEST_COUNT', 10, 0); - -const repoDir = resolve(repoDirArg); -const outputFile = resolve(outputFileArg); -const backendDir = join(repoDir, 'packages/backend'); -const backendBuiltDir = join(backendDir, 'built'); -const traceFile = join(tmpdir(), `misskey-backend-js-footprint-${process.pid}-${Date.now()}.jsonl`); -const require = createRequire(join(repoDir, 'package.json')); -const ts = require('typescript'); -const jsExtensions = new Set(['.js', '.mjs', '.cjs']); -const fileMetricCache = new Map(); -const packageInfoCache = new Map(); -const nativePackageNames = new Set(); - -function isInside(parent, child) { - const rel = relative(parent, child); - return rel === '' || (!rel.startsWith('..') && !rel.includes(`..${sep}`)); -} - -function bytesToKiB(value) { - return Math.round(value / 1024); -} - -async function resetState() { - const backendRequire = createRequire(join(backendDir, 'package.json')); - const pg = backendRequire('pg'); - const Redis = backendRequire('ioredis'); - - const postgres = new pg.Client({ - host: '127.0.0.1', - port: 54312, - database: 'postgres', - user: 'postgres', - }); - - await postgres.connect(); - try { - await postgres.query('DROP DATABASE IF EXISTS "test-misskey" WITH (FORCE)'); - await postgres.query('CREATE DATABASE "test-misskey"'); - } finally { - await postgres.end(); - } - - const redis = new Redis({ host: '127.0.0.1', port: 56312 }); - try { - await redis.flushall(); - } finally { - redis.disconnect(); - } -} - -function createRequest() { - return new Promise((resolvePromise, reject) => { - const req = http.request({ - host: 'localhost', - port: 61812, - path: '/api/meta', - method: 'POST', - }, res => { - res.on('data', () => { }); - res.on('end', () => resolvePromise()); - }); - req.on('error', reject); - req.end(); - }); -} - -async function waitForServerReady(serverProcess) { - let serverReady = false; - serverProcess.on('message', message => { - if (message === 'ok') serverReady = true; - }); - - const startupStartTime = Date.now(); - while (!serverReady) { - if (Date.now() - startupStartTime > STARTUP_TIMEOUT) { - serverProcess.kill('SIGTERM'); - throw new Error('Server startup timeout'); - } - await setTimeout(100); - } -} - -async function stopServer(serverProcess) { - serverProcess.kill('SIGTERM'); - - let exited = false; - await new Promise(resolvePromise => { - serverProcess.on('exit', () => { - exited = true; - resolvePromise(undefined); - }); - - setTimeout(10000).then(() => { - if (!exited) serverProcess.kill('SIGKILL'); - resolvePromise(undefined); - }); - }); -} - -function getPackageNameFromPath(filePath) { - const normalized = util.normalizePath(filePath); - const marker = '/node_modules/'; - const index = normalized.lastIndexOf(marker); - if (index === -1) return null; - - const rest = normalized.slice(index + marker.length).split('/'); - if (rest[0] === '.pnpm') { - const nestedNodeModulesIndex = rest.indexOf('node_modules'); - if (nestedNodeModulesIndex === -1) return null; - const packageParts = rest.slice(nestedNodeModulesIndex + 1); - if (packageParts.length === 0) return null; - return packageParts[0].startsWith('@') ? packageParts.slice(0, 2).join('/') : packageParts[0]; - } - - return rest[0]?.startsWith('@') ? rest.slice(0, 2).join('/') : rest[0] ?? null; -} - -function findPackageDir(filePath, packageName) { - const normalizedPackageName = packageName.split('/').join(sep); - let current = dirname(filePath); - - while (current !== dirname(current)) { - if (current.endsWith(`${sep}${normalizedPackageName}`) && fsSync.existsSync(join(current, 'package.json'))) { - return current; - } - - const parent = dirname(current); - if (parent === current) break; - current = parent; - } - - return null; -} - -function readPackageInfo(filePath) { - const externalPackageName = getPackageNameFromPath(filePath); - if (externalPackageName != null) { - const packageDir = findPackageDir(filePath, externalPackageName); - const cacheKey = packageDir ?? externalPackageName; - if (packageInfoCache.has(cacheKey)) return packageInfoCache.get(cacheKey); - - let version = null; - if (packageDir != null) { - try { - const packageJson = JSON.parse(fsSync.readFileSync(join(packageDir, 'package.json'), 'utf8')); - version = typeof packageJson.version === 'string' ? packageJson.version : null; - } catch { } - } - - const info = { - category: 'external', - name: externalPackageName, - version, - dir: packageDir, - }; - packageInfoCache.set(cacheKey, info); - return info; - } - - if (isInside(backendBuiltDir, filePath)) { - return { - category: 'internal', - name: 'backend', - version: null, - dir: backendDir, - }; - } - - return { - category: 'internal', - name: 'workspace', - version: null, - dir: repoDir, - }; -} - -function analyzeSource(filePath, source) { - const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS); - const metrics = { - astNodeCount: 0, - functionCount: 0, - classCount: 0, - stringLiteralBytes: 0, - }; - - function visit(node) { - metrics.astNodeCount += 1; - - if ( - ts.isFunctionDeclaration(node) || - ts.isFunctionExpression(node) || - ts.isArrowFunction(node) || - ts.isMethodDeclaration(node) || - ts.isConstructorDeclaration(node) || - ts.isGetAccessorDeclaration(node) || - ts.isSetAccessorDeclaration(node) - ) { - metrics.functionCount += 1; - } else if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) { - metrics.classCount += 1; - } else if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) { - metrics.stringLiteralBytes += Buffer.byteLength(node.text); - } - - ts.forEachChild(node, visit); - } - - visit(sourceFile); - return metrics; -} - -function readFileMetrics(filePath) { - if (fileMetricCache.has(filePath)) return fileMetricCache.get(filePath); - - const source = fsSync.readFileSync(filePath); - const sourceText = source.toString('utf8'); - const astMetrics = analyzeSource(filePath, sourceText); - const packageInfo = readPackageInfo(filePath); - const metric = { - path: filePath, - displayPath: util.normalizePath(relative(repoDir, filePath)), - sourceBytes: source.byteLength, - gzipBytes: gzipSync(source).byteLength, - ...astMetrics, - package: packageInfo, - }; - - fileMetricCache.set(filePath, metric); - return metric; -} - -async function readTraceRecords() { - let content = ''; - try { - content = await fs.readFile(traceFile, 'utf8'); - } catch (err) { - if (err.code === 'ENOENT') return []; - throw err; - } - - const records = []; - for (const line of content.split('\n')) { - if (line.trim() === '') continue; - try { - records.push(JSON.parse(line)); - } catch { } - } - return records; -} - -function emptyTotals() { - return { - loadedJsModules: 0, - loadedJsSourceBytes: 0, - loadedJsGzipBytes: 0, - astNodeCount: 0, - functionCount: 0, - classCount: 0, - stringLiteralBytes: 0, - externalPackageCount: 0, - nativeAddonPackageCount: 0, - }; -} - -function addFileMetrics(target, metric) { - target.loadedJsModules += 1; - target.loadedJsSourceBytes += metric.sourceBytes; - target.loadedJsGzipBytes += metric.gzipBytes; - target.astNodeCount += metric.astNodeCount; - target.functionCount += metric.functionCount; - target.classCount += metric.classCount; - target.stringLiteralBytes += metric.stringLiteralBytes; -} - -function summarizeRecords(records, phase) { - const jsPaths = new Set(); - const nativePaths = new Set(); - - for (const record of records) { - if (typeof record.path !== 'string') continue; - - const extension = extname(record.path); - if (jsExtensions.has(extension)) { - jsPaths.add(resolve(record.path)); - } else if (extension === '.node') { - nativePaths.add(resolve(record.path)); - } - } - - for (const nativePath of nativePaths) { - const packageInfo = readPackageInfo(nativePath); - if (packageInfo.category === 'external') nativePackageNames.add(packageInfo.name); - } - - const totals = emptyTotals(); - const packages = new Map(); - const modules = []; - - for (const filePath of [...jsPaths].toSorted()) { - let metric; - try { - metric = readFileMetrics(filePath); - } catch (err) { - process.stderr.write(`Failed to analyze ${filePath}: ${err.message}\n`); - continue; - } - - addFileMetrics(totals, metric); - - const packageKey = metric.package.name; - if (!packages.has(packageKey)) { - packages.set(packageKey, { - name: metric.package.name, - version: metric.package.version, - category: metric.package.category, - sourceBytes: 0, - gzipBytes: 0, - modules: 0, - astNodeCount: 0, - functionCount: 0, - classCount: 0, - stringLiteralBytes: 0, - nativeAddon: false, - }); - } - - const packageSummary = packages.get(packageKey); - packageSummary.sourceBytes += metric.sourceBytes; - packageSummary.gzipBytes += metric.gzipBytes; - packageSummary.modules += 1; - packageSummary.astNodeCount += metric.astNodeCount; - packageSummary.functionCount += metric.functionCount; - packageSummary.classCount += metric.classCount; - packageSummary.stringLiteralBytes += metric.stringLiteralBytes; - - modules.push({ - path: metric.displayPath, - package: metric.package.name, - category: metric.package.category, - sourceBytes: metric.sourceBytes, - gzipBytes: metric.gzipBytes, - astNodeCount: metric.astNodeCount, - functionCount: metric.functionCount, - classCount: metric.classCount, - stringLiteralBytes: metric.stringLiteralBytes, - }); - } - - for (const packageName of nativePackageNames) { - const packageSummary = packages.get(packageName); - if (packageSummary != null) packageSummary.nativeAddon = true; - } - - const externalPackages = [...packages.values()].filter(packageSummary => packageSummary.category === 'external'); - totals.externalPackageCount = externalPackages.length; - totals.nativeAddonPackageCount = externalPackages.filter(packageSummary => packageSummary.nativeAddon).length; - - return { - totals: { - ...totals, - loadedJsSourceKiB: bytesToKiB(totals.loadedJsSourceBytes), - loadedJsGzipKiB: bytesToKiB(totals.loadedJsGzipBytes), - stringLiteralKiB: bytesToKiB(totals.stringLiteralBytes), - }, - packages: [...packages.values()].toSorted((a, b) => b.sourceBytes - a.sourceBytes), - modules: modules.toSorted((a, b) => b.sourceBytes - a.sourceBytes), - }; -} - -async function measureFootprint() { - await fs.writeFile(traceFile, ''); - - process.stderr.write('Resetting database and Redis\n'); - await resetState(); - - process.stderr.write('Running migrations\n'); - await util.run('pnpm', ['--filter', 'backend', 'migrate'], { - cwd: repoDir, - env: process.env, - logStdout: true, - }); - - const serverProcess = fork(join(backendBuiltDir, 'entry.js'), [], { - cwd: backendDir, - env: { - ...process.env, - NODE_ENV: 'production', - MK_DISABLE_CLUSTERING: '1', - MK_ONLY_SERVER: '1', - MK_NO_DAEMONS: '1', - MK_BACKEND_JS_FOOTPRINT_TRACE: traceFile, - }, - stdio: ['pipe', 'pipe', 'pipe', 'ipc'], - execArgv: [ - '--require', - join(__dirname, 'backend-js-footprint-require.cjs'), - '--experimental-loader', - pathToFileURL(join(__dirname, 'backend-js-footprint-loader.mjs')).href, - ], - }); - - serverProcess.stdout?.on('data', data => { - process.stderr.write(`[server stdout] ${data}`); - }); - - serverProcess.stderr?.on('data', data => { - process.stderr.write(`[server stderr] ${data}`); - }); - - serverProcess.on('error', err => { - process.stderr.write(`[server error] ${err}\n`); - }); - - try { - await waitForServerReady(serverProcess); - await setTimeout(SETTLE_TIME); - - //const startup = summarizeRecords(await readTraceRecords(), 'startup'); - - await Promise.all( - Array.from({ length: REQUEST_COUNT }).map(() => createRequest()), - ); - await setTimeout(1000); - - const afterRequest = summarizeRecords(await readTraceRecords(), 'afterRequest'); - - return { - timestamp: new Date().toISOString(), - measurement: { - strategy: 'runtime-loader-trace', - startupTimeoutMs: STARTUP_TIMEOUT, - settleTimeMs: SETTLE_TIME, - requestCount: REQUEST_COUNT, - cpus: cpus().length, - }, - phases: { - //startup, - afterRequest, - }, - }; - } finally { - await stopServer(serverProcess); - await fs.rm(traceFile, { force: true }); - } -} - -const result = await measureFootprint(); -await fs.writeFile(outputFile, `${JSON.stringify(result, null, 2)}\n`); diff --git a/.github/scripts/backend-memory-report.mts b/.github/scripts/backend-memory-report.mts deleted file mode 100644 index 4a701655ec..0000000000 --- a/.github/scripts/backend-memory-report.mts +++ /dev/null @@ -1,428 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { readFile, writeFile } from 'node:fs/promises'; -import * as util from './utility.mts'; -import * as heapSnapshotUtil from './heap-snapshot-util.mts'; -import type { MemoryReport } from './measure-backend-memory-comparison.mts'; - -const [baseFile, headFile, outputFile, baseJsFootprintFile, headJsFootprintFile] = process.argv.slice(2); - -type RuntimeLoadedJsFootprintReport = { - phases: Record<'afterRequest', { - totals: { - loadedJsModules: number; - loadedJsSourceBytes: number; - loadedJsGzipBytes: number; - astNodeCount: number; - functionCount: number; - classCount: number; - stringLiteralBytes: number; - externalPackageCount: number; - nativeAddonPackageCount: number; - }; - modules: { - path: string; - package: string; - category: string; - sourceBytes: number; - gzipBytes: number; - astNodeCount: number; - functionCount: number; - classCount: number; - stringLiteralBytes: number; - }[]; - }>; -}; - -const memoryReportPhases = [ - { - key: 'afterGc', - title: 'After GC', - }, -] as const; - -const metrics = [ - 'HeapUsed', - 'Pss', - 'USS', - 'External', -] as const; - -function formatMemoryMb(valueKiB: number | null | undefined) { - if (valueKiB == null) return '-'; - return `${util.formatNumber(valueKiB / 1000)} MB`; -} - -function formatMetricName(metric: typeof metrics[number]) { - return metric === 'Pss' ? 'PSS' : metric; -} - -function getMemoryValueFromSample(sample: MemoryReport['samples'][number], phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) { - const memoryUsage = sample.phases[phase].memoryUsage; - if (metric !== 'USS') return memoryUsage[metric]; - return memoryUsage.Private_Clean + memoryUsage.Private_Dirty; -} - -function getSampleValues(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) { - return report.samples.map(sample => getMemoryValueFromSample(sample, phase, metric)); -} - -function getMemoryValue(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) { - if (metric !== 'USS') return report.summary[phase].memoryUsage[metric]; - return util.median(getSampleValues(report, phase, metric)); -} - -function getSampleSpread(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) { - const values = getSampleValues(report, phase, metric); - if (values.length < 2) return null; - - const center = util.median(values); - return util.median(values.map(value => Math.abs(value - center))); -} - -function renderMainTableForPhase(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key']) { - const lines = [ - '| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |', - '| --- | ---: | ---: | ---: | ---: | ---: | ---: |', - ]; - - function formatDeltaMemory(deltaKiB: number) { - return util.formatColoredDelta(deltaKiB, v => formatMemoryMb(v), 100); // 0.1 MB threshold - } - - for (const metric of metrics) { - const baseValue = getMemoryValue(base, phase, metric); - const headValue = getMemoryValue(head, phase, metric); - - const baseSpread = getSampleSpread(base, phase, metric); - const headSpread = getSampleSpread(head, phase, metric); - const summary = util.pairedDeltaSummary(base.samples, head.samples, (sample) => getMemoryValueFromSample(sample, phase, metric)); - const percent = summary.median * 100 / baseValue; - const deltaMedian = `${formatDeltaMemory(summary.median)}
${util.formatDeltaPercent(percent, 0.1).replaceAll('\\%', '\\\\%')}`; - - lines.push(`| **${formatMetricName(metric)}** | ${formatMemoryMb(baseValue)}
± ${formatMemoryMb(baseSpread)} | ${formatMemoryMb(headValue)}
± ${formatMemoryMb(headSpread)} | ${deltaMedian} | ${formatMemoryMb(summary.mad)} | ${formatDeltaMemory(summary.min)} | ${formatDeltaMemory(summary.max)} |`); - } - - return lines.join('\n'); -} - -/* -function measurementSummary(base, head) { - const baseCount = base?.sampleCount; - const headCount = head?.sampleCount; - const strategy = base?.comparison?.strategy; - if (baseCount == null || headCount == null) return null; - - if (strategy === 'interleaved-pairs') { - const rounds = base?.comparison?.rounds ?? baseCount; - const warmupRounds = base?.comparison?.warmupRounds ?? 0; - return `_Measured as ${rounds} interleaved base/head pairs after ${warmupRounds} warmup pair(s). Values are medians; ± is median absolute deviation._`; - } - - return `_Sample count: base ${baseCount}, head ${headCount}. Values are medians; ± is median absolute deviation._`; -} -*/ - -function renderHeapSnapshotSection(base: MemoryReport, head: MemoryReport) { - const baseHeapSnapshotReport = { - summary: base.summary.afterGc.heapSnapshot!, - samples: base.samples.map(sample => ({ - round: sample.round, - data: sample.phases.afterGc.heapSnapshot!, - })), - }; - - const headHeapSnapshotReport = { - summary: head.summary.afterGc.heapSnapshot!, - samples: head.samples.map(sample => ({ - round: sample.round, - data: sample.phases.afterGc.heapSnapshot!, - })), - }; - - const table = heapSnapshotUtil.renderHeapSnapshotTable(baseHeapSnapshotReport, headHeapSnapshotReport); - if (table == null) return null; - - const lines = [ - '### V8 Heap Snapshot Statistics', - '', - table, - '', - ]; - - for (const graph of [ - //heapSnapshotUtil.renderHeapSnapshotSankey(baseHeapSnapshotReport, 'Base'), - heapSnapshotUtil.renderHeapSnapshotSankey(headHeapSnapshotReport, 'Head'), - ]) { - if (graph == null) continue; - lines.push(graph); - lines.push(''); - } - - return lines.join('\n'); -} - -function getJsFootprintValue(report: RuntimeLoadedJsFootprintReport, phase: 'afterRequest', key: keyof RuntimeLoadedJsFootprintReport['phases'][typeof phase]['totals']) { - const value = report.phases[phase].totals[key]; - return Number.isFinite(value) ? value : null; -} - -function renderJsFootprintMetricTable(base: RuntimeLoadedJsFootprintReport, head: RuntimeLoadedJsFootprintReport) { - const metricRows = [ - ['Loaded JS modules', 'loadedJsModules', util.formatNumber], - ['Loaded JS source', 'loadedJsSourceBytes', util.formatBytes], - //['Loaded JS gzip estimate', 'loadedJsGzipBytes', util.formatBytes], - //['AST nodes', 'astNodeCount', util.formatNumber], - //['Functions', 'functionCount', util.formatNumber], - //['Classes', 'classCount', util.formatNumber], - //['String literals', 'stringLiteralBytes', util.formatBytes], - ['External packages loaded', 'externalPackageCount', util.formatNumber], - ['Native addon packages', 'nativeAddonPackageCount', util.formatNumber], - ] as const; - - const lines = [ - '| Metric | Base | Head | Δ | Δ (%) |', - '| --- | ---: | ---: | ---: | ---: |', - ]; - - for (const [title, key, formatter] of metricRows) { - const baseValue = getJsFootprintValue(base, 'afterRequest', key); - const headValue = getJsFootprintValue(head, 'afterRequest', key); - if (baseValue == null || headValue == null) continue; - - lines.push(`| **${title}** | ${formatter(baseValue)} | ${formatter(headValue)} | ${util.formatColoredDelta(headValue - baseValue, v => formatter(v))} | ${util.calcAndFormatDeltaPercent(baseValue, headValue).replaceAll('\\%', '\\\\%')} |`); - } - - return lines.join('\n'); -} - -/* -function renderJsFootprintPhaseTable(base, head) { - const lines = [ - '| Phase | Base modules | Head modules | Δ modules | Base source | Head source | Δ source |', - '| --- | ---: | ---: | ---: | ---: | ---: | ---: |', - ]; - - for (const [phase, title] of [['startup', 'Startup'], ['afterRequest', 'After warmup requests']]) { - const baseModules = getJsFootprintValue(base, phase, 'loadedJsModules'); - const headModules = getJsFootprintValue(head, phase, 'loadedJsModules'); - const baseSource = getJsFootprintValue(base, phase, 'loadedJsSourceBytes'); - const headSource = getJsFootprintValue(head, phase, 'loadedJsSourceBytes'); - if (baseModules == null || headModules == null || baseSource == null || headSource == null) continue; - - lines.push(`| ${title} | ${util.formatNumber(baseModules)} | ${util.formatNumber(headModules)} | ${formatPlainDelta(baseModules, headModules)} | ${util.formatBytes(baseSource)} | ${util.formatBytes(headSource)} | ${formatPlainDelta(baseSource, headSource, util.formatBytes)} |`); - } - - return lines.join('\n'); -} -*/ - -function packageMap(report: RuntimeLoadedJsFootprintReport) { - const map = new Map(); - for (const packageSummary of report.phases.afterRequest.packages) { - if (packageSummary?.category !== 'external' || typeof packageSummary.name !== 'string') continue; - map.set(packageSummary.name, packageSummary); - } - return map; -} - -function packageDisplayName(packageSummary: { name: string; version?: string | null }) { - if (packageSummary.version == null) return packageSummary.name; - return `${packageSummary.name} ${packageSummary.version}`; -} - -function renderNewExternalPackages(base: RuntimeLoadedJsFootprintReport, head: RuntimeLoadedJsFootprintReport) { - const basePackages = packageMap(base); - const headPackages = packageMap(head); - const newPackages = [...headPackages.values()] - .filter(packageSummary => !basePackages.has(packageSummary.name)) - .toSorted((a, b) => b.sourceBytes - a.sourceBytes) - .slice(0, 10); - - if (newPackages.length === 0) return null; - - const lines = [ - '#### Newly Loaded External Packages', - '', - '| Package | Loaded JS | Modules | Notes |', - '| --- | ---: | ---: | --- |', - ]; - - for (const packageSummary of newPackages) { - lines.push(`| ${packageDisplayName(packageSummary)} | ${util.formatBytes(packageSummary.sourceBytes)} | ${util.formatNumber(packageSummary.modules)} | ${packageSummary.nativeAddon ? 'native addon' : ''} |`); - } - - return lines.join('\n'); -} - -function renderLargestPackageIncreases(base: RuntimeLoadedJsFootprintReport, head: RuntimeLoadedJsFootprintReport) { - const basePackages = packageMap(base); - const headPackages = packageMap(head); - const increases = [...headPackages.values()] - .map(headPackage => { - const basePackage = basePackages.get(headPackage.name); - const baseSourceBytes = basePackage?.sourceBytes ?? 0; - const baseModules = basePackage?.modules ?? 0; - return { - ...headPackage, - baseSourceBytes, - baseModules, - sourceDiff: headPackage.sourceBytes - baseSourceBytes, - moduleDiff: headPackage.modules - baseModules, - }; - }) - .filter(packageSummary => packageSummary.sourceDiff > 0) - .toSorted((a, b) => b.sourceDiff - a.sourceDiff) - .slice(0, 10); - - if (increases.length === 0) return null; - - const lines = [ - '#### Largest Package Increases', - '', - '| Package | Base | Head | Δ | Modules Δ |', - '| --- | ---: | ---: | ---: | ---: |', - ]; - - for (const packageSummary of increases) { - lines.push(`| ${packageDisplayName(packageSummary)} | ${util.formatBytes(packageSummary.baseSourceBytes)} | ${util.formatBytes(packageSummary.sourceBytes)} | ${util.formatColoredDelta(packageSummary.sourceBytes - packageSummary.baseSourceBytes, v => util.formatBytes(v))} | ${util.formatColoredDelta(packageSummary.modules - packageSummary.baseModules, v => util.formatNumber(v))} |`); - } - - return lines.join('\n'); -} - -function renderNewLoadedModules(base: RuntimeLoadedJsFootprintReport, head: RuntimeLoadedJsFootprintReport) { - function moduleMap(report: RuntimeLoadedJsFootprintReport) { - const map = new Map(); - for (const moduleSummary of report.phases.afterRequest.modules) { - if (typeof moduleSummary.path !== 'string') continue; - map.set(moduleSummary.path, moduleSummary); - } - return map; - } - - const baseModules = moduleMap(base); - const headModules = moduleMap(head); - const newModules = [...headModules.values()] - .filter(moduleSummary => !baseModules.has(moduleSummary.path)) - .toSorted((a, b) => b.sourceBytes - a.sourceBytes) - .slice(0, 10); - - if (newModules.length === 0) return null; - - const lines = [ - '#### Largest Newly Loaded Modules', - '', - '| Module | Package | Loaded JS |', - '| --- | --- | ---: |', - ]; - - for (const moduleSummary of newModules) { - lines.push(`| \`${moduleSummary.path}\` | ${moduleSummary.package} | ${util.formatBytes(moduleSummary.sourceBytes)} |`); - } - - return lines.join('\n'); -} - -function renderJsFootprintSection(base: RuntimeLoadedJsFootprintReport, head: RuntimeLoadedJsFootprintReport) { - const lines = [ - '### Runtime Loaded JS Footprint', - '', - '
Click to show', - '', - renderJsFootprintMetricTable(base, head), - '', - //'#### Load Phase Breakdown', - //'', - //renderJsFootprintPhaseTable(base, head), - //'', - ]; - - for (const block of [ - renderNewExternalPackages(base, head), - renderLargestPackageIncreases(base, head), - renderNewLoadedModules(base, head), - ]) { - if (block == null) continue; - lines.push(block); - lines.push(''); - } - - lines.push('
'); - lines.push(''); - - return lines.join('\n'); -} - -const base = JSON.parse(await readFile(baseFile, 'utf8')) as MemoryReport; -const head = JSON.parse(await readFile(headFile, 'utf8')) as MemoryReport; -const baseJsFootprint = JSON.parse(await readFile(baseJsFootprintFile, 'utf8')) as RuntimeLoadedJsFootprintReport; -const headJsFootprint = JSON.parse(await readFile(headJsFootprintFile, 'utf8')) as RuntimeLoadedJsFootprintReport; -const lines = [ - '## ⚙️ Backend Memory Usage Report', - '', -]; - -//const summary = measurementSummary(base, head); -//if (summary != null) { -// lines.push(summary); -// lines.push(''); -//} - -for (const phase of memoryReportPhases) { - lines.push(`### ${phase.title}`); - lines.push(renderMainTableForPhase(base, head, phase.key)); - lines.push(''); -} - -const heapSnapshotSection = renderHeapSnapshotSection(base, head); -if (heapSnapshotSection != null) { - lines.push(heapSnapshotSection); - lines.push(''); -} - -const baseHeapSnapshotArtifactUrl = process.env.MK_MEMORY_HEAP_SNAPSHOT_ARTIFACT_URL_BASE!.trim(); -const headHeapSnapshotArtifactUrl = process.env.MK_MEMORY_HEAP_SNAPSHOT_ARTIFACT_URL_HEAD!.trim(); -lines.push(`Download representative V8 heap snapshot [base](${baseHeapSnapshotArtifactUrl}) / [head](${headHeapSnapshotArtifactUrl})`); -lines.push(''); - -const jsFootprintSection = renderJsFootprintSection(baseJsFootprint, headJsFootprint); -if (jsFootprintSection != null) { - lines.push(jsFootprintSection); - lines.push(''); -} - -function getDiffPercent(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) { - const baseValue = getMemoryValue(base, phase, metric); - const headValue = getMemoryValue(head, phase, metric); - return ((headValue - baseValue) * 100) / baseValue; -} - -function isBeyondSampleNoise(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) { - const baseValue = getMemoryValue(base, phase, metric); - const headValue = getMemoryValue(head, phase, metric); - - const delta = headValue - baseValue; - if (delta <= 0) return false; - - const baseSpread = getSampleSpread(base, phase, metric); - const headSpread = getSampleSpread(head, phase, metric); - if (baseSpread == null || headSpread == null) return true; - - const combinedSpread = Math.hypot(baseSpread, headSpread); - return delta > combinedSpread * 3; -} - -const warningMetric = 'Pss'; -const warningDiffPercent = getDiffPercent(base, head, 'afterGc', warningMetric); -if (warningDiffPercent > 5 && isBeyondSampleNoise(base, head, 'afterGc', warningMetric)) { - lines.push(`⚠️ **Warning**: Memory usage (${formatMetricName(warningMetric)}) has increased by more than 5% and exceeds the observed sample noise. Please verify this is not an unintended change.`); - lines.push(''); -} - -//lines.push(`[See workflow logs for details](https://github.com/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID})`); - -await writeFile(outputFile, `${lines.join('\n')}\n`); diff --git a/.github/scripts/chrome.mts b/.github/scripts/chrome.mts index 5d07ac6efe..b40c82a263 100644 --- a/.github/scripts/chrome.mts +++ b/.github/scripts/chrome.mts @@ -7,6 +7,7 @@ import { createRequire } from 'node:module'; import { writeFile } from 'node:fs/promises'; import type { Browser, BrowserContext, CDPSession, Page } from 'playwright'; import type { HeapSnapshotData } from './heap-snapshot-util.mts'; +import * as util from './utility.mts'; export type NetworkRequest = { requestId: string; @@ -89,11 +90,31 @@ export type TabMemory = { totalBytes: number; }; +export type BrowserDiagnostics = { + pageErrorCount: number; + console: Record<'log' | 'warning' | 'error' | 'info', number>; +}; + +export function summarizeBrowserDiagnostics(samples: BrowserDiagnostics[]): BrowserDiagnostics { + const median = (select: (sample: BrowserDiagnostics) => number) => util.median(samples.map(select)); + + return { + pageErrorCount: median(sample => sample.pageErrorCount), + console: { + log: median(sample => sample.console.log), + warning: median(sample => sample.console.warning), + error: median(sample => sample.console.error), + info: median(sample => sample.console.info), + }, + }; +} + export type BrowserMeasurement = { label: string; timestamp: string; url: string; scenario: string; + diagnostics: BrowserDiagnostics; durationMs: number; network: NetworkSummary; performance: { @@ -149,6 +170,10 @@ type PlaywrightBrowserOptions = { export class HeadlessChromeController { public networkRequests: NetworkRequest[] = []; public webSocketConnections: WebSocketConnection[] = []; + private readonly diagnostics = { + pageErrorCount: 0, + console: {} as Record, + }; private readonly browser: Browser; private readonly context: BrowserContext; public readonly page: Page; @@ -168,6 +193,16 @@ export class HeadlessChromeController { this.cdp = cdp; this.page.setDefaultTimeout(options.scenarioTimeoutMs); this.page.setDefaultNavigationTimeout(options.scenarioTimeoutMs); + this.page.on('pageerror', () => { + this.diagnostics.pageErrorCount++; + }); + this.page.on('console', message => { + if (this.diagnostics.console[message.type()] == null) { + this.diagnostics.console[message.type()] = 1; + } else { + this.diagnostics.console[message.type()]++; + } + }); } static async create(label: string, options: PlaywrightBrowserOptions): Promise { @@ -376,6 +411,18 @@ export class HeadlessChromeController { ]) as T; } + public collectDiagnostics(): BrowserDiagnostics { + return { + pageErrorCount: this.diagnostics.pageErrorCount, + console: { + log: this.diagnostics.console.log ?? 0, + warning: this.diagnostics.console.warning ?? 0, + error: this.diagnostics.console.error ?? 0, + info: this.diagnostics.console.info ?? 0, + }, + }; + } + public async collectPerformance(): Promise { const cdpMetricsResult = await this.cdp.send<{ metrics: { name: string; value: number }[] }>('Performance.getMetrics'); const cdpMetrics = Object.fromEntries(cdpMetricsResult.metrics.map(metric => [metric.name, metric.value])); diff --git a/.github/scripts/measure-frontend-browser-comparison.mts b/.github/scripts/frontend-browser-diagnostics.inspect.mts similarity index 85% rename from .github/scripts/measure-frontend-browser-comparison.mts rename to .github/scripts/frontend-browser-diagnostics.inspect.mts index 0825133b1d..1f0d74875f 100644 --- a/.github/scripts/measure-frontend-browser-comparison.mts +++ b/.github/scripts/frontend-browser-diagnostics.inspect.mts @@ -7,16 +7,23 @@ import { copyFile, mkdir, rm, writeFile } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import * as util from './utility.mts'; import * as heapSnapshotUtil from './heap-snapshot-util.mts'; -import { HeadlessChromeController, summarizeNetwork } from './chrome.mts'; +import { HeadlessChromeController, summarizeBrowserDiagnostics, summarizeNetwork } from './chrome.mts'; import type { BrowserMeasurement, NetworkRequest, NetworkSummary } from './chrome.mts'; import { closeUserSetupDialog, postNote, signupThroughUi, visitHome } from '../../packages/frontend/test/e2e/shared.ts'; -const [baseDirArg, headDirArg, baseOutputArg, headOutputArg, headHeapSnapshotOutputArg] = process.argv.slice(2); +const [baseDirArg, headDirArg, baseOutputArg, headOutputArg] = process.argv.slice(2); const baseUrl = process.env.FRONTEND_BROWSER_METRICS_URL ?? 'http://127.0.0.1:61812'; const sampleCount = util.readIntegerEnv('FRONTEND_BROWSER_METRICS_SAMPLE_COUNT', 5, 1); const heapSnapshotBreakdownTopN = util.readIntegerEnv('FRONTEND_BROWSER_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', heapSnapshotUtil.defaultHeapSnapshotBreakdownTopN, 1); -const headHeapSnapshotWorkDir = resolve('frontend-browser-head-heap-snapshots'); +const heapSnapshotWorkDirs = { + base: resolve('frontend-browser-base-heap-snapshots'), + head: resolve('frontend-browser-head-heap-snapshots'), +} as const; +const heapSnapshotOutputPaths = { + base: resolve('base-heap-snapshot.heapsnapshot'), + head: resolve('head-heap-snapshot.heapsnapshot'), +} as const; type BrowserMeasurementSample = BrowserMeasurement & { round: number; @@ -173,6 +180,7 @@ function summarizeSamples(label: 'base' | 'head', samples: BrowserMeasurementSam timestamp: new Date().toISOString(), url: baseUrl, scenario: representative.scenario, + diagnostics: summarizeBrowserDiagnostics(samples.map(sample => sample.diagnostics)), durationMs: finiteMedian(samples.map(sample => sample.durationMs)), network: summarizeNetworkSamples(samples), performance: summarizePerformanceSamples(samples), @@ -210,6 +218,7 @@ async function measureSample(label: 'base' | 'head', round: number, heapSnapshot timestamp: new Date().toISOString(), url: baseUrl, scenario: 'fresh browser signup, first timeline note, after the note becomes visible', + diagnostics: chrome.collectDiagnostics(), durationMs, network: summarizeNetwork(chrome.networkRequests, baseUrl, chrome.webSocketConnections), networkRequests: chrome.networkRequests, @@ -221,28 +230,26 @@ async function measureSample(label: 'base' | 'head', round: number, heapSnapshot }); } -function headHeapSnapshotPath(round: number) { - return join(headHeapSnapshotWorkDir, `round-${round}.heapsnapshot`); +function heapSnapshotPath(label: 'base' | 'head', round: number) { + return join(heapSnapshotWorkDirs[label], `round-${round}.heapsnapshot`); } -async function saveRepresentativeHeadHeapSnapshot(report: BrowserMetricsReport, outputPath: string) { +async function saveRepresentativeHeapSnapshot(label: 'base' | 'head', report: BrowserMetricsReport) { const representative = selectRepresentativeSample(report.samples, sample => sample.heapSnapshot.categories.total); - await copyFile(headHeapSnapshotPath(representative.round), outputPath); - process.stderr.write(`[head] Selected round ${representative.round} heap snapshot for artifact\n`); - await rm(headHeapSnapshotWorkDir, { recursive: true, force: true }); + await copyFile(heapSnapshotPath(label, representative.round), heapSnapshotOutputPaths[label]); + process.stderr.write(`[${label}] Selected round ${representative.round} heap snapshot for artifact\n`); + await rm(heapSnapshotWorkDirs[label], { recursive: true, force: true }); } -async function measureRepo(label: 'base' | 'head', repoDir: string, outputPath: string, heapSnapshotSavePath?: string) { +async function genReport(label: 'base' | 'head', repoDir: string, outputPath: string) { let server: ReturnType | null = null; try { server = util.startServer(label, repoDir); - await util.waitForServer(baseUrl, server!); + await util.waitForServer(baseUrl, server); - if (label === 'head' && heapSnapshotSavePath != null) { - await rm(headHeapSnapshotWorkDir, { recursive: true, force: true }); - await mkdir(headHeapSnapshotWorkDir, { recursive: true }); - } + await rm(heapSnapshotWorkDirs[label], { recursive: true, force: true }); + await mkdir(heapSnapshotWorkDirs[label], { recursive: true }); const samples: BrowserMeasurementSample[] = []; for (let round = 1; round <= sampleCount; round++) { @@ -250,7 +257,7 @@ async function measureRepo(label: 'base' | 'head', repoDir: string, outputPath: samples.push(await measureSample( label, round, - label === 'head' && heapSnapshotSavePath != null ? headHeapSnapshotPath(round) : undefined, + heapSnapshotPath(label, round), )); } @@ -258,17 +265,15 @@ async function measureRepo(label: 'base' | 'head', repoDir: string, outputPath: await writeFile(outputPath, JSON.stringify(report, null, '\t')); process.stderr.write(`[${label}] Wrote browser metrics report to ${outputPath}\n`); - if (label === 'head' && heapSnapshotSavePath != null) { - await saveRepresentativeHeadHeapSnapshot(report, heapSnapshotSavePath); - } + await saveRepresentativeHeapSnapshot(label, report); } finally { if (server != null) await util.stopServer(server); } } async function main() { - await measureRepo('base', resolve(baseDirArg), resolve(baseOutputArg)); - await measureRepo('head', resolve(headDirArg), resolve(headOutputArg), headHeapSnapshotOutputArg == null ? undefined : resolve(headHeapSnapshotOutputArg)); + await genReport('base', resolve(baseDirArg), resolve(baseOutputArg)); + await genReport('head', resolve(headDirArg), resolve(headOutputArg)); } await main(); diff --git a/.github/scripts/frontend-browser-detailed-html.mts b/.github/scripts/frontend-browser-diagnostics.render-additional-html.mts similarity index 98% rename from .github/scripts/frontend-browser-detailed-html.mts rename to .github/scripts/frontend-browser-diagnostics.render-additional-html.mts index 34240eefc6..568f4257e2 100644 --- a/.github/scripts/frontend-browser-detailed-html.mts +++ b/.github/scripts/frontend-browser-diagnostics.render-additional-html.mts @@ -6,7 +6,7 @@ import { readFile, writeFile } from 'node:fs/promises'; import { pathToFileURL } from 'node:url'; import * as util from './utility.mts'; -import type { BrowserMeasurementSample, BrowserMetricsReport } from './frontend-browser-report.mts'; +import type { BrowserMeasurementSample, BrowserMetricsReport } from './frontend-browser-diagnostics.render-md.mts'; import type { NetworkRequest } from './chrome.mts'; type DiffDirection = 'added' | 'removed'; @@ -434,15 +434,13 @@ function renderHtml(base: BrowserMetricsReport, head: BrowserMetricsReport) { async function main() { const [baseFile, headFile, outputFile] = process.argv.slice(2); - if (baseFile == null || headFile == null || outputFile == null) { - throw new Error('Usage: node frontend-browser-detailed-html.mts '); - } const base = JSON.parse(await readFile(baseFile, 'utf8')) as BrowserMetricsReport; const head = JSON.parse(await readFile(headFile, 'utf8')) as BrowserMetricsReport; await writeFile(outputFile, renderHtml(base, head)); } +// 直接実行されたときだけ呼ぶための判定(テストなどでこのファイル内の関数をimportするだけのとき用) if (process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href) { await main(); } diff --git a/.github/scripts/frontend-browser-report.mts b/.github/scripts/frontend-browser-diagnostics.render-md.mts similarity index 90% rename from .github/scripts/frontend-browser-report.mts rename to .github/scripts/frontend-browser-diagnostics.render-md.mts index 755b29bc88..76ff29b911 100644 --- a/.github/scripts/frontend-browser-report.mts +++ b/.github/scripts/frontend-browser-diagnostics.render-md.mts @@ -8,13 +8,14 @@ import { pathToFileURL } from 'node:url'; import * as util from './utility.mts'; import * as heapSnapshotUtil from './heap-snapshot-util.mts'; import type { HeapSnapshotData, HeapSnapshotReport } from './heap-snapshot-util.mts'; -import type { NetworkRequest } from './chrome.mts'; +import type { BrowserDiagnostics, NetworkRequest } from './chrome.mts'; export type BrowserMeasurement = { label: string; timestamp: string; url: string; scenario: string; + diagnostics: BrowserDiagnostics; durationMs: number; network: { requestCount: number; @@ -203,7 +204,12 @@ function renderSummaryTable(base: BrowserMetricsReport, head: BrowserMetricsRepo metricRow('WebSocket connections', base, head, summary => summary.network.webSocketConnectionCount, sample => sample.network.webSocketConnectionCount, util.formatNumber, 1, !all), metricRow('WebSocket sent', base, head, summary => summary.network.webSocketSentBytes, sample => sample.network.webSocketSentBytes, util.formatBytes, 10000, !all), metricRow('WebSocket received', base, head, summary => summary.network.webSocketReceivedBytes, sample => sample.network.webSocketReceivedBytes, util.formatBytes, 10000, !all), - metricRow('Tab memory', base, head, summary => summary.performance.tabMemory.totalBytes, sample => sample.performance.tabMemory.totalBytes, util.formatBytes, 10000, !all), + metricRow('Page errors', base, head, summary => summary.diagnostics.pageErrorCount, sample => sample.diagnostics.pageErrorCount, util.formatNumber, 1, !all), + metricRow('Console log', base, head, summary => summary.diagnostics.console.log, sample => sample.diagnostics.console.log, util.formatNumber, 1, !all), + metricRow('Console warnings', base, head, summary => summary.diagnostics.console.warning, sample => sample.diagnostics.console.warning, util.formatNumber, 1, !all), + metricRow('Console errors', base, head, summary => summary.diagnostics.console.error, sample => sample.diagnostics.console.error, util.formatNumber, 1, !all), + metricRow('Console info', base, head, summary => summary.diagnostics.console.info, sample => sample.diagnostics.console.info, util.formatNumber, 1, !all), + metricRow('Page-attributed memory', base, head, summary => summary.performance.tabMemory.totalBytes, sample => sample.performance.tabMemory.totalBytes, util.formatBytes, 10000, !all), ].filter(row => row != null); return [ @@ -307,25 +313,20 @@ function toHeapSnapshotReport(report: BrowserMetricsReport): HeapSnapshotReport }; } -export function renderFrontendBrowserReport(base: BrowserMetricsReport, head: BrowserMetricsReport, options: { - headHeapSnapshotUrl?: string; +function renderMd(base: BrowserMetricsReport, head: BrowserMetricsReport, options: { + baseHeapSnapshotUrl: string; + headHeapSnapshotUrl: string; detailedHtmlUrl?: string; -} = {}) { - const headHeapSnapshotUrl = options.headHeapSnapshotUrl; +}) { const detailedHtmlUrl = options.detailedHtmlUrl; - const sampleSummary = base.sampleCount === head.sampleCount - ? `${base.sampleCount} samples per side` - : `${base.sampleCount} base sample(s), ${head.sampleCount} head sample(s)`; const heapSnapshotTable = heapSnapshotUtil.renderHeapSnapshotTable(toHeapSnapshotReport(base), toHeapSnapshotReport(head)); const lines = [ - '## 🖥 Frontend Browser Metrics', - '', - 'Only metrics showing significant changes are displayed.', + '## 🖥 Frontend Browser Diagnostics Report', '', renderSummaryTable(base, head), '', - //`> Measured ${sampleSummary} with fresh headless Chrome profiles, browser cache disabled, service workers bypassed, and forced V8 GC before each heap snapshot. Base/Head values are medians; Δ median is the median of paired Head - Base sample deltas; percent uses Δ median / Base median; ± and Δ MAD are median absolute deviations. Scenario: sign up, dismiss the initial account setup dialog, create the first timeline note, then wait until that note is visible.`, - //'', + 'Only metrics showing significant changes are displayed.', + '', detailedHtmlUrl == null || detailedHtmlUrl === '' ? null : `[View details](${detailedHtmlUrl})`, detailedHtmlUrl == null || detailedHtmlUrl === '' ? null : '', '
', @@ -342,7 +343,7 @@ export function renderFrontendBrowserReport(base: BrowserMetricsReport, head: Br '', heapSnapshotUtil.renderHeapSnapshotSankey(toHeapSnapshotReport(head), 'Head'), '', - `[Download representative head heap snapshot](${headHeapSnapshotUrl})`, + `Download representative heap snapshot: [base](${options.baseHeapSnapshotUrl}) / [head](${options.headHeapSnapshotUrl})`, '
', '', ]; @@ -361,18 +362,17 @@ export function renderFrontendBrowserReport(base: BrowserMetricsReport, head: Br async function main() { const [baseFile, headFile, outputFile] = process.argv.slice(2); - if (baseFile == null || headFile == null || outputFile == null) { - throw new Error('Usage: node frontend-browser-report.mts '); - } const base = JSON.parse(await readFile(baseFile, 'utf8')) as BrowserMetricsReport; const head = JSON.parse(await readFile(headFile, 'utf8')) as BrowserMetricsReport; - await writeFile(outputFile, renderFrontendBrowserReport(base, head, { - headHeapSnapshotUrl: process.env.FRONTEND_BROWSER_HEAD_HEAP_SNAPSHOT_ARTIFACT_URL, + await writeFile(outputFile, renderMd(base, head, { + baseHeapSnapshotUrl: process.env.FRONTEND_BROWSER_BASE_HEAP_SNAPSHOT_ARTIFACT_URL!, + headHeapSnapshotUrl: process.env.FRONTEND_BROWSER_HEAD_HEAP_SNAPSHOT_ARTIFACT_URL!, detailedHtmlUrl: process.env.FRONTEND_BROWSER_DETAILED_HTML_ARTIFACT_URL, })); } +// 直接実行されたときだけ呼ぶための判定(テストなどでこのファイル内の関数をimportするだけのとき用) if (process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href) { await main(); } diff --git a/.github/scripts/frontend-js-size.mts b/.github/scripts/frontend-bundle-diagnostics.render-md.mts similarity index 99% rename from .github/scripts/frontend-js-size.mts rename to .github/scripts/frontend-bundle-diagnostics.render-md.mts index db134bab79..1e8cf37f24 100644 --- a/.github/scripts/frontend-js-size.mts +++ b/.github/scripts/frontend-bundle-diagnostics.render-md.mts @@ -7,9 +7,9 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; import * as util from './utility.mts'; -const marker = ''; +const marker = ''; -const locale = process.env.FRONTEND_JS_SIZE_LOCALE ?? 'ja-JP'; +const locale = 'ja-JP'; //function sharePercent(value, total) { // if (total === 0) return '0%'; diff --git a/.github/scripts/frontend-js-size.test.mts b/.github/scripts/frontend-js-size.test.mts deleted file mode 100644 index d3b3db9af6..0000000000 --- a/.github/scripts/frontend-js-size.test.mts +++ /dev/null @@ -1,252 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import assert from 'node:assert/strict'; -import { execFile } from 'node:child_process'; -import { promises as fs } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import test, { type TestContext } from 'node:test'; -import * as util from './utility.mts'; - -type Manifest = Record; - -const repoDir = path.resolve(import.meta.dirname, '../..'); -const reportScript = path.join(repoDir, '.github/scripts/frontend-js-size.mts'); - -async function writeBuild(repo: string, manifest: Manifest, sizes: Record) { - const outDir = path.join(repo, 'built/_frontend_vite_/'); - await fs.mkdir(path.join(outDir, 'ja-JP'), { recursive: true }); - await fs.writeFile(path.join(outDir, 'manifest.json'), JSON.stringify(manifest)); - for (const [file, size] of Object.entries(sizes)) { - const localizedFile = file.replace(/^scripts\//, ''); - const outputFile = path.join(outDir, 'ja-JP', localizedFile); - await fs.mkdir(path.dirname(outputFile), { recursive: true }); - await fs.writeFile(outputFile, Buffer.alloc(size)); - } -} - -async function runReport(t: TestContext, before: { manifest: Manifest; sizes: Record }, after: { manifest: Manifest; sizes: Record }, expectFailure = false) { - const root = await fs.mkdtemp(path.join(os.tmpdir(), 'frontend-js-size-')); - t.after(() => fs.rm(root, { recursive: true, force: true })); - const beforeDir = path.join(root, 'before'); - const afterDir = path.join(root, 'after'); - const beforeStats = path.join(root, 'before-stats.json'); - const afterStats = path.join(root, 'after-stats.json'); - const reportFile = path.join(root, 'report.md'); - await writeBuild(beforeDir, before.manifest, before.sizes); - await writeBuild(afterDir, after.manifest, after.sizes); - await fs.writeFile(beforeStats, '{}'); - await fs.writeFile(afterStats, '{}'); - const args = [reportScript, beforeDir, afterDir, beforeStats, afterStats, reportFile]; - if (expectFailure) { - return new Promise((resolve, reject) => { - execFile(process.execPath, args, (error, _stdout, stderr) => { - if (error == null) { - reject(new Error('Expected frontend report script to fail')); - } else { - resolve(stderr); - } - }); - }); - } - await util.run(process.execPath, args); - return fs.readFile(reportFile, 'utf8'); -} - -function fixture(suffix: string, generatedName: string, sizes: { entry: number; generatedA: number; generatedB: number; vue: number; i18n: number }) { - const files = { - entry: `scripts/entry-${suffix}.js`, - generatedA: `scripts/generated-a-${suffix}.js`, - generatedB: `scripts/generated-b-${suffix}.js`, - vue: `scripts/vue-${suffix}.js`, - i18n: `scripts/i18n-${suffix}.js`, - }; - return { - manifest: { - 'src/_boot_.ts': { file: files.entry, src: 'src/_boot_.ts', name: 'entry', isEntry: true, imports: ['_generatedA', '_generatedB', '_vue', '_i18n'] }, - _generatedA: { file: files.generatedA, name: generatedName }, - _generatedB: { file: files.generatedB, name: generatedName }, - _vue: { file: files.vue, name: 'vue' }, - _i18n: { file: files.i18n, name: 'i18n' }, - }, - sizes: Object.fromEntries(Object.entries(files).map(([key, file]) => [file, sizes[key as keyof typeof sizes]])), - }; -} - -test('groups generated chunks while preserving full and startup totals', async t => { - const report = await runReport( - t, - fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }), - fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 55, i18n: 50 }), - ); - - assert.match(report, /\| \(total\) \| 220 B \| 285 B \|/); - assert.equal(report.match(/\| \(other generated chunks\) \| 30 B \| 70 B \|/g)?.length, 2); - assert.doesNotMatch(report, /generated chunks are grouped/); - assert.doesNotMatch(report, /`(?:dist|esm)`<\/summary>/); - assert.match(report, /`src\/_boot_\.ts`<\/summary>/); - assert.match(report, /`vue`<\/summary>/); -}); - -test('groups small deltas at the bottom while preserving all change counts', async t => { - const before = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }); - before.manifest._removedSmall = { file: 'scripts/removed-small-before.js', src: 'src/removed-small.ts' }; - before.manifest['src/_boot_.ts'].imports?.push('_removedSmall'); - before.sizes['scripts/removed-small-before.js'] = 5; - - const after = fixture('after', 'esm', { entry: 106, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }); - after.manifest._addedSmall = { file: 'scripts/added-small-after.js', src: 'src/added-small.ts' }; - after.manifest['src/_boot_.ts'].imports?.push('_addedSmall'); - after.sizes['scripts/added-small-after.js'] = 5; - - const report = await runReport(t, before, after); - - assert.match(report, /Chunk size diff \(2 updated, 1 added, 1 removed\)<\/summary>/); - assert.match(report, /Startup chunk size \(2 updated, 1 added, 1 removed\)<\/summary>/); - assert.equal(report.match(/`src\/_boot_\.ts`<\/summary>/g)?.length, 2); - assert.doesNotMatch(report, /`(?:vue|i18n|src\/added-small\.ts|src\/removed-small\.ts)`<\/summary>/); - assert.match(report, /\| \(total\) \| 225 B \| 276 B \|[^\n]*\n\| \| \| \| \| \|\n\|
`src\/_boot_\.ts`<\/summary>[^\n]*\n\| \(other generated chunks\) \| 30 B \| 70 B \|[^\n]*\n\| \(other\) \| 45 B \| 50 B \|/); - assert.match(report, /\| \(total\) \| 225 B \| 276 B \|[^\n]*\n\| \| \| \| \| \|\n\|
`src\/_boot_\.ts`<\/summary>[^\n]*\n\| \(other generated chunks\) \| 30 B \| 70 B \|[^\n]*\n\| \(other\) \| 95 B \| 100 B \|/); -}); - -test('fails instead of overwriting duplicate stable chunk keys', async t => { - const duplicateVue = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }); - duplicateVue.manifest._vueDuplicate = { file: 'scripts/vue-duplicate-before.js', name: 'vue' }; - duplicateVue.sizes['scripts/vue-duplicate-before.js'] = 60; - - const diagnostic = await runReport( - t, - duplicateVue, - fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }), - true, - ); - assert.match(diagnostic, /Duplicate stable chunk key "named:vue".*vue-before\.js.*vue-duplicate-before\.js/); -}); - -test('shows both filenames for an updated stable chunk', async t => { - const report = await runReport( - t, - fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }), - fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 55, i18n: 50 }), - ); - - assert.match(report, /`ja-JP\/entry-before\.js → ja-JP\/entry-after\.js`/); - assert.match(report, /`ja-JP\/vue-before\.js → ja-JP\/vue-after\.js`/); -}); - -test('fails descriptively when the startup entry is missing', async t => { - const before = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }); - delete before.manifest['src/_boot_.ts']; - delete before.sizes['scripts/entry-before.js']; - - const diagnostic = await runReport( - t, - before, - fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }), - true, - ); - assert.match(diagnostic, /Unable to find frontend startup entry in Vite manifest/); -}); - -test('fails descriptively when a static import is missing from the manifest', async t => { - const before = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }); - before.manifest['src/_boot_.ts'].imports = ['_missing']; - - const diagnostic = await runReport( - t, - before, - fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }), - true, - ); - assert.match(diagnostic, /Startup manifest key "_missing".*is missing/); -}); - -test('fails descriptively when a static import has no output file', async t => { - const before = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }); - before.manifest['src/_boot_.ts'].imports = ['_malformed']; - before.manifest._malformed = { name: 'malformed' }; - - const diagnostic = await runReport( - t, - before, - fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }), - true, - ); - assert.match(diagnostic, /Startup manifest key "_malformed".*has no output file/); -}); - -test('fails descriptively when a static import resolves to a non-JavaScript output', async t => { - const before = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }); - before.manifest['src/_boot_.ts'].imports = ['_malformed']; - before.manifest._malformed = { file: 'assets/malformed.css', name: 'malformed' }; - - const diagnostic = await runReport( - t, - before, - fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }), - true, - ); - assert.match(diagnostic, /Startup manifest key "_malformed".*non-JavaScript output "assets\/malformed\.css"/); -}); - -test('keeps source-backed additions and removals as individual rows', async t => { - const before = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }); - before.manifest._removed = { file: 'scripts/removed-before.js', src: 'src/removed.ts' }; - before.sizes['scripts/removed-before.js'] = 12; - const after = fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }); - after.manifest._added = { file: 'scripts/added-after.js', src: 'src/added.ts' }; - after.sizes['scripts/added-after.js'] = 13; - - const report = await runReport(t, before, after); - - assert.match(report, /`src\/added\.ts`<\/summary> `ja-JP\/added-after\.js` <\/details> \| 0 B \| 13 B \|/); - assert.match(report, /`src\/removed\.ts`<\/summary> `ja-JP\/removed-before\.js` <\/details> \| 12 B \| 0 B \|/); -}); - -test('counts an unmanifested localized JavaScript file in totals and the generated aggregate', async t => { - const before = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }); - before.sizes['scripts/unmanifested-before.js'] = 15; - - const report = await runReport( - t, - before, - fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }), - ); - - assert.match(report, /\| \(total\) \| 235 B \| 275 B \|/); - assert.match(report, /\| \(other generated chunks\) \| 45 B \| 70 B \|/); -}); - -test('counts duplicate manifest entries for one physical output only once', async t => { - const before = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }); - before.manifest._generatedAlias = { file: 'scripts/generated-a-before.js', name: 'dist' }; - - const report = await runReport( - t, - before, - fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }), - ); - - assert.match(report, /\| \(total\) \| 220 B \| 275 B \|/); -}); - -test('does not count generated-aggregate-only changes as individual chunk changes', async t => { - const report = await runReport( - t, - fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }), - fixture('after', 'esm', { entry: 100, generatedA: 30, generatedB: 40, vue: 40, i18n: 50 }), - ); - - assert.match(report, /Chunk size diff \(0 updated, 0 added, 0 removed\)<\/summary>/); - assert.match(report, /Startup chunk size \(0 updated, 0 added, 0 removed\)<\/summary>/); - assert.equal(report.match(/\| \(other generated chunks\) \| 30 B \| 70 B \|/g)?.length, 2); -}); diff --git a/.github/workflows/report-backend-memory.yml b/.github/workflows/backend-diagnostics.comment.yml similarity index 89% rename from .github/workflows/report-backend-memory.yml rename to .github/workflows/backend-diagnostics.comment.yml index a6c0b4afdf..a19166a319 100644 --- a/.github/workflows/report-backend-memory.yml +++ b/.github/workflows/backend-diagnostics.comment.yml @@ -1,10 +1,10 @@ -name: Report backend memory +name: Backend diagnostics (comment) on: workflow_run: types: [completed] workflows: - - Get backend memory usage # get-backend-memory.yml + - Backend diagnostics (inspect) # backend-diagnostics.inspect.yml jobs: compare-memory: @@ -35,7 +35,7 @@ jobs: run: echo "pr-number=$(cat artifacts/pr_number)" >> "$GITHUB_OUTPUT" - name: Find heap snapshot artifacts id: find-heap-snapshot-artifacts - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: script: | const { owner, repo } = context.repo; @@ -60,7 +60,7 @@ jobs: env: MK_MEMORY_HEAP_SNAPSHOT_ARTIFACT_URL_BASE: ${{ steps.find-heap-snapshot-artifacts.outputs.base-url }} MK_MEMORY_HEAP_SNAPSHOT_ARTIFACT_URL_HEAD: ${{ steps.find-heap-snapshot-artifacts.outputs.head-url }} - run: node .github/scripts/backend-memory-report.mts ./artifacts/memory-base.json ./artifacts/memory-head.json ./output.md ./artifacts/js-footprint-base.json ./artifacts/js-footprint-head.json + run: node .github/scripts/backend-diagnostics.render-md.mts ./artifacts/memory-base.json ./artifacts/memory-head.json ./output.md - uses: thollander/actions-comment-pull-request@v3 with: pr-number: ${{ steps.load-pr-num.outputs.pr-number }} diff --git a/.github/workflows/get-backend-memory.yml b/.github/workflows/backend-diagnostics.inspect.yml similarity index 78% rename from .github/workflows/get-backend-memory.yml rename to .github/workflows/backend-diagnostics.inspect.yml index 1f933444bd..d5a47b804f 100644 --- a/.github/workflows/get-backend-memory.yml +++ b/.github/workflows/backend-diagnostics.inspect.yml @@ -1,5 +1,5 @@ -# this name is used in report-backend-memory.yml so be careful when change name -name: Get backend memory usage +# this name is used in backend-diagnostics.comment.yml so be careful when change name +name: Backend diagnostics (inspect) on: pull_request: @@ -10,14 +10,11 @@ on: - packages/backend/** - packages/misskey-js/** - .github/scripts/utility.mts - - .github/scripts/backend-memory-report.mts - - .github/scripts/measure-backend-memory-comparison.mts + - .github/scripts/backend-diagnostics.render-md.mts + - .github/scripts/backend-diagnostics.inspect.mts - .github/scripts/memory-stability-util*.mts - - .github/scripts/backend-js-footprint.mjs - - .github/scripts/backend-js-footprint-loader.mjs - - .github/scripts/backend-js-footprint-require.cjs - - .github/workflows/get-backend-memory.yml - - .github/workflows/report-backend-memory.yml + - .github/workflows/backend-diagnostics.inspect.yml + - .github/workflows/backend-diagnostics.comment.yml jobs: get-memory-usage: @@ -96,7 +93,7 @@ jobs: MK_MEMORY_COMPARE_ROUNDS: 10 MK_MEMORY_COMPARE_WARMUP_ROUNDS: 1 MK_MEMORY_HEAP_SNAPSHOT: 1 - run: node head/.github/scripts/measure-backend-memory-comparison.mts base head memory-base.json memory-head.json + run: node head/.github/scripts/backend-diagnostics.inspect.mts base head memory-base.json memory-head.json - name: Upload base heap snapshot uses: actions/upload-artifact@v7 with: @@ -111,10 +108,6 @@ jobs: archive: false if-no-files-found: error retention-days: 7 - - name: Measure backend loaded JS footprint - run: | - node head/.github/scripts/backend-js-footprint.mjs base js-footprint-base.json - node head/.github/scripts/backend-js-footprint.mjs head js-footprint-head.json - name: Upload Artifact uses: actions/upload-artifact@v7 with: @@ -122,8 +115,6 @@ jobs: path: | memory-base.json memory-head.json - js-footprint-base.json - js-footprint-head.json save-pr-number: runs-on: ubuntu-latest diff --git a/.github/workflows/frontend-browser-metrics-report-comment.yml b/.github/workflows/frontend-browser-diagnostics.comment.yml similarity index 62% rename from .github/workflows/frontend-browser-metrics-report-comment.yml rename to .github/workflows/frontend-browser-diagnostics.comment.yml index 0312092a5b..5098e618ad 100644 --- a/.github/workflows/frontend-browser-metrics-report-comment.yml +++ b/.github/workflows/frontend-browser-diagnostics.comment.yml @@ -1,9 +1,9 @@ -name: frontend-browser-metrics-report-comment +name: Frontend browser diagnostics (comment) on: workflow_run: workflows: - - frontend-browser-metrics-report + - Frontend browser diagnostics (inspect) types: - completed @@ -15,18 +15,18 @@ permissions: jobs: comment: - name: Comment frontend browser metrics report + name: Comment frontend browser diagnostics report if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' runs-on: ubuntu-latest concurrency: - group: frontend-browser-metrics-report-comment-${{ github.event.workflow_run.id }} + group: frontend-browser-diagnostics-report-${{ github.event.workflow_run.id }} cancel-in-progress: true steps: - name: Download browser metrics report uses: actions/download-artifact@v8 with: - name: frontend-browser-metrics-report - path: ${{ runner.temp }}/frontend-browser-metrics-report + name: frontend-browser-diagnostics + path: ${{ runner.temp }}/frontend-browser-diagnostics github-token: ${{ github.token }} repository: ${{ github.repository }} run-id: ${{ github.event.workflow_run.id }} @@ -34,11 +34,11 @@ jobs: - name: Load PR number id: load-pr-number shell: bash - run: echo "pr-number=$(cat "$RUNNER_TEMP/frontend-browser-metrics-report/pr-number.txt")" >> "$GITHUB_OUTPUT" + run: echo "pr-number=$(cat "$RUNNER_TEMP/frontend-browser-diagnostics/pr-number.txt")" >> "$GITHUB_OUTPUT" - name: Comment on pull request uses: thollander/actions-comment-pull-request@v3 with: pr-number: ${{ steps.load-pr-number.outputs.pr-number }} - comment-tag: frontend_browser_metrics_report - file-path: ${{ runner.temp }}/frontend-browser-metrics-report/frontend-browser-metrics-report.md + comment-tag: frontend-browser-diagnostics + file-path: ${{ runner.temp }}/frontend-browser-diagnostics/frontend-browser-diagnostics.md diff --git a/.github/workflows/frontend-browser-metrics-report.yml b/.github/workflows/frontend-browser-diagnostics.inspect.yml similarity index 65% rename from .github/workflows/frontend-browser-metrics-report.yml rename to .github/workflows/frontend-browser-diagnostics.inspect.yml index 863c7bb155..92f645ab26 100644 --- a/.github/workflows/frontend-browser-metrics-report.yml +++ b/.github/workflows/frontend-browser-diagnostics.inspect.yml @@ -1,4 +1,4 @@ -name: frontend-browser-metrics-report +name: Frontend browser diagnostics (inspect) on: pull_request: @@ -23,20 +23,20 @@ on: - .node-version - .github/misskey/test.yml - .github/scripts/utility.mts - - .github/scripts/frontend-browser-detailed-html.mts - - .github/scripts/frontend-browser-report.mts - .github/scripts/heap-snapshot-util.mts - - .github/scripts/measure-frontend-browser-comparison.mts - .github/scripts/chrome.mts - - .github/workflows/frontend-browser-metrics-report.yml - - .github/workflows/frontend-browser-metrics-report-comment.yml + - .github/scripts/frontend-browser-diagnostics.render-additional-html.mts + - .github/scripts/frontend-browser-diagnostics.render-md.mts + - .github/scripts/frontend-browser-diagnostics.inspect.mts + - .github/workflows/frontend-browser-diagnostics.inspect.yml + - .github/workflows/frontend-browser-diagnostics.comment.yml permissions: contents: read pull-requests: read concurrency: - group: frontend-browser-metrics-report-${{ github.event.pull_request.number }} + group: frontend-browser-diagnostics-inspect-${{ github.event.pull_request.number }} cancel-in-progress: true jobs: @@ -60,7 +60,7 @@ jobs: steps: - name: Checkout base - uses: actions/checkout@v6.0.2 + uses: actions/checkout@v6.0.3 with: persist-credentials: false repository: ${{ github.event.pull_request.base.repo.full_name }} @@ -69,7 +69,7 @@ jobs: submodules: true - name: Checkout pull request - uses: actions/checkout@v6.0.2 + uses: actions/checkout@v6.0.3 with: persist-credentials: false repository: ${{ github.event.pull_request.head.repo.full_name }} @@ -78,7 +78,7 @@ jobs: submodules: true - name: Setup pnpm - uses: pnpm/action-setup@v6.0.3 + uses: pnpm/action-setup@v6.0.9 with: package_json_file: after/package.json @@ -125,33 +125,42 @@ jobs: FRONTEND_BROWSER_METRICS_SAMPLE_COUNT: 5 MK_ENABLE_CROSS_ORIGIN_ISOLATION: "true" run: | - REPORT_DIR="$RUNNER_TEMP/frontend-browser-metrics-report" + REPORT_DIR="$RUNNER_TEMP/frontend-browser-diagnostics" mkdir -p "$REPORT_DIR" - node after/.github/scripts/measure-frontend-browser-comparison.mts before after "$REPORT_DIR/before-browser.json" "$REPORT_DIR/after-browser.json" "$REPORT_DIR/head-heap-snapshot.heapsnapshot" + node after/.github/scripts/frontend-browser-diagnostics.inspect.mts before after "$REPORT_DIR/before-browser.json" "$REPORT_DIR/after-browser.json" + + - name: Upload browser base heap snapshot + id: upload-browser-base-heap-snapshot + uses: actions/upload-artifact@v7 + with: + path: base-heap-snapshot.heapsnapshot + archive: false + if-no-files-found: error + retention-days: 7 - name: Upload browser head heap snapshot id: upload-browser-head-heap-snapshot uses: actions/upload-artifact@v7 with: - name: frontend-browser-metrics-head-heap-snapshot - path: ${{ runner.temp }}/frontend-browser-metrics-report/head-heap-snapshot.heapsnapshot + path: head-heap-snapshot.heapsnapshot + archive: false if-no-files-found: error retention-days: 7 - name: Generate browser detailed html shell: bash run: | - REPORT_DIR="$RUNNER_TEMP/frontend-browser-metrics-report" + REPORT_DIR="$RUNNER_TEMP/frontend-browser-diagnostics" test -s "$REPORT_DIR/before-browser.json" test -s "$REPORT_DIR/after-browser.json" - node after/.github/scripts/frontend-browser-detailed-html.mts "$REPORT_DIR/before-browser.json" "$REPORT_DIR/after-browser.json" "$REPORT_DIR/frontend-browser-detailed-html.html" + node after/.github/scripts/frontend-browser-diagnostics.render-additional-html.mts "$REPORT_DIR/before-browser.json" "$REPORT_DIR/after-browser.json" "$REPORT_DIR/frontend-browser-detailed-html.html" - name: Upload browser detailed html id: upload-browser-detailed-html uses: actions/upload-artifact@v7 with: name: frontend-browser-metrics-detailed-html - path: ${{ runner.temp }}/frontend-browser-metrics-report/frontend-browser-detailed-html.html + path: ${{ runner.temp }}/frontend-browser-diagnostics/frontend-browser-detailed-html.html if-no-files-found: error archive: false retention-days: 7 @@ -162,13 +171,14 @@ jobs: BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} PR_NUMBER: ${{ github.event.pull_request.number }} + FRONTEND_BROWSER_BASE_HEAP_SNAPSHOT_ARTIFACT_URL: ${{ steps.upload-browser-base-heap-snapshot.outputs.artifact-url }} FRONTEND_BROWSER_HEAD_HEAP_SNAPSHOT_ARTIFACT_URL: ${{ steps.upload-browser-head-heap-snapshot.outputs.artifact-url }} FRONTEND_BROWSER_DETAILED_HTML_ARTIFACT_URL: ${{ steps.upload-browser-detailed-html.outputs.artifact-url }} run: | - REPORT_DIR="$RUNNER_TEMP/frontend-browser-metrics-report" + REPORT_DIR="$RUNNER_TEMP/frontend-browser-diagnostics" test -s "$REPORT_DIR/before-browser.json" test -s "$REPORT_DIR/after-browser.json" - node after/.github/scripts/frontend-browser-report.mts "$REPORT_DIR/before-browser.json" "$REPORT_DIR/after-browser.json" "$REPORT_DIR/frontend-browser-metrics-report.md" + node after/.github/scripts/frontend-browser-diagnostics.render-md.mts "$REPORT_DIR/before-browser.json" "$REPORT_DIR/after-browser.json" "$REPORT_DIR/frontend-browser-diagnostics.md" printf '%s\n' "$PR_NUMBER" > "$REPORT_DIR/pr-number.txt" printf '%s\n' "$BASE_SHA" > "$REPORT_DIR/base-sha.txt" printf '%s\n' "$HEAD_SHA" > "$REPORT_DIR/head-sha.txt" @@ -177,24 +187,24 @@ jobs: - name: Check browser metrics report shell: bash run: | - REPORT_DIR="$RUNNER_TEMP/frontend-browser-metrics-report" - test -s "$REPORT_DIR/frontend-browser-metrics-report.md" + REPORT_DIR="$RUNNER_TEMP/frontend-browser-diagnostics" + test -s "$REPORT_DIR/frontend-browser-diagnostics.md" test -s "$REPORT_DIR/frontend-browser-detailed-html.html" test -s "$REPORT_DIR/pr-number.txt" test -s "$REPORT_DIR/head-sha.txt" - cat "$REPORT_DIR/frontend-browser-metrics-report.md" >> "$GITHUB_STEP_SUMMARY" + cat "$REPORT_DIR/frontend-browser-diagnostics.md" >> "$GITHUB_STEP_SUMMARY" - name: Upload browser metrics report uses: actions/upload-artifact@v7 with: - name: frontend-browser-metrics-report + name: frontend-browser-diagnostics path: | - ${{ runner.temp }}/frontend-browser-metrics-report/before-browser.json - ${{ runner.temp }}/frontend-browser-metrics-report/after-browser.json - ${{ runner.temp }}/frontend-browser-metrics-report/frontend-browser-metrics-report.md - ${{ runner.temp }}/frontend-browser-metrics-report/pr-number.txt - ${{ runner.temp }}/frontend-browser-metrics-report/base-sha.txt - ${{ runner.temp }}/frontend-browser-metrics-report/head-sha.txt - ${{ runner.temp }}/frontend-browser-metrics-report/pr-url.txt + ${{ runner.temp }}/frontend-browser-diagnostics/before-browser.json + ${{ runner.temp }}/frontend-browser-diagnostics/after-browser.json + ${{ runner.temp }}/frontend-browser-diagnostics/frontend-browser-diagnostics.md + ${{ runner.temp }}/frontend-browser-diagnostics/pr-number.txt + ${{ runner.temp }}/frontend-browser-diagnostics/base-sha.txt + ${{ runner.temp }}/frontend-browser-diagnostics/head-sha.txt + ${{ runner.temp }}/frontend-browser-diagnostics/pr-url.txt if-no-files-found: error retention-days: 7 diff --git a/.github/workflows/frontend-bundle-diagnostics.comment.yml b/.github/workflows/frontend-bundle-diagnostics.comment.yml new file mode 100644 index 0000000000..709c735e69 --- /dev/null +++ b/.github/workflows/frontend-bundle-diagnostics.comment.yml @@ -0,0 +1,44 @@ +name: Frontend bundle diagnostics (comment) + +on: + workflow_run: + workflows: + - Frontend bundle diagnostics (inspect) + types: + - completed + +permissions: + actions: read + contents: read + issues: write + pull-requests: write + +jobs: + comment: + name: Comment frontend bundle report + if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + concurrency: + group: frontend-bundle-diagnostics-report-${{ github.event.workflow_run.id }} + cancel-in-progress: true + steps: + - name: Download bundle report + uses: actions/download-artifact@v8 + with: + name: frontend-bundle-report + path: ${{ runner.temp }}/frontend-bundle-report + github-token: ${{ github.token }} + repository: ${{ github.repository }} + run-id: ${{ github.event.workflow_run.id }} + + - name: Load PR number + id: load-pr-number + shell: bash + run: echo "pr-number=$(cat "$RUNNER_TEMP/frontend-bundle-report/pr-number.txt")" >> "$GITHUB_OUTPUT" + + - name: Comment on pull request + uses: thollander/actions-comment-pull-request@v3 + with: + pr-number: ${{ steps.load-pr-number.outputs.pr-number }} + comment-tag: frontend-bundle-diagnostics + file-path: ${{ runner.temp }}/frontend-bundle-report/frontend-bundle-diagnostics-report.md diff --git a/.github/workflows/frontend-bundle-report.yml b/.github/workflows/frontend-bundle-diagnostics.inspect.yml similarity index 69% rename from .github/workflows/frontend-bundle-report.yml rename to .github/workflows/frontend-bundle-diagnostics.inspect.yml index b5af3bb0e1..6aa4f54fb4 100644 --- a/.github/workflows/frontend-bundle-report.yml +++ b/.github/workflows/frontend-bundle-diagnostics.inspect.yml @@ -1,4 +1,4 @@ -name: frontend-bundle-report +name: Frontend bundle diagnostics (inspect) on: pull_request: @@ -21,24 +21,22 @@ on: - pnpm-workspace.yaml - .node-version - .github/scripts/utility.mts - - .github/scripts/frontend-js-size.mts - - .github/workflows/frontend-bundle-report.yml - - .github/workflows/frontend-bundle-report-comment.yml + - .github/scripts/frontend-bundle-diagnostics.render-md.mts + - .github/workflows/frontend-bundle-diagnostics.inspect.yml + - .github/workflows/frontend-bundle-diagnostics.comment.yml permissions: contents: read pull-requests: read concurrency: - group: frontend-bundle-report-${{ github.event.pull_request.number }} + group: frontend-bundle-diagnostics-inspect-${{ github.event.pull_request.number }} cancel-in-progress: true jobs: report: name: Build frontend bundle report runs-on: ubuntu-latest - env: - FRONTEND_JS_SIZE_LOCALE: ja-JP steps: - name: Checkout base uses: actions/checkout@v6.0.3 @@ -56,25 +54,12 @@ jobs: path: after submodules: true - - name: Check base visualizer support - id: check-base-visualizer - shell: bash - run: | - if grep -q 'FRONTEND_BUNDLE_VISUALIZER' before/packages/frontend/vite.config.ts; then - echo 'supported=true' >> "$GITHUB_OUTPUT" - else - echo 'supported=false' >> "$GITHUB_OUTPUT" - echo 'Base commit does not support frontend bundle visualizer. Skipping frontend bundle report.' >> "$GITHUB_STEP_SUMMARY" - fi - - name: Setup pnpm - if: steps.check-base-visualizer.outputs.supported == 'true' uses: pnpm/action-setup@v6.0.9 with: package_json_file: after/package.json - name: Setup Node.js - if: steps.check-base-visualizer.outputs.supported == 'true' uses: actions/setup-node@v6.4.0 with: node-version-file: after/.node-version @@ -84,21 +69,17 @@ jobs: after/pnpm-lock.yaml - name: Install dependencies for base - if: steps.check-base-visualizer.outputs.supported == 'true' working-directory: before run: pnpm i --frozen-lockfile - name: Build frontend dependencies for base - if: steps.check-base-visualizer.outputs.supported == 'true' working-directory: before run: pnpm --filter "frontend^..." run build - name: Prepare report output - if: steps.check-base-visualizer.outputs.supported == 'true' run: mkdir -p "$RUNNER_TEMP/frontend-bundle-report" - name: Build frontend report for base - if: steps.check-base-visualizer.outputs.supported == 'true' working-directory: before env: FRONTEND_BUNDLE_VISUALIZER: 'true' @@ -106,17 +87,14 @@ jobs: run: pnpm --filter frontend run build - name: Install dependencies for pull request - if: steps.check-base-visualizer.outputs.supported == 'true' working-directory: after run: pnpm i --frozen-lockfile - name: Build frontend dependencies for pull request - if: steps.check-base-visualizer.outputs.supported == 'true' working-directory: after run: pnpm --filter "frontend^..." run build - name: Build frontend report for pull request - if: steps.check-base-visualizer.outputs.supported == 'true' working-directory: after env: FRONTEND_BUNDLE_VISUALIZER: 'true' @@ -125,7 +103,6 @@ jobs: run: pnpm --filter frontend run build - name: Upload bundle visualizer - if: steps.check-base-visualizer.outputs.supported == 'true' id: upload-bundle-visualizer uses: actions/upload-artifact@v7 with: @@ -136,7 +113,6 @@ jobs: retention-days: 7 - name: Generate report markdown - if: steps.check-base-visualizer.outputs.supported == 'true' shell: bash env: BASE_SHA: ${{ github.event.pull_request.base.sha }} @@ -145,23 +121,21 @@ jobs: FRONTEND_BUNDLE_REPORT_ARTIFACT_URL: ${{ steps.upload-bundle-visualizer.outputs.artifact-url }} run: | REPORT_DIR="$RUNNER_TEMP/frontend-bundle-report" - node after/.github/scripts/frontend-js-size.mts before after "$REPORT_DIR/before-stats.json" "$REPORT_DIR/after-stats.json" "$REPORT_DIR/frontend-js-size-report.md" + node after/.github/scripts/frontend-bundle-diagnostics.render-md.mts before after "$REPORT_DIR/before-stats.json" "$REPORT_DIR/after-stats.json" "$REPORT_DIR/frontend-bundle-diagnostics-report.md" printf '%s\n' "$PR_NUMBER" > "$REPORT_DIR/pr-number.txt" printf '%s\n' "$BASE_SHA" > "$REPORT_DIR/base-sha.txt" printf '%s\n' "$HEAD_SHA" > "$REPORT_DIR/head-sha.txt" printf '%s\n' "${{ github.event.pull_request.html_url }}" > "$REPORT_DIR/pr-url.txt" - name: Check report - if: steps.check-base-visualizer.outputs.supported == 'true' run: | REPORT_DIR="$RUNNER_TEMP/frontend-bundle-report" test -s "$REPORT_DIR/before-stats.json" test -s "$REPORT_DIR/after-stats.json" - test -s "$REPORT_DIR/frontend-js-size-report.md" - cat "$REPORT_DIR/frontend-js-size-report.md" >> "$GITHUB_STEP_SUMMARY" + test -s "$REPORT_DIR/frontend-bundle-diagnostics-report.md" + cat "$REPORT_DIR/frontend-bundle-diagnostics-report.md" >> "$GITHUB_STEP_SUMMARY" - name: Upload bundle report - if: steps.check-base-visualizer.outputs.supported == 'true' uses: actions/upload-artifact@v7 with: name: frontend-bundle-report diff --git a/.github/workflows/frontend-bundle-report-comment.yml b/.github/workflows/frontend-bundle-report-comment.yml deleted file mode 100644 index b93f8a320f..0000000000 --- a/.github/workflows/frontend-bundle-report-comment.yml +++ /dev/null @@ -1,318 +0,0 @@ -name: frontend-bundle-report-comment - -on: - workflow_run: - workflows: - - frontend-bundle-report - types: - - completed - pull_request_target: - types: - - opened - - synchronize - - reopened - - ready_for_review - paths: - - packages/frontend/** - - packages/frontend-shared/** - - packages/frontend-builder/** - - packages/i18n/** - - packages/icons-subsetter/** - - packages/misskey-js/** - - packages/misskey-reversi/** - - packages/misskey-bubble-game/** - - package.json - - pnpm-lock.yaml - - pnpm-workspace.yaml - - .node-version - - .github/scripts/utility.mts - - .github/scripts/frontend-js-size.mts - - .github/workflows/frontend-bundle-report.yml - - .github/workflows/frontend-bundle-report-comment.yml - -permissions: - actions: read - contents: read - issues: write - pull-requests: write - -jobs: - comment: - name: Comment frontend bundle report - if: github.event_name == 'pull_request_target' || (github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success') - runs-on: ubuntu-latest - concurrency: - group: frontend-bundle-report-comment-${{ github.event.pull_request.number || github.event.workflow_run.id }} - cancel-in-progress: true - steps: - - name: Find bundle report run - if: github.event_name == 'pull_request_target' - id: find-report-run - uses: actions/github-script@v9 - with: - script: | - const workflow_id = 'frontend-bundle-report.yml'; - const artifactName = 'frontend-bundle-report'; - const headSha = context.payload.pull_request.head.sha; - const prNumber = context.payload.pull_request.number; - const pollIntervalMs = 30_000; - const timeoutMs = 90 * 60_000; - const startedAt = Date.now(); - const { owner, repo } = context.repo; - - async function listReportWorkflowRuns() { - const runsForHead = await github.paginate(github.rest.actions.listWorkflowRuns, { - owner, - repo, - workflow_id, - event: 'pull_request', - head_sha: headSha, - per_page: 100, - }); - - if (runsForHead.length > 0) { - return runsForHead; - } - - const recentRuns = await github.paginate(github.rest.actions.listWorkflowRuns, { - owner, - repo, - workflow_id, - event: 'pull_request', - per_page: 100, - }); - return recentRuns.filter((run) => - run.pull_requests?.some((pullRequest) => pullRequest.number === prNumber)); - } - - async function findReportRun() { - const runs = (await listReportWorkflowRuns()) - .sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at)); - - for (const run of runs) { - if (run.status !== 'completed') continue; - if (run.conclusion !== 'success') { - core.warning(`Frontend bundle report run ${run.id} completed with conclusion: ${run.conclusion}`); - return { done: true, run: null }; - } - - const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { - owner, - repo, - run_id: run.id, - per_page: 100, - }); - const report = artifacts.find((artifact) => artifact.name === artifactName && !artifact.expired); - if (report) return { done: true, run }; - - core.info(`Frontend bundle report run ${run.id} did not produce ${artifactName}.`); - return { done: true, run: null }; - } - - return { done: false, run: null }; - } - - while (Date.now() - startedAt < timeoutMs) { - const { done, run } = await findReportRun(); - if (run) { - core.info(`Found frontend bundle report on workflow run ${run.id}.`); - core.setOutput('run-id', String(run.id)); - return; - } - if (done) { - return; - } - - core.info('Waiting for frontend bundle report artifact...'); - await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); - } - - core.warning(`Timed out waiting for ${artifactName} from ${workflow_id} for ${headSha}.`); - - - name: Find bundle report artifact - if: github.event_name == 'workflow_run' - id: find-report-artifact - uses: actions/github-script@v9 - with: - script: | - const artifactName = 'frontend-bundle-report'; - const { owner, repo } = context.repo; - const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { - owner, - repo, - run_id: context.payload.workflow_run.id, - per_page: 100, - }); - const report = artifacts.find((artifact) => artifact.name === artifactName && !artifact.expired); - if (report) { - core.setOutput('exists', 'true'); - } else { - core.info(`Workflow run ${context.payload.workflow_run.id} did not produce ${artifactName}.`); - core.setOutput('exists', 'false'); - } - - - name: Download bundle report from workflow_run - if: github.event_name == 'workflow_run' && steps.find-report-artifact.outputs.exists == 'true' - uses: actions/download-artifact@v8 - with: - name: frontend-bundle-report - path: ${{ runner.temp }}/frontend-bundle-report - github-token: ${{ github.token }} - repository: ${{ github.repository }} - run-id: ${{ github.event.workflow_run.id }} - - - name: Download bundle report from pull_request_target - if: github.event_name == 'pull_request_target' && steps.find-report-run.outputs.run-id != '' - uses: actions/download-artifact@v8 - with: - name: frontend-bundle-report - path: ${{ runner.temp }}/frontend-bundle-report - github-token: ${{ github.token }} - repository: ${{ github.repository }} - run-id: ${{ steps.find-report-run.outputs.run-id }} - - - name: Comment on pull request - if: (github.event_name == 'workflow_run' && steps.find-report-artifact.outputs.exists == 'true') || steps.find-report-run.outputs.run-id != '' - uses: actions/github-script@v9 - with: - github-token: ${{ secrets.FRONTEND_BUNDLE_REPORT_COMMENT_TOKEN || secrets.FRONTEND_JS_SIZE_COMMENT_TOKEN || secrets.FRONTEND_BUNDLE_VISUALIZER_COMMENT_TOKEN || github.token }} - script: | - const fs = require('node:fs'); - const path = require('node:path'); - - const jsSizeMarker = ''; - const visualizerMarker = ''; - const reportMarkers = [jsSizeMarker, visualizerMarker]; - const reportDir = path.join(process.env.RUNNER_TEMP, 'frontend-bundle-report'); - const jsSizeReportPath = path.join(reportDir, 'frontend-js-size-report.md'); - const prNumberPath = path.join(reportDir, 'pr-number.txt'); - const headShaPath = path.join(reportDir, 'head-sha.txt'); - const workflowRun = context.payload.workflow_run; - const pullRequest = context.payload.pull_request; - const eventHeadSha = workflowRun?.head_sha ?? pullRequest?.head?.sha ?? null; - const { owner, repo } = context.repo; - - if (!fs.existsSync(jsSizeReportPath)) { - core.setFailed('The frontend bundle report artifact does not contain frontend-js-size-report.md.'); - return; - } - - const artifactHeadSha = fs.existsSync(headShaPath) - ? fs.readFileSync(headShaPath, 'utf8').trim() - : null; - if (eventHeadSha != null && artifactHeadSha != null && artifactHeadSha !== eventHeadSha) { - core.info(`The artifact head SHA (${artifactHeadSha}) differs from the event head SHA (${eventHeadSha}). Using artifact metadata for PR validation.`); - } - const reportHeadSha = artifactHeadSha ?? eventHeadSha; - - const artifactPrNumber = fs.existsSync(prNumberPath) - ? Number(fs.readFileSync(prNumberPath, 'utf8').trim()) - : null; - let issue_number = null; - if (pullRequest != null) { - issue_number = pullRequest.number; - if (Number.isInteger(artifactPrNumber) && artifactPrNumber !== issue_number) { - core.setFailed(`The artifact pull request number (${artifactPrNumber}) does not match the event pull request number (${issue_number}).`); - return; - } - } else if (workflowRun != null) { - const associatedPullRequests = new Map(); - for (const pullRequest of workflowRun.pull_requests ?? []) { - if (Number.isInteger(pullRequest.number)) { - associatedPullRequests.set(pullRequest.number, pullRequest); - } - } - - if (reportHeadSha != null) { - const pullRequestsForCommit = await github.paginate(github.rest.repos.listPullRequestsAssociatedWithCommit, { - owner, - repo, - commit_sha: reportHeadSha, - per_page: 100, - }); - for (const pullRequest of pullRequestsForCommit) { - associatedPullRequests.set(pullRequest.number, pullRequest); - } - } - - if (Number.isInteger(artifactPrNumber) && associatedPullRequests.has(artifactPrNumber)) { - issue_number = artifactPrNumber; - } else if (Number.isInteger(artifactPrNumber) && associatedPullRequests.size === 0) { - issue_number = artifactPrNumber; - } else if (!Number.isInteger(artifactPrNumber) && associatedPullRequests.size === 1) { - issue_number = [...associatedPullRequests.keys()][0]; - } else if (Number.isInteger(artifactPrNumber)) { - core.setFailed(`The artifact pull request number (${artifactPrNumber}) is not associated with ${reportHeadSha}.`); - return; - } else { - core.setFailed(`Could not determine the pull request associated with ${reportHeadSha}.`); - return; - } - } else { - core.setFailed('Could not determine the pull request event for this report.'); - return; - } - - const currentPullRequest = await github.rest.pulls.get({ - owner, - repo, - pull_number: issue_number, - }); - const currentHeadSha = currentPullRequest.data.head?.sha; - if (reportHeadSha != null && currentHeadSha != null && reportHeadSha !== currentHeadSha) { - core.info(`The report head SHA (${reportHeadSha}) is not the current pull request head SHA (${currentHeadSha}). Skipping stale frontend bundle report.`); - return; - } - - const jsSizeReport = fs.readFileSync(jsSizeReportPath, 'utf8').trim(); - if (!jsSizeReport.includes(jsSizeMarker)) { - core.setFailed('The frontend JS size report is missing the expected marker.'); - return; - } - let body = `${jsSizeReport}\n`; - - const maxCommentLength = 65_000; - if (body.length > maxCommentLength) { - const reportLocation = workflowRun?.html_url != null - ? `[workflow run](${workflowRun.html_url})` - : 'workflow artifact'; - const footer = [ - '', - '', - `_Report truncated because it exceeded ${maxCommentLength.toLocaleString('en-US')} characters. See the ${reportLocation} for the full report._`, - ].join('\n'); - body = `${body.slice(0, maxCommentLength - footer.length)}${footer}`; - } - - const comments = await github.paginate(github.rest.issues.listComments, { - owner, - repo, - issue_number, - per_page: 100, - }); - const previousReports = comments.filter((comment) => - comment.user?.type === 'Bot' && reportMarkers.some((reportMarker) => comment.body?.includes(reportMarker))); - - if (previousReports.length > 0) { - const [previous, ...duplicates] = previousReports; - await github.rest.issues.updateComment({ - owner, - repo, - comment_id: previous.id, - body, - }); - for (const duplicate of duplicates) { - await github.rest.issues.deleteComment({ - owner, - repo, - comment_id: duplicate.id, - }); - } - } else { - await github.rest.issues.createComment({ - owner, - repo, - issue_number, - body, - }); - } diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 63f8684c33..0e77f896a2 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -18,9 +18,6 @@ on: - packages/misskey-reversi/** - packages/shared/eslint.config.js - scripts/check-dts*.mjs - - .github/scripts/frontend-js-size*.mts - - .github/scripts/memory-stability-util*.mts - - .github/scripts/utility.mts - .github/workflows/lint.yml - package.json pull_request: @@ -37,9 +34,6 @@ on: - packages/misskey-reversi/** - packages/shared/eslint.config.js - scripts/check-dts*.mjs - - .github/scripts/frontend-js-size*.mts - - .github/scripts/memory-stability-util*.mts - - .github/scripts/utility.mts - .github/workflows/lint.yml - package.json jobs: @@ -129,12 +123,12 @@ jobs: runs-on: ubuntu-latest continue-on-error: true steps: - - uses: actions/checkout@v6.0.2 + - uses: actions/checkout@v6.0.3 with: fetch-depth: 0 submodules: true - name: Setup pnpm - uses: pnpm/action-setup@v6.0.3 + uses: pnpm/action-setup@v6.0.9 - uses: actions/setup-node@v6.4.0 with: node-version-file: '.node-version' @@ -142,13 +136,3 @@ jobs: - run: pnpm i --frozen-lockfile - run: node --test scripts/check-dts.test.mjs - run: pnpm check-dts - - frontend-bundle-report-test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6.0.3 - - uses: actions/setup-node@v6.4.0 - with: - node-version-file: '.node-version' - - run: node --test .github/scripts/frontend-js-size.test.mts - - run: node --test .github/scripts/memory-stability-util.test.mts diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index a2effe339a..634ee1f02c 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -2884,6 +2884,8 @@ type MiauthGenTokenRequest = operations['miauth___gen-token']['requestBody']['co // @public (undocumented) type MiauthGenTokenResponse = operations['miauth___gen-token']['responses']['200']['content']['application/json']; +// Warning: (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts +// // @public (undocumented) type ModerationLog = { id: ID; @@ -2891,165 +2893,11 @@ type ModerationLog = { userId: User['id']; user: UserDetailedNotMe; } & ({ - type: 'updateServerSettings'; - info: ModerationLogPayloads['updateServerSettings']; -} | { - type: 'suspend'; - info: ModerationLogPayloads['suspend']; -} | { - type: 'unsuspend'; - info: ModerationLogPayloads['unsuspend']; -} | { - type: 'updateUserNote'; - info: ModerationLogPayloads['updateUserNote']; -} | { - type: 'addCustomEmoji'; - info: ModerationLogPayloads['addCustomEmoji']; -} | { - type: 'updateCustomEmoji'; - info: ModerationLogPayloads['updateCustomEmoji']; -} | { - type: 'deleteCustomEmoji'; - info: ModerationLogPayloads['deleteCustomEmoji']; -} | { - type: 'assignRole'; - info: ModerationLogPayloads['assignRole']; -} | { - type: 'unassignRole'; - info: ModerationLogPayloads['unassignRole']; -} | { - type: 'createRole'; - info: ModerationLogPayloads['createRole']; -} | { - type: 'updateRole'; - info: ModerationLogPayloads['updateRole']; -} | { - type: 'deleteRole'; - info: ModerationLogPayloads['deleteRole']; -} | { - type: 'clearQueue'; - info: ModerationLogPayloads['clearQueue']; -} | { - type: 'promoteQueue'; - info: ModerationLogPayloads['promoteQueue']; -} | { - type: 'deleteDriveFile'; - info: ModerationLogPayloads['deleteDriveFile']; -} | { - type: 'deleteNote'; - info: ModerationLogPayloads['deleteNote']; -} | { - type: 'createGlobalAnnouncement'; - info: ModerationLogPayloads['createGlobalAnnouncement']; -} | { - type: 'createUserAnnouncement'; - info: ModerationLogPayloads['createUserAnnouncement']; -} | { - type: 'updateGlobalAnnouncement'; - info: ModerationLogPayloads['updateGlobalAnnouncement']; -} | { - type: 'updateUserAnnouncement'; - info: ModerationLogPayloads['updateUserAnnouncement']; -} | { - type: 'deleteGlobalAnnouncement'; - info: ModerationLogPayloads['deleteGlobalAnnouncement']; -} | { - type: 'deleteUserAnnouncement'; - info: ModerationLogPayloads['deleteUserAnnouncement']; -} | { - type: 'resetPassword'; - info: ModerationLogPayloads['resetPassword']; -} | { - type: 'suspendRemoteInstance'; - info: ModerationLogPayloads['suspendRemoteInstance']; -} | { - type: 'unsuspendRemoteInstance'; - info: ModerationLogPayloads['unsuspendRemoteInstance']; -} | { - type: 'updateRemoteInstanceNote'; - info: ModerationLogPayloads['updateRemoteInstanceNote']; -} | { - type: 'markSensitiveDriveFile'; - info: ModerationLogPayloads['markSensitiveDriveFile']; -} | { - type: 'unmarkSensitiveDriveFile'; - info: ModerationLogPayloads['unmarkSensitiveDriveFile']; -} | { - type: 'createInvitation'; - info: ModerationLogPayloads['createInvitation']; -} | { - type: 'createAd'; - info: ModerationLogPayloads['createAd']; -} | { - type: 'updateAd'; - info: ModerationLogPayloads['updateAd']; -} | { - type: 'deleteAd'; - info: ModerationLogPayloads['deleteAd']; -} | { - type: 'createAvatarDecoration'; - info: ModerationLogPayloads['createAvatarDecoration']; -} | { - type: 'updateAvatarDecoration'; - info: ModerationLogPayloads['updateAvatarDecoration']; -} | { - type: 'deleteAvatarDecoration'; - info: ModerationLogPayloads['deleteAvatarDecoration']; -} | { - type: 'resolveAbuseReport'; - info: ModerationLogPayloads['resolveAbuseReport']; -} | { - type: 'forwardAbuseReport'; - info: ModerationLogPayloads['forwardAbuseReport']; -} | { - type: 'updateAbuseReportNote'; - info: ModerationLogPayloads['updateAbuseReportNote']; -} | { - type: 'unsetMfa'; - info: ModerationLogPayloads['unsetMfa']; -} | { - type: 'unsetUserAvatar'; - info: ModerationLogPayloads['unsetUserAvatar']; -} | { - type: 'unsetUserBanner'; - info: ModerationLogPayloads['unsetUserBanner']; -} | { - type: 'createSystemWebhook'; - info: ModerationLogPayloads['createSystemWebhook']; -} | { - type: 'updateSystemWebhook'; - info: ModerationLogPayloads['updateSystemWebhook']; -} | { - type: 'deleteSystemWebhook'; - info: ModerationLogPayloads['deleteSystemWebhook']; -} | { - type: 'createAbuseReportNotificationRecipient'; - info: ModerationLogPayloads['createAbuseReportNotificationRecipient']; -} | { - type: 'updateAbuseReportNotificationRecipient'; - info: ModerationLogPayloads['updateAbuseReportNotificationRecipient']; -} | { - type: 'deleteAbuseReportNotificationRecipient'; - info: ModerationLogPayloads['deleteAbuseReportNotificationRecipient']; -} | { - type: 'deleteAccount'; - info: ModerationLogPayloads['deleteAccount']; -} | { - type: 'deletePage'; - info: ModerationLogPayloads['deletePage']; -} | { - type: 'deleteFlash'; - info: ModerationLogPayloads['deleteFlash']; -} | { - type: 'deleteGalleryPost'; - info: ModerationLogPayloads['deleteGalleryPost']; -} | { - type: 'deleteChatRoom'; - info: ModerationLogPayloads['deleteChatRoom']; -} | { - type: 'updateProxyAccountDescription'; - info: ModerationLogPayloads['updateProxyAccountDescription']; -}); + [K in keyof ModerationLogPayloads]: { + type: K; + info: ModerationLogPayloads[K]; + }; +}[keyof ModerationLogPayloads]); // @public (undocumented) export const moderationLogTypes: readonly ["updateServerSettings", "suspend", "unsuspend", "updateUserNote", "addCustomEmoji", "updateCustomEmoji", "deleteCustomEmoji", "assignRole", "unassignRole", "createRole", "updateRole", "deleteRole", "clearQueue", "promoteQueue", "deleteDriveFile", "deleteNote", "createGlobalAnnouncement", "createUserAnnouncement", "updateGlobalAnnouncement", "updateUserAnnouncement", "deleteGlobalAnnouncement", "deleteUserAnnouncement", "resetPassword", "suspendRemoteInstance", "unsuspendRemoteInstance", "updateRemoteInstanceNote", "markSensitiveDriveFile", "unmarkSensitiveDriveFile", "resolveAbuseReport", "forwardAbuseReport", "updateAbuseReportNote", "createInvitation", "createAd", "updateAd", "deleteAd", "createAvatarDecoration", "updateAvatarDecoration", "deleteAvatarDecoration", "unsetMfa", "unsetUserAvatar", "unsetUserBanner", "createSystemWebhook", "updateSystemWebhook", "deleteSystemWebhook", "createAbuseReportNotificationRecipient", "updateAbuseReportNotificationRecipient", "deleteAbuseReportNotificationRecipient", "deleteAccount", "deletePage", "deleteFlash", "deleteGalleryPost", "deleteChatRoom", "updateProxyAccountDescription"]; @@ -3922,7 +3770,6 @@ type VerifyEmailRequest = operations['verify-email']['requestBody']['content'][' // Warnings were encountered during analysis: // -// src/entities.ts:60:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts // src/streaming.ts:57:3 - (ae-forgotten-export) The symbol "ReconnectingWebSocket" needs to be exported by the entry point index.d.ts // src/streaming.types.ts:226:4 - (ae-forgotten-export) The symbol "ReversiUpdateKey" needs to be exported by the entry point index.d.ts // src/streaming.types.ts:241:4 - (ae-forgotten-export) The symbol "ReversiUpdateSettings" needs to be exported by the entry point index.d.ts diff --git a/packages/misskey-js/src/entities.ts b/packages/misskey-js/src/entities.ts index 77cec5fe43..4fabe4f096 100644 --- a/packages/misskey-js/src/entities.ts +++ b/packages/misskey-js/src/entities.ts @@ -56,165 +56,11 @@ export type ModerationLog = { userId: User['id']; user: UserDetailedNotMe; } & ({ - type: 'updateServerSettings'; - info: ModerationLogPayloads['updateServerSettings']; -} | { - type: 'suspend'; - info: ModerationLogPayloads['suspend']; -} | { - type: 'unsuspend'; - info: ModerationLogPayloads['unsuspend']; -} | { - type: 'updateUserNote'; - info: ModerationLogPayloads['updateUserNote']; -} | { - type: 'addCustomEmoji'; - info: ModerationLogPayloads['addCustomEmoji']; -} | { - type: 'updateCustomEmoji'; - info: ModerationLogPayloads['updateCustomEmoji']; -} | { - type: 'deleteCustomEmoji'; - info: ModerationLogPayloads['deleteCustomEmoji']; -} | { - type: 'assignRole'; - info: ModerationLogPayloads['assignRole']; -} | { - type: 'unassignRole'; - info: ModerationLogPayloads['unassignRole']; -} | { - type: 'createRole'; - info: ModerationLogPayloads['createRole']; -} | { - type: 'updateRole'; - info: ModerationLogPayloads['updateRole']; -} | { - type: 'deleteRole'; - info: ModerationLogPayloads['deleteRole']; -} | { - type: 'clearQueue'; - info: ModerationLogPayloads['clearQueue']; -} | { - type: 'promoteQueue'; - info: ModerationLogPayloads['promoteQueue']; -} | { - type: 'deleteDriveFile'; - info: ModerationLogPayloads['deleteDriveFile']; -} | { - type: 'deleteNote'; - info: ModerationLogPayloads['deleteNote']; -} | { - type: 'createGlobalAnnouncement'; - info: ModerationLogPayloads['createGlobalAnnouncement']; -} | { - type: 'createUserAnnouncement'; - info: ModerationLogPayloads['createUserAnnouncement']; -} | { - type: 'updateGlobalAnnouncement'; - info: ModerationLogPayloads['updateGlobalAnnouncement']; -} | { - type: 'updateUserAnnouncement'; - info: ModerationLogPayloads['updateUserAnnouncement']; -} | { - type: 'deleteGlobalAnnouncement'; - info: ModerationLogPayloads['deleteGlobalAnnouncement']; -} | { - type: 'deleteUserAnnouncement'; - info: ModerationLogPayloads['deleteUserAnnouncement']; -} | { - type: 'resetPassword'; - info: ModerationLogPayloads['resetPassword']; -} | { - type: 'suspendRemoteInstance'; - info: ModerationLogPayloads['suspendRemoteInstance']; -} | { - type: 'unsuspendRemoteInstance'; - info: ModerationLogPayloads['unsuspendRemoteInstance']; -} | { - type: 'updateRemoteInstanceNote'; - info: ModerationLogPayloads['updateRemoteInstanceNote']; -} | { - type: 'markSensitiveDriveFile'; - info: ModerationLogPayloads['markSensitiveDriveFile']; -} | { - type: 'unmarkSensitiveDriveFile'; - info: ModerationLogPayloads['unmarkSensitiveDriveFile']; -} | { - type: 'createInvitation'; - info: ModerationLogPayloads['createInvitation']; -} | { - type: 'createAd'; - info: ModerationLogPayloads['createAd']; -} | { - type: 'updateAd'; - info: ModerationLogPayloads['updateAd']; -} | { - type: 'deleteAd'; - info: ModerationLogPayloads['deleteAd']; -} | { - type: 'createAvatarDecoration'; - info: ModerationLogPayloads['createAvatarDecoration']; -} | { - type: 'updateAvatarDecoration'; - info: ModerationLogPayloads['updateAvatarDecoration']; -} | { - type: 'deleteAvatarDecoration'; - info: ModerationLogPayloads['deleteAvatarDecoration']; -} | { - type: 'resolveAbuseReport'; - info: ModerationLogPayloads['resolveAbuseReport']; -} | { - type: 'forwardAbuseReport'; - info: ModerationLogPayloads['forwardAbuseReport']; -} | { - type: 'updateAbuseReportNote'; - info: ModerationLogPayloads['updateAbuseReportNote']; -} | { - type: 'unsetMfa'; - info: ModerationLogPayloads['unsetMfa']; -} | { - type: 'unsetUserAvatar'; - info: ModerationLogPayloads['unsetUserAvatar']; -} | { - type: 'unsetUserBanner'; - info: ModerationLogPayloads['unsetUserBanner']; -} | { - type: 'createSystemWebhook'; - info: ModerationLogPayloads['createSystemWebhook']; -} | { - type: 'updateSystemWebhook'; - info: ModerationLogPayloads['updateSystemWebhook']; -} | { - type: 'deleteSystemWebhook'; - info: ModerationLogPayloads['deleteSystemWebhook']; -} | { - type: 'createAbuseReportNotificationRecipient'; - info: ModerationLogPayloads['createAbuseReportNotificationRecipient']; -} | { - type: 'updateAbuseReportNotificationRecipient'; - info: ModerationLogPayloads['updateAbuseReportNotificationRecipient']; -} | { - type: 'deleteAbuseReportNotificationRecipient'; - info: ModerationLogPayloads['deleteAbuseReportNotificationRecipient']; -} | { - type: 'deleteAccount'; - info: ModerationLogPayloads['deleteAccount']; -} | { - type: 'deletePage'; - info: ModerationLogPayloads['deletePage']; -} | { - type: 'deleteFlash'; - info: ModerationLogPayloads['deleteFlash']; -} | { - type: 'deleteGalleryPost'; - info: ModerationLogPayloads['deleteGalleryPost']; -} | { - type: 'deleteChatRoom'; - info: ModerationLogPayloads['deleteChatRoom']; -} | { - type: 'updateProxyAccountDescription'; - info: ModerationLogPayloads['updateProxyAccountDescription']; -}); + [K in keyof ModerationLogPayloads]: { + type: K; + info: ModerationLogPayloads[K]; + }; +}[keyof ModerationLogPayloads]); export type ServerStats = { cpu: number;