1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-07-28 08:04:37 +02:00

Merge remote-tracking branch 'msky/develop' into feature/role-hidden-setting

This commit is contained in:
kakkokari-gtyih
2026-07-17 10:03:09 +09:00
22 changed files with 409 additions and 2043 deletions

View File

@@ -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,

View File

@@ -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)}<br>${util.formatDeltaPercent(percent, 0.1).replaceAll('\\%', '\\\\%')}`;
lines.push(`| **${formatMemoryMetricName(metric)}** | ${formatMemoryMb(baseValue)} <br> ± ${formatMemoryMb(baseSpread)} | ${formatMemoryMb(headValue)} <br> ± ${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`);

View File

@@ -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;
}

View File

@@ -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;
};

View File

@@ -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`);

View File

@@ -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)}<br>${util.formatDeltaPercent(percent, 0.1).replaceAll('\\%', '\\\\%')}`;
lines.push(`| **${formatMetricName(metric)}** | ${formatMemoryMb(baseValue)} <br> ± ${formatMemoryMb(baseSpread)} | ${formatMemoryMb(headValue)} <br> ± ${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',
'',
'<details><summary>Click to show</summary>',
'',
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('</details>');
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`);

View File

@@ -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<string, number | undefined>,
};
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<HeadlessChromeController> {
@@ -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<BrowserMeasurement['performance']> {
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]));

View File

@@ -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<typeof util.startServer> | 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();

View File

@@ -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 <base-browser.json> <head-browser.json> <output.html>');
}
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();
}

View File

@@ -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.`,
//'',
'<i>Only metrics showing significant changes are displayed.</i>',
'',
detailedHtmlUrl == null || detailedHtmlUrl === '' ? null : `[View details](${detailedHtmlUrl})`,
detailedHtmlUrl == null || detailedHtmlUrl === '' ? null : '',
'<details>',
@@ -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})`,
'</details>',
'',
];
@@ -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 <base-browser.json> <head-browser.json> <output.md>');
}
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();
}

View File

@@ -7,9 +7,9 @@ import { promises as fs } from 'node:fs';
import path from 'node:path';
import * as util from './utility.mts';
const marker = '<!-- misskey-frontend-js-size -->';
const marker = '<!-- misskey-frontend-bundle-diagnostics -->';
const locale = process.env.FRONTEND_JS_SIZE_LOCALE ?? 'ja-JP';
const locale = 'ja-JP';
//function sharePercent(value, total) {
// if (total === 0) return '0%';

View File

@@ -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<string, {
file?: string;
src?: string;
name?: string;
isEntry?: boolean;
imports?: string[];
}>;
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<string, number>) {
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<string, number> }, after: { manifest: Manifest; sizes: Record<string, number> }, 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<string>((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, /<summary>`(?:dist|esm)`<\/summary>/);
assert.match(report, /<summary>`src\/_boot_\.ts`<\/summary>/);
assert.match(report, /<summary>`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, /<summary>Chunk size diff \(2 updated, 1 added, 1 removed\)<\/summary>/);
assert.match(report, /<summary>Startup chunk size \(2 updated, 1 added, 1 removed\)<\/summary>/);
assert.equal(report.match(/<summary>`src\/_boot_\.ts`<\/summary>/g)?.length, 2);
assert.doesNotMatch(report, /<summary>`(?:vue|i18n|src\/added-small\.ts|src\/removed-small\.ts)`<\/summary>/);
assert.match(report, /\| \(total\) \| 225 B \| 276 B \|[^\n]*\n\| \| \| \| \| \|\n\| <details><summary>`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\| <details><summary>`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, /<summary>`src\/added\.ts`<\/summary> `ja-JP\/added-after\.js` <\/details> \| 0 B \| 13 B \|/);
assert.match(report, /<summary>`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, /<summary>Chunk size diff \(0 updated, 0 added, 0 removed\)<\/summary>/);
assert.match(report, /<summary>Startup chunk size \(0 updated, 0 added, 0 removed\)<\/summary>/);
assert.equal(report.match(/\| \(other generated chunks\) \| 30 B \| 70 B \|/g)?.length, 2);
});

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 = '<!-- misskey-frontend-js-size -->';
const visualizerMarker = '<!-- misskey-frontend-bundle-visualizer -->';
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,
});
}

View File

@@ -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

View File

@@ -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

View File

@@ -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;