From 721b1b06a021858787cb4ead122f6a7e21e34fe6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=8B=E3=81=A3=E3=81=93=E3=81=8B=E3=82=8A?= <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:57:39 +0900 Subject: [PATCH 001/168] =?UTF-8?q?fix(backend/test):=20backend=20e2e=20te?= =?UTF-8?q?st=20=E3=81=AE=20beforeAll=20=E3=81=A7dispose=E5=89=8D=E3=81=AB?= =?UTF-8?q?schema=20drop=E3=81=8C=E8=B5=B0=E3=82=8B=E3=81=AE=E3=82=92?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20(#17653)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix(backend): setup.e2e.ts の beforeAll でdispose前にschema dropが走る順序バグを修正 (Phase 6) (tiramiss-community/endolphin#52) Co-authored-by: おさむのひと <46447427+samunohito@users.noreply.github.com> --- packages/backend/test/setup.e2e.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/backend/test/setup.e2e.ts b/packages/backend/test/setup.e2e.ts index 3141dc15ad..59bc6fcc62 100644 --- a/packages/backend/test/setup.e2e.ts +++ b/packages/backend/test/setup.e2e.ts @@ -7,6 +7,9 @@ import { beforeAll } from 'vitest'; import { initTestDb, sendEnvResetRequest } from './utils.js'; beforeAll(async () => { - await initTestDb(false); + // 前ファイルのNestJSアプリをdispose(env-reset)した後にスキーマをdrop & 再作成する。 + // 逆順だと、前ファイルの最後のテストが投げっぱなしにした非同期処理(cacheServiceのrefresh等)が + // dispose前のdrop中に発火し、Unhandled Rejection (relation does not exist) でクラッシュしうる。 await sendEnvResetRequest(); + await initTestDb(false); }); From c29a3d902b8789aa4963b2c2c3fc6a4c64c14e81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=8B=E3=81=A3=E3=81=93=E3=81=8B=E3=82=8A?= <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Fri, 3 Jul 2026 19:14:48 +0900 Subject: [PATCH 002/168] =?UTF-8?q?refactor(frontend):=20MkNote/MkNoteDeta?= =?UTF-8?q?iled=E3=81=AE=E3=83=AD=E3=82=B8=E3=83=83=E3=82=AF=E3=82=92?= =?UTF-8?q?=E7=B5=B1=E5=90=88=20(#17636)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(frontend): MkNote/MkNoteDetailedのロジックを統合 * refactor * fix * fix: 差分を解消 * fix lint * fix types --------- Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com> --- packages/frontend/src/components/MkNote.vue | 529 ++++-------------- .../src/components/MkNoteDetailed.vue | 458 ++++----------- packages/frontend/src/composables/use-note.ts | 473 ++++++++++++++++ packages/frontend/src/pages/note.vue | 6 + packages/frontend/src/types/misc.ts | 5 +- .../frontend/src/utility/get-note-menu.ts | 6 +- 6 files changed, 682 insertions(+), 795 deletions(-) create mode 100644 packages/frontend/src/composables/use-note.ts diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 1cb562fb62..e55a496bfe 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -144,7 +144,7 @@ SPDX-License-Identifier: AGPL-3.0-only -
@@ -100,6 +100,7 @@ import { hms } from '@/filters/hms.js'; import MkMediaRange from '@/components/MkMediaRange.vue'; import { $i, iAmModerator } from '@/i.js'; import { prefer } from '@/preferences.js'; +import { getFileMenu } from '@/utility/get-file-menu.js'; import { canRevealFile, shouldHideFileByDefault } from '@/utility/sensitive-file.js'; const props = defineProps<{ @@ -208,56 +209,11 @@ function showMenu(ev: MouseEvent) { { type: 'divider', }, - { - text: i18n.ts.hide, - icon: 'ti ti-eye-off', - action: () => { - hide.value = true; - }, - }, ]; - if (iAmModerator) { - menu.push({ - text: props.audio.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive, - icon: props.audio.isSensitive ? 'ti ti-eye' : 'ti ti-eye-exclamation', - danger: true, - action: () => toggleSensitive(props.audio), - }); - } - - const details: MenuItem[] = []; - if ($i?.id === props.audio.userId) { - details.push({ - type: 'link', - text: i18n.ts._fileViewer.title, - icon: 'ti ti-info-circle', - to: `/my/drive/file/${props.audio.id}`, - }); - } - - if (iAmModerator) { - details.push({ - type: 'link', - text: i18n.ts.moderation, - icon: 'ti ti-photo-exclamation', - to: `/admin/file/${props.audio.id}`, - }); - } - - if (details.length > 0) { - menu.push({ type: 'divider' }, ...details); - } - - if (prefer.s.devMode) { - menu.push({ type: 'divider' }, { - icon: 'ti ti-hash', - text: i18n.ts.copyFileId, - action: () => { - copyToClipboard(props.audio.id); - }, - }); - } + menu.push(...getFileMenu(props.audio, (newState) => { + hide.value = newState; + })); menuShowing.value = true; os.popupMenu(menu, ev.currentTarget ?? ev.target, { @@ -268,20 +224,6 @@ function showMenu(ev: MouseEvent) { }); } -async function toggleSensitive(file: Misskey.entities.DriveFile) { - const { canceled } = await os.confirm({ - type: 'warning', - text: file.isSensitive ? i18n.ts.unmarkAsSensitiveConfirm : i18n.ts.markAsSensitiveConfirm, - }); - - if (canceled) return; - - os.apiWithDialog('drive/files/update', { - fileId: file.id, - isSensitive: !file.isSensitive, - }); -} - // MediaControl: Common State const oncePlayed = ref(false); const isReady = ref(false); diff --git a/packages/frontend/src/components/MkMediaImage.vue b/packages/frontend/src/components/MkMediaImage.vue index 4236bd943a..01f449753b 100644 --- a/packages/frontend/src/components/MkMediaImage.vue +++ b/packages/frontend/src/components/MkMediaImage.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/utility/paginator.ts b/packages/frontend/src/utility/paginator.ts index 45054acfd0..471b7c0d91 100644 --- a/packages/frontend/src/utility/paginator.ts +++ b/packages/frontend/src/utility/paginator.ts @@ -17,6 +17,8 @@ export type MisskeyEntity = { id: string; createdAt: string; _shouldInsertAd_?: boolean; + _shouldAnimateIn_?: boolean; + _shouldAnimateOut_?: boolean; }; type AbsEndpointType = { @@ -107,6 +109,8 @@ export class Paginator< private canFetchDetection: 'safe' | 'limit' | null = null; private aheadQueue: T[] = []; private useShallowRef: SRef; + private itemRemovalDelay: number | false; + private removalTimers = new Map(); // 配列内の要素をどのような順序で並べるか // newest: 新しいものが先頭 (default) @@ -138,6 +142,9 @@ export class Paginator< useShallowRef?: SRef; + // アイテム削除時にアニメーションを待つ時間 (ms) + itemRemovalDelay?: number | false; + canSearch?: boolean; searchParamName?: keyof E['req']; }) { @@ -160,6 +167,7 @@ export class Paginator< this.noPaging = props.noPaging ?? false; this.offsetMode = props.offsetMode ?? false; this.canSearch = props.canSearch ?? false; + this.itemRemovalDelay = props.itemRemovalDelay ?? false; this.searchParamName = props.searchParamName ?? 'search'; this.getNewestId = this.getNewestId.bind(this); @@ -191,6 +199,7 @@ export class Paginator< } public async init(): Promise { + this.clearRemovalTimers(); this.items.value = []; this.aheadQueue = []; this.queuedAheadItemsCount.value = 0; @@ -384,6 +393,8 @@ export class Paginator< public prepend(item: T): void { if (this.items.value.some(x => x.id === item.id)) return; + item._shouldAnimateIn_ = true; + item._shouldAnimateOut_ = false; this.items.value.unshift(item); this.trim(false); if (this.useShallowRef) triggerRef(this.items); @@ -399,6 +410,9 @@ export class Paginator< public releaseQueue(): void { if (this.aheadQueue.length === 0) return; // これやらないと余計なre-renderが走る + for (const item of this.aheadQueue) { + item._shouldAnimateIn_ = true; + } this.unshiftItems(this.aheadQueue); this.aheadQueue = []; this.queuedAheadItemsCount.value = 0; @@ -407,13 +421,43 @@ export class Paginator< public removeItem(id: string): void { // TODO: queueからも消す + if (this.itemRemovalDelay === false) { + const index = this.items.value.findIndex(x => x.id === id); + if (index !== -1) { + this.items.value.splice(index, 1); + if (this.useShallowRef) triggerRef(this.items); + } + return; + } + const index = this.items.value.findIndex(x => x.id === id); if (index !== -1) { - this.items.value.splice(index, 1); if (this.useShallowRef) triggerRef(this.items); + + const item = this.items.value[index]!; + item._shouldAnimateOut_ = true; + if (this.useShallowRef) triggerRef(this.items); + + if (this.removalTimers.has(id)) return; + + this.removalTimers.set(id, window.setTimeout(() => { + this.removalTimers.delete(id); + const currentIndex = this.items.value.findIndex(x => x.id === id); + if (currentIndex !== -1) { + this.items.value.splice(currentIndex, 1); + if (this.useShallowRef) triggerRef(this.items); + } + }, this.itemRemovalDelay + 20)); // アニメーション終了からやや余裕をもたせる } } + private clearRemovalTimers(): void { + for (const timer of this.removalTimers.values()) { + window.clearTimeout(timer); + } + this.removalTimers.clear(); + } + public updateItem(id: string, updater: (item: T) => T): void { // TODO: queueのも更新 From 667919581e604140f7caed9be9800ecf46a5c2e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=8A=E3=81=95=E3=82=80=E3=81=AE=E3=81=B2=E3=81=A8?= <46447427+samunohito@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:29:39 +0900 Subject: [PATCH 046/168] =?UTF-8?q?enhance:=20=E6=96=B0=E3=81=97=E3=81=84L?= =?UTF-8?q?ogger=E3=81=AE=E3=82=B3=E3=82=A2=E5=AE=9F=E8=A3=85=20(#17714)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * enhance: impl logger core * fix order --- packages/backend/src/logger.ts | 105 +++++-------- packages/backend/src/logging/LogBackend.ts | 21 +++ packages/backend/src/logging/LogManager.ts | 94 +++++++++++ .../src/logging/PrettyConsoleBackend.ts | 82 ++++++++++ .../backend/src/logging/logging-runtime.ts | 13 ++ packages/backend/src/logging/types.ts | 50 ++++++ .../backend/test/unit/logging/LogManager.ts | 125 +++++++++++++++ packages/backend/test/unit/logging/Logger.ts | 103 ++++++++++++ .../test/unit/logging/PrettyConsoleBackend.ts | 147 ++++++++++++++++++ 9 files changed, 678 insertions(+), 62 deletions(-) create mode 100644 packages/backend/src/logging/LogBackend.ts create mode 100644 packages/backend/src/logging/LogManager.ts create mode 100644 packages/backend/src/logging/PrettyConsoleBackend.ts create mode 100644 packages/backend/src/logging/logging-runtime.ts create mode 100644 packages/backend/src/logging/types.ts create mode 100644 packages/backend/test/unit/logging/LogManager.ts create mode 100644 packages/backend/test/unit/logging/Logger.ts create mode 100644 packages/backend/test/unit/logging/PrettyConsoleBackend.ts diff --git a/packages/backend/src/logger.ts b/packages/backend/src/logger.ts index ce76f8d05e..c32b462cc0 100644 --- a/packages/backend/src/logger.ts +++ b/packages/backend/src/logger.ts @@ -3,80 +3,59 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import cluster from 'node:cluster'; -import chalk from 'chalk'; -import { default as convertColor } from 'color-convert'; -import { format as dateFormat } from 'date-fns'; import { bindThis } from '@/decorators.js'; -import { envOption } from './env.js'; +import { logManager } from './logging/logging-runtime.js'; +import type { LogLevel, LoggerContext } from './logging/types.js'; import type { Keyword } from 'color-convert'; -type Context = { - name: string; - color?: Keyword; -}; - -type Level = 'error' | 'success' | 'warning' | 'debug' | 'info'; - +/** + * ロガー名の階層と従来の公開APIを提供する薄い窓口です。 + * 出力条件の判断や整形はLogManagerとLogBackendへ委譲します。 + */ // eslint-disable-next-line import/no-default-export export default class Logger { - private context: Context; - private parentLogger: Logger | null = null; + private context: readonly LoggerContext[]; + /** 指定した名前を起点とするLoggerを作成します。 */ constructor(context: string, color?: Keyword) { - this.context = { + this.context = [{ name: context, - color: color, - }; + color, + }]; } + /** + * 現在のロガーを親として、下位の名前を持つLoggerを作成します。 + */ @bindThis public createSubLogger(context: string, color?: Keyword): Logger { const logger = new Logger(context, color); - logger.parentLogger = this; + logger.context = [...this.context, ...logger.context]; return logger; } + /** + * 従来APIの引数を共通形式へ変換し、LogManagerへ渡します。 + */ @bindThis - private log(level: Level, message: string, data?: Record | null, important = false, subContexts: Context[] = []): void { - if (envOption.quiet) return; - - if (this.parentLogger) { - this.parentLogger.log(level, message, data, important, [this.context].concat(subContexts)); - return; - } - - const time = dateFormat(new Date(), 'HH:mm:ss'); - const worker = cluster.isPrimary ? '*' : cluster.worker!.id; - const l = - level === 'error' ? important ? chalk.bgRed.white('ERR ') : chalk.red('ERR ') : - level === 'warning' ? chalk.yellow('WARN') : - level === 'success' ? important ? chalk.bgGreen.white('DONE') : chalk.green('DONE') : - level === 'debug' ? chalk.gray('VERB') : - level === 'info' ? chalk.blue('INFO') : - null; - const contexts = [this.context].concat(subContexts).map(d => d.color ? chalk.rgb(...convertColor.keyword.rgb(d.color))(d.name) : chalk.white(d.name)); - const m = - level === 'error' ? chalk.red(message) : - level === 'warning' ? chalk.yellow(message) : - level === 'success' ? chalk.green(message) : - level === 'debug' ? chalk.gray(message) : - level === 'info' ? message : - null; - - let log = `${l} ${worker}\t[${contexts.join(' ')}]\t${m}`; - if (envOption.withLogTime) log = chalk.gray(time) + ' ' + log; - - const args: unknown[] = [important ? chalk.bold(log) : log]; - if (data != null) { - args.push(data); - } - console.log(...args); + private log(level: LogLevel, message: string, data?: Record | null, important = false, legacyLevel?: 'success'): void { + logManager.write({ + level, + message, + context: this.context, + compatibility: { + legacyLevel, + important, + data, + }, + }); } + /** 処理を継続できない状況を記録します。 */ @bindThis - public error(x: string | Error, data?: Record | null, important = false): void { // 実行を継続できない状況で使う + public error(x: string | Error, data?: Record | null, important = false): void { if (x instanceof Error) { + // Error本体も第2引数へ残し、従来どおりスタックなどを確認できるようにします。 data = data ?? {}; data.e = x; this.log('error', x.toString(), data, important); @@ -87,25 +66,27 @@ export default class Logger { } } + /** 処理は継続できるものの、改善が必要な状況を記録します。 */ @bindThis - public warn(message: string, data?: Record | null, important = false): void { // 実行を継続できるが改善すべき状況で使う - this.log('warning', message, data, important); + public warn(message: string, data?: Record | null, important = false): void { + this.log('warn', message, data, important); } + /** 処理が成功したことを、従来のDONE表示で記録します。 */ @bindThis - public succ(message: string, data?: Record | null, important = false): void { // 何かに成功した状況で使う - this.log('success', message, data, important); + public succ(message: string, data?: Record | null, important = false): void { + this.log('info', message, data, important, 'success'); } + /** 開発者向けの調査情報を記録します。 */ @bindThis - public debug(message: string, data?: Record | null, important = false): void { // デバッグ用に使う(開発者に必要だが利用者に不要な情報) - if (process.env.NODE_ENV !== 'production' || envOption.verbose) { - this.log('debug', message, data, important); - } + public debug(message: string, data?: Record | null, important = false): void { + this.log('debug', message, data, important); } + /** 通常の動作状況を記録します。 */ @bindThis - public info(message: string, data?: Record | null, important = false): void { // それ以外 + public info(message: string, data?: Record | null, important = false): void { this.log('info', message, data, important); } } diff --git a/packages/backend/src/logging/LogBackend.ts b/packages/backend/src/logging/LogBackend.ts new file mode 100644 index 0000000000..36d2a60bb6 --- /dev/null +++ b/packages/backend/src/logging/LogBackend.ts @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { LogRecord } from './types.js'; + +/** + * 整形済みのログを実際の出力先へ渡すための共通窓口です。 + * Loggerを特定の出力形式へ依存させず、後から出力先を追加できるようにします。 + */ +export interface LogBackend { + /** ログを一件出力します。 */ + write(record: LogRecord): void; + + /** 保留中の出力がある場合に、すべて書き出します。 */ + flush?(): void | Promise; + + /** 出力先が持つ資源を解放します。 */ + close?(): void | Promise; +} diff --git a/packages/backend/src/logging/LogManager.ts b/packages/backend/src/logging/LogManager.ts new file mode 100644 index 0000000000..d31aac5daf --- /dev/null +++ b/packages/backend/src/logging/LogManager.ts @@ -0,0 +1,94 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import cluster from 'node:cluster'; +import process from 'node:process'; +import { envOption } from '@/env.js'; +import type { LogBackend } from './LogBackend.js'; +import type { LogRecordInput } from './types.js'; + +/** ログを出力したプロセスを識別するための情報です。 */ +export type LogProcessInfo = { + readonly processId: number; + readonly isPrimary: boolean; + readonly workerId: number | null; +}; + +/** + * 実行環境から取得する値をまとめた依存関係です。 + * テストでは固定値へ差し替え、時刻やプロセス状態に左右されないようにします。 + */ +export type LogManagerDependencies = { + readonly now: () => Date; + readonly getProcessInfo: () => LogProcessInfo; + readonly isQuiet: () => boolean; + readonly isVerbose: () => boolean; + readonly getNodeEnv: () => string | undefined; +}; + +const defaultDependencies: LogManagerDependencies = { + now: () => new Date(), + getProcessInfo: () => ({ + processId: process.pid, + isPrimary: cluster.isPrimary, + workerId: cluster.isPrimary ? null : (cluster.worker?.id ?? null), + }), + isQuiet: () => envOption.quiet, + isVerbose: () => envOption.verbose, + getNodeEnv: () => process.env.NODE_ENV, +}; + +/** + * ログの出力可否を判断し、すべての出力先で共通となる情報を付加します。 + * Loggerと出力先の間に置くことで、設定や共通情報の扱いを一か所へ集約します。 + */ +export class LogManager { + private backend: LogBackend; + private readonly dependencies: LogManagerDependencies; + + /** + * 出力先と実行環境から値を取得する処理を受け取ります。 + * 実行環境の取得処理は、必要な項目だけテスト用に差し替えられます。 + */ + constructor(backend: LogBackend, dependencies: Partial = {}) { + this.backend = backend; + this.dependencies = { + ...defaultDependencies, + ...dependencies, + }; + } + + /** + * 以後のログを書き込む出力先を切り替えます。 + * 作成済みのLoggerにも切り替えを反映するため、LogManager側で保持します。 + */ + public setBackend(backend: LogBackend): void { + this.backend = backend; + } + + /** + * 出力条件を確認し、共通情報を付加して出力先へ渡します。 + */ + public write(input: LogRecordInput): void { + // `quiet`は他の条件より優先し、ログに付随する情報の取得も行いません。 + if (this.dependencies.isQuiet()) return; + + // 本番環境のデバッグログは、明示的に`verbose`が指定された場合だけ出力します。 + if (input.level === 'debug' && this.dependencies.getNodeEnv() === 'production' && !this.dependencies.isVerbose()) return; + + const processInfo = this.dependencies.getProcessInfo(); + // 呼び出し側の配列を共有せず、親から末端までの順序を固定します。 + const context = [...input.context]; + this.backend.write({ + ...input, + context, + timestamp: this.dependencies.now().toISOString(), + loggerName: context.map(segment => segment.name).join('.'), + processId: processInfo.processId, + isPrimary: processInfo.isPrimary, + workerId: processInfo.workerId, + }); + } +} diff --git a/packages/backend/src/logging/PrettyConsoleBackend.ts b/packages/backend/src/logging/PrettyConsoleBackend.ts new file mode 100644 index 0000000000..6af60e5efd --- /dev/null +++ b/packages/backend/src/logging/PrettyConsoleBackend.ts @@ -0,0 +1,82 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import chalk from 'chalk'; +import { default as convertColor } from 'color-convert'; +import { format as dateFormat } from 'date-fns'; +import { envOption } from '@/env.js'; +import type { LogBackend } from './LogBackend.js'; +import type { LogRecord } from './types.js'; + +/** Pretty形式の出力処理が外部から受け取る依存関係です。 */ +export type PrettyConsoleBackendDependencies = { + readonly output: (...args: unknown[]) => void; + readonly withLogTime: () => boolean; +}; + +const defaultDependencies: PrettyConsoleBackendDependencies = { + output: (...args) => console.log(...args), + withLogTime: () => envOption.withLogTime, +}; + +/** + * 人が読みやすい従来形式へ整形し、コンソールへ出力します。 + * 色、ラベル、時刻などの見た目だけを担当し、出力可否はLogManagerへ任せます。 + */ +export class PrettyConsoleBackend implements LogBackend { + private readonly dependencies: PrettyConsoleBackendDependencies; + + /** + * 出力処理と時刻表示の判定処理を受け取ります。 + * 省略時は従来どおり、標準のコンソールと環境設定を使用します。 + */ + constructor(dependencies: Partial = {}) { + this.dependencies = { + ...defaultDependencies, + ...dependencies, + }; + } + + /** + * 共通のログを従来形式へ整形して一件出力します。 + */ + public write(record: LogRecord): void { + const legacyLevel = record.compatibility?.legacyLevel; + // `fatal`は重大なエラーとして扱い、従来の`important`と同じ強調表示にします。 + const important = record.level === 'fatal' || (record.compatibility?.important ?? false); + const presentationLevel = record.level === 'fatal' ? 'error' : (legacyLevel ?? record.level); + const label = + presentationLevel === 'error' ? important ? chalk.bgRed.white('ERR ') : chalk.red('ERR ') : + presentationLevel === 'warn' ? chalk.yellow('WARN') : + presentationLevel === 'success' ? important ? chalk.bgGreen.white('DONE') : chalk.green('DONE') : + presentationLevel === 'debug' ? chalk.gray('VERB') : + presentationLevel === 'info' ? chalk.blue('INFO') : + null; + const contexts = record.context.map(context => context.color + ? chalk.rgb(...convertColor.keyword.rgb(context.color))(context.name) + : chalk.white(context.name)); + const message = + presentationLevel === 'error' ? chalk.red(record.message) : + presentationLevel === 'warn' ? chalk.yellow(record.message) : + presentationLevel === 'success' ? chalk.green(record.message) : + presentationLevel === 'debug' ? chalk.gray(record.message) : + presentationLevel === 'info' ? record.message : + null; + // 主プロセスは従来どおり「*」、子プロセスはワーカー番号で識別します。 + const worker = record.isPrimary ? '*' : record.workerId; + + let log = `${label} ${worker}\t[${contexts.join(' ')}]\t${message}`; + if (this.dependencies.withLogTime()) { + log = chalk.gray(dateFormat(new Date(record.timestamp), 'HH:mm:ss')) + ' ' + log; + } + + // `data`は文字列へ埋め込まず、第2引数として渡す従来の挙動を維持します。 + const args: unknown[] = [important ? chalk.bold(log) : log]; + if (record.compatibility?.data != null) { + args.push(record.compatibility.data); + } + this.dependencies.output(...args); + } +} diff --git a/packages/backend/src/logging/logging-runtime.ts b/packages/backend/src/logging/logging-runtime.ts new file mode 100644 index 0000000000..79890ad496 --- /dev/null +++ b/packages/backend/src/logging/logging-runtime.ts @@ -0,0 +1,13 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { LogManager } from './LogManager.js'; +import { PrettyConsoleBackend } from './PrettyConsoleBackend.js'; + +/** + * プロセス内のすべてのLoggerが共有するLogManagerです。 + * Logger作成後も同じLogManagerを参照するため、出力先の切り替えを一括で反映できます。 + */ +export const logManager = new LogManager(new PrettyConsoleBackend()); diff --git a/packages/backend/src/logging/types.ts b/packages/backend/src/logging/types.ts new file mode 100644 index 0000000000..e65fd2195e --- /dev/null +++ b/packages/backend/src/logging/types.ts @@ -0,0 +1,50 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { Keyword } from 'color-convert'; + +/** ログの重要度を表します。 */ +export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal'; + +/** + * ロガー名を構成する一要素です。 + * 色はPretty形式での表示だけに使い、ログの意味には影響させません。 + */ +export type LoggerContext = { + readonly name: string; + readonly color?: Keyword; +}; + +/** + * 従来のコンソール表示を維持するための情報です。 + * 構造化ログの項目と混同しないよう、互換用の領域へ分離しています。 + */ +export type LogCompatibility = { + readonly legacyLevel?: 'success'; + readonly important?: boolean; + readonly data?: Record | null; +}; + +/** + * 呼び出し側からLogManagerへ渡す、時刻などを付加する前のログです。 + */ +export type LogRecordInput = { + readonly level: LogLevel; + readonly message: string; + readonly context: readonly LoggerContext[]; + readonly compatibility?: LogCompatibility; +}; + +/** + * 出力先へ渡すログです。 + * LogManagerが時刻やプロセス情報を付加するため、出力形式に依存せず利用できます。 + */ +export type LogRecord = LogRecordInput & { + readonly timestamp: string; + readonly loggerName: string; + readonly processId: number; + readonly isPrimary: boolean; + readonly workerId: number | null; +}; diff --git a/packages/backend/test/unit/logging/LogManager.ts b/packages/backend/test/unit/logging/LogManager.ts new file mode 100644 index 0000000000..629be70d5c --- /dev/null +++ b/packages/backend/test/unit/logging/LogManager.ts @@ -0,0 +1,125 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, test, vi } from 'vitest'; +import { LogManager } from '@/logging/LogManager.js'; +import type { LogBackend } from '@/logging/LogBackend.js'; +import type { LogRecordInput } from '@/logging/types.js'; + +/** テストで使う最小構成のログ入力を作成します。 */ +function createInput(level: LogRecordInput['level'] = 'info'): LogRecordInput { + return { + level, + message: 'message', + context: [ + { name: 'queue', color: 'red' }, + { name: 'deliver', color: 'blue' }, + ], + }; +} + +/** 実行環境を固定したLogManagerと、出力確認用の関数を作成します。 */ +function createManager(options: { + quiet?: boolean; + verbose?: boolean; + nodeEnv?: string; + isPrimary?: boolean; + workerId?: number | null; +} = {}) { + const write = vi.fn(); + const manager = new LogManager({ write }, { + now: () => new Date('2025-01-02T03:04:05.678Z'), + getProcessInfo: () => ({ + processId: 1234, + isPrimary: options.isPrimary ?? true, + workerId: options.workerId ?? null, + }), + isQuiet: () => options.quiet ?? false, + isVerbose: () => options.verbose ?? false, + getNodeEnv: () => options.nodeEnv ?? 'development', + }); + + return { manager, write }; +} + +describe('LogManager', () => { + test('adds logger and process metadata while preserving root-to-leaf context order', () => { + const { manager, write } = createManager(); + const input = createInput(); + + manager.write(input); + + expect(write).toHaveBeenCalledOnce(); + expect(write).toHaveBeenCalledWith({ + ...input, + context: [ + { name: 'queue', color: 'red' }, + { name: 'deliver', color: 'blue' }, + ], + timestamp: '2025-01-02T03:04:05.678Z', + loggerName: 'queue.deliver', + processId: 1234, + isPrimary: true, + workerId: null, + }); + expect(write.mock.calls[0][0].context).not.toBe(input.context); + }); + + test('records worker process metadata', () => { + const { manager, write } = createManager({ isPrimary: false, workerId: 7 }); + + manager.write(createInput()); + + expect(write.mock.calls[0][0]).toMatchObject({ + processId: 1234, + isPrimary: false, + workerId: 7, + }); + }); + + test('does not call the backend in quiet mode', () => { + const { manager, write } = createManager({ quiet: true, verbose: true }); + + manager.write(createInput('fatal')); + manager.write(createInput('debug')); + + expect(write).not.toHaveBeenCalled(); + }); + + test('writes debug logs outside production', () => { + const { manager, write } = createManager({ nodeEnv: 'development' }); + + manager.write(createInput('debug')); + + expect(write).toHaveBeenCalledOnce(); + }); + + test('suppresses debug logs in production by default', () => { + const { manager, write } = createManager({ nodeEnv: 'production' }); + + manager.write(createInput('debug')); + + expect(write).not.toHaveBeenCalled(); + }); + + test('writes debug logs in verbose production mode', () => { + const { manager, write } = createManager({ nodeEnv: 'production', verbose: true }); + + manager.write(createInput('debug')); + + expect(write).toHaveBeenCalledOnce(); + }); + + test('uses a replaced backend for subsequent records', () => { + const { manager, write } = createManager(); + const replacementWrite = vi.fn(); + + manager.setBackend({ write: replacementWrite }); + manager.write(createInput()); + + expect(write).not.toHaveBeenCalled(); + expect(replacementWrite).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/backend/test/unit/logging/Logger.ts b/packages/backend/test/unit/logging/Logger.ts new file mode 100644 index 0000000000..21fa84e5bf --- /dev/null +++ b/packages/backend/test/unit/logging/Logger.ts @@ -0,0 +1,103 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import type { LogRecordInput } from '@/logging/types.js'; + +const mocks = vi.hoisted(() => ({ + write: vi.fn<(input: LogRecordInput) => void>(), +})); + +vi.mock('@/logging/logging-runtime.js', () => ({ + logManager: { + write: mocks.write, + }, +})); + +import Logger from '@/logger.js'; + +describe('Logger', () => { + beforeEach(() => { + mocks.write.mockReset(); + }); + + test('passes immutable multi-level root-to-leaf context to the manager', () => { + const root = new Logger('root', 'red'); + const child = root.createSubLogger('child', 'green'); + const leaf = child.createSubLogger('leaf', 'blue'); + + leaf.info('from leaf'); + root.info('from root'); + + expect(mocks.write.mock.calls[0][0]).toMatchObject({ + level: 'info', + message: 'from leaf', + context: [ + { name: 'root', color: 'red' }, + { name: 'child', color: 'green' }, + { name: 'leaf', color: 'blue' }, + ], + }); + expect(mocks.write.mock.calls[1][0].context).toEqual([ + { name: 'root', color: 'red' }, + ]); + }); + + test('maps succ to info with the legacy success presentation', () => { + new Logger('root').succ('completed', { count: 1 }, true); + + expect(mocks.write).toHaveBeenCalledWith({ + level: 'info', + message: 'completed', + context: [{ name: 'root', color: undefined }], + compatibility: { + legacyLevel: 'success', + important: true, + data: { count: 1 }, + }, + }); + }); + + test('uses Error.toString and adds the Error to existing data', () => { + const logger = new Logger('root'); + const error = new TypeError('broken'); + const data: Record = { requestId: 'request' }; + + logger.error(error, data, true); + + expect(data).toEqual({ requestId: 'request', e: error }); + expect(mocks.write).toHaveBeenCalledWith({ + level: 'error', + message: 'TypeError: broken', + context: [{ name: 'root', color: undefined }], + compatibility: { + legacyLevel: undefined, + important: true, + data, + }, + }); + }); + + test('creates data containing the Error when none is supplied', () => { + const error = new Error('broken'); + + new Logger('root').error(error); + + expect(mocks.write.mock.calls[0][0].compatibility?.data).toEqual({ e: error }); + }); + + test('keeps public methods bound when called separately', () => { + const logger = new Logger('root'); + const info = logger.info; + + info('bound'); + + expect(mocks.write).toHaveBeenCalledWith(expect.objectContaining({ + level: 'info', + message: 'bound', + context: [{ name: 'root', color: undefined }], + })); + }); +}); diff --git a/packages/backend/test/unit/logging/PrettyConsoleBackend.ts b/packages/backend/test/unit/logging/PrettyConsoleBackend.ts new file mode 100644 index 0000000000..512facbee0 --- /dev/null +++ b/packages/backend/test/unit/logging/PrettyConsoleBackend.ts @@ -0,0 +1,147 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, test, vi } from 'vitest'; +import type { LogRecord } from '@/logging/types.js'; + +type Formatter = ((value: unknown) => string) & { white?: Formatter }; + +const chalkMock = vi.hoisted(() => { + const format = (name: string): Formatter => (value: unknown) => `<${name}>${String(value)}`; + const bgRed = format('bgRed'); + bgRed.white = format('bgRed.white'); + const bgGreen = format('bgGreen'); + bgGreen.white = format('bgGreen.white'); + + return { + red: format('red'), + yellow: format('yellow'), + green: format('green'), + gray: format('gray'), + blue: format('blue'), + white: format('white'), + bold: format('bold'), + bgRed, + bgGreen, + rgb: (red: number, green: number, blue: number) => format(`rgb:${red},${green},${blue}`), + }; +}); + +vi.mock('chalk', () => ({ + default: chalkMock, +})); + +import { PrettyConsoleBackend } from '@/logging/PrettyConsoleBackend.js'; + +/** Pretty形式のテストで使う共通のログを作成します。 */ +function createRecord(overrides: Partial = {}): LogRecord { + return { + level: 'info', + message: 'message', + context: [{ name: 'root' }], + timestamp: '2025-01-02T03:04:05.678Z', + loggerName: 'root', + processId: 1234, + isPrimary: true, + workerId: null, + ...overrides, + }; +} + +describe('PrettyConsoleBackend', () => { + test.each([ + { level: 'error', label: 'ERR ', message: 'message' }, + { level: 'warn', label: 'WARN', message: 'message' }, + { level: 'debug', label: 'VERB', message: 'message' }, + { level: 'info', label: 'INFO', message: 'message' }, + ] as const)('formats the $level label and message', ({ level, label, message }) => { + const output = vi.fn(); + const backend = new PrettyConsoleBackend({ output, withLogTime: () => false }); + + backend.write(createRecord({ level })); + + expect(output).toHaveBeenCalledWith(`${label} *\t[root]\t${message}`); + }); + + test('formats legacy success as DONE while retaining the info severity', () => { + const output = vi.fn(); + const backend = new PrettyConsoleBackend({ output, withLogTime: () => false }); + + backend.write(createRecord({ + level: 'info', + compatibility: { legacyLevel: 'success' }, + })); + + expect(output).toHaveBeenCalledWith('DONE *\t[root]\tmessage'); + }); + + test('formats contexts from root to leaf using their configured colors', () => { + const output = vi.fn(); + const backend = new PrettyConsoleBackend({ output, withLogTime: () => false }); + + backend.write(createRecord({ + context: [ + { name: 'root', color: 'red' }, + { name: 'child' }, + { name: 'leaf', color: 'blue' }, + ], + })); + + expect(output).toHaveBeenCalledWith('INFO *\t[root child leaf]\tmessage'); + }); + + test('prefixes the time derived from the record timestamp when enabled', () => { + const output = vi.fn(); + const backend = new PrettyConsoleBackend({ output, withLogTime: () => true }); + + backend.write(createRecord()); + + expect(output).toHaveBeenCalledWith('03:04:05 INFO *\t[root]\tmessage'); + }); + + test('uses the worker id for a worker process', () => { + const output = vi.fn(); + const backend = new PrettyConsoleBackend({ output, withLogTime: () => false }); + + backend.write(createRecord({ isPrimary: false, workerId: 7 })); + + expect(output).toHaveBeenCalledWith('INFO 7\t[root]\tmessage'); + }); + + test('bolds an important record and passes data as the second output argument', () => { + const output = vi.fn(); + const data = { detail: 'value' }; + const backend = new PrettyConsoleBackend({ output, withLogTime: () => false }); + + backend.write(createRecord({ + level: 'error', + compatibility: { important: true, data }, + })); + + expect(output).toHaveBeenCalledWith( + 'ERR *\t[root]\tmessage', + data, + ); + }); + + test('treats fatal records as important errors', () => { + const output = vi.fn(); + const backend = new PrettyConsoleBackend({ output, withLogTime: () => false }); + + backend.write(createRecord({ level: 'fatal' })); + + expect(output).toHaveBeenCalledWith('ERR *\t[root]\tmessage'); + }); + + test('omits null data from output arguments', () => { + const output = vi.fn(); + const backend = new PrettyConsoleBackend({ output, withLogTime: () => false }); + + backend.write(createRecord({ compatibility: { data: null } })); + + expect(output).toHaveBeenCalledTimes(1); + expect(output.mock.calls[0]).toHaveLength(1); + }); +}); From 1110d334c82827a647bace2441c0f25a9eacb7f0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 15 Jul 2026 04:36:32 +0000 Subject: [PATCH 047/168] Bump version to 2026.7.0-alpha.5 --- package.json | 2 +- packages/misskey-js/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 643035b384..fd2253f431 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "misskey", - "version": "2026.7.0-alpha.4", + "version": "2026.7.0-alpha.5", "codename": "nasubi", "repository": { "type": "git", diff --git a/packages/misskey-js/package.json b/packages/misskey-js/package.json index af719499c5..eb61b1a81e 100644 --- a/packages/misskey-js/package.json +++ b/packages/misskey-js/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "misskey-js", - "version": "2026.7.0-alpha.4", + "version": "2026.7.0-alpha.5", "description": "Misskey SDK for JavaScript", "license": "MIT", "main": "./built/index.js", From ed70123ace31bb35a1b4bc33fd0e608d541649f5 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:12:33 +0900 Subject: [PATCH 048/168] Create .coderabbit.yaml --- .coderabbit.yaml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .coderabbit.yaml diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000000..be81490b64 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,4 @@ +reviews: + auto_review: + base_branches: + - "develop" From 0849dc7cdd8286938aad705050f9b64ad187772f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=8A=E3=81=95=E3=82=80=E3=81=AE=E3=81=B2=E3=81=A8?= <46447427+samunohito@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:14:19 +0900 Subject: [PATCH 049/168] =?UTF-8?q?enhance:=20=E6=A7=8B=E9=80=A0=E5=8C=96?= =?UTF-8?q?=E3=83=AD=E3=82=B0API=E3=81=A8=E6=AD=A3=E8=A6=8F=E5=8C=96=20(#1?= =?UTF-8?q?7719)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * enhance: 構造化ログAPIと正規化 * fix changelog * fixed copilot review * fixed coderabbit review --- CHANGELOG.md | 1 + packages/backend/src/logger.ts | 18 +- packages/backend/src/logging/LogManager.ts | 45 +- packages/backend/src/logging/LogNormalizer.ts | 386 ++++++++++++++++++ .../src/logging/PrettyConsoleBackend.ts | 10 +- packages/backend/src/logging/types.ts | 44 +- .../backend/src/server/api/ApiCallService.ts | 18 +- .../backend/test/unit/logging/LogManager.ts | 78 ++++ .../test/unit/logging/LogNormalizer.ts | 136 ++++++ packages/backend/test/unit/logging/Logger.ts | 17 + .../test/unit/logging/PrettyConsoleBackend.ts | 22 +- .../test/unit/server/ApiCallService.ts | 98 +++++ 12 files changed, 846 insertions(+), 27 deletions(-) create mode 100644 packages/backend/src/logging/LogNormalizer.ts create mode 100644 packages/backend/test/unit/logging/LogNormalizer.ts create mode 100644 packages/backend/test/unit/server/ApiCallService.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bef208dbf6..d2cb4e8e40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,7 @@ - Enhance: Node.js 22.23.0以降、24.17.0以降、26.4.0以降をサポートするように - Enhance: Docker Image の Node.js を 26.4.0 に、Debian を trixie (v13) に更新 - Enhance: URLプレビューの結果を内部でキャッシュするように +- Enhance: API内部エラーのログに構造化属性と正規化したエラー情報を付与し、認証情報を自動的に秘匿するように(従来形式の表示は維持) - Fix: `/stats` API のレスポンス型が正しくない問題を修正 - Fix: ハッシュタグに関連するデータを更新する際のエラーハンドリングを修正 - Fix: Sentry 使用環境下にて、Misskey が発行した SQL クエリが span に含まれない問題を修正 diff --git a/packages/backend/src/logger.ts b/packages/backend/src/logger.ts index c32b462cc0..8d58fd88d2 100644 --- a/packages/backend/src/logger.ts +++ b/packages/backend/src/logger.ts @@ -5,7 +5,7 @@ import { bindThis } from '@/decorators.js'; import { logManager } from './logging/logging-runtime.js'; -import type { LogLevel, LoggerContext } from './logging/types.js'; +import type { LogLevel, LoggerContext, LogWriteInput } from './logging/types.js'; import type { Keyword } from 'color-convert'; /** @@ -38,11 +38,12 @@ export default class Logger { * 従来APIの引数を共通形式へ変換し、LogManagerへ渡します。 */ @bindThis - private log(level: LogLevel, message: string, data?: Record | null, important = false, legacyLevel?: 'success'): void { + private log(level: LogLevel, message: string, data?: unknown, important = false, legacyLevel?: 'success', error?: unknown): void { logManager.write({ level, message, context: this.context, + ...(typeof error !== 'undefined' ? { error } : {}), compatibility: { legacyLevel, important, @@ -51,14 +52,23 @@ export default class Logger { }); } + /** 構造化ログをLoggerのcontext付きでLogManagerへ渡します。 */ + @bindThis + public write(input: LogWriteInput): void { + logManager.write({ + ...input, + context: this.context, + }); + } + /** 処理を継続できない状況を記録します。 */ @bindThis public error(x: string | Error, data?: Record | null, important = false): void { if (x instanceof Error) { - // Error本体も第2引数へ残し、従来どおりスタックなどを確認できるようにします。 + // エラー本体も第2引数へ残し、従来どおりスタックなどを確認できるようにします。 data = data ?? {}; data.e = x; - this.log('error', x.toString(), data, important); + this.log('error', x.toString(), data, important, undefined, x); } else if (typeof x === 'object') { this.log('error', `${(x as any).message ?? (x as any).name ?? x}`, data, important); } else { diff --git a/packages/backend/src/logging/LogManager.ts b/packages/backend/src/logging/LogManager.ts index d31aac5daf..6fef23d35f 100644 --- a/packages/backend/src/logging/LogManager.ts +++ b/packages/backend/src/logging/LogManager.ts @@ -6,8 +6,14 @@ import cluster from 'node:cluster'; import process from 'node:process'; import { envOption } from '@/env.js'; +import { + findLegacyLogError, + normalizeLogAttributes, + serializeLogError, + type LogNormalizationProfile, +} from './LogNormalizer.js'; import type { LogBackend } from './LogBackend.js'; -import type { LogRecordInput } from './types.js'; +import type { LogRecord, LogRecordInput } from './types.js'; /** ログを出力したプロセスを識別するための情報です。 */ export type LogProcessInfo = { @@ -28,6 +34,11 @@ export type LogManagerDependencies = { readonly getNodeEnv: () => string | undefined; }; +/** ログ管理の初期化時に指定できる正規化設定です。 */ +export type LogManagerOptions = { + readonly normalizationProfile?: LogNormalizationProfile; +}; + const defaultDependencies: LogManagerDependencies = { now: () => new Date(), getProcessInfo: () => ({ @@ -47,17 +58,23 @@ const defaultDependencies: LogManagerDependencies = { export class LogManager { private backend: LogBackend; private readonly dependencies: LogManagerDependencies; + private normalizationProfile: LogNormalizationProfile; /** * 出力先と実行環境から値を取得する処理を受け取ります。 * 実行環境の取得処理は、必要な項目だけテスト用に差し替えられます。 */ - constructor(backend: LogBackend, dependencies: Partial = {}) { + constructor( + backend: LogBackend, + dependencies: Partial = {}, + options: LogManagerOptions = {}, + ) { this.backend = backend; this.dependencies = { ...defaultDependencies, ...dependencies, }; + this.normalizationProfile = options.normalizationProfile ?? 'standard'; } /** @@ -68,6 +85,11 @@ export class LogManager { this.backend = backend; } + /** 正規化方式を切り替え、既に作成済みのLoggerにも反映します。 */ + public setNormalizationProfile(profile: LogNormalizationProfile): void { + this.normalizationProfile = profile; + } + /** * 出力条件を確認し、共通情報を付加して出力先へ渡します。 */ @@ -81,14 +103,27 @@ export class LogManager { const processInfo = this.dependencies.getProcessInfo(); // 呼び出し側の配列を共有せず、親から末端までの順序を固定します。 const context = [...input.context]; - this.backend.write({ - ...input, + // 出力を実際に行う直前にだけ正規化し、捨てられるdebugログのコストを抑えます。 + const { attributes, error: inputError, ...inputWithoutStructuredValues } = input; + const normalizedAttributes = typeof attributes !== 'undefined' + ? normalizeLogAttributes(attributes, { profile: this.normalizationProfile }) + : undefined; + const error = inputError ?? findLegacyLogError(input.compatibility?.data); + const normalizedError = typeof error !== 'undefined' + ? serializeLogError(error, { profile: this.normalizationProfile }) + : undefined; + const record = { + ...inputWithoutStructuredValues, context, timestamp: this.dependencies.now().toISOString(), loggerName: context.map(segment => segment.name).join('.'), processId: processInfo.processId, isPrimary: processInfo.isPrimary, workerId: processInfo.workerId, - }); + ...(normalizedAttributes ? { attributes: normalizedAttributes } : {}), + ...(normalizedError ? { error: normalizedError } : {}), + } as LogRecord; + + this.backend.write(record); } } diff --git a/packages/backend/src/logging/LogNormalizer.ts b/packages/backend/src/logging/LogNormalizer.ts new file mode 100644 index 0000000000..aa3d1360a7 --- /dev/null +++ b/packages/backend/src/logging/LogNormalizer.ts @@ -0,0 +1,386 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Buffer } from 'node:buffer'; +import type { LogAttributeValue, LogAttributes, SerializedError } from './types.js'; + +/** 正規化の粒度を表します。詳細指定でも秘匿処理は常に有効です。 */ +export type LogNormalizationProfile = 'standard' | 'detailed'; + +/** 正規化で使う上限値です。 */ +export type LogNormalizationLimits = { + readonly maxDepth: number; + readonly maxEntries: number; + readonly maxStringBytes: number; + readonly maxBytes: number; +}; + +/** 属性のキーを秘匿すべきか判定する関数です。 */ +export type LogRedactor = (path: readonly string[], key: string) => boolean; + +/** 正規化処理へ渡す設定です。 */ +export type LogNormalizationOptions = { + readonly profile?: LogNormalizationProfile; + readonly limits?: Partial; + readonly redactor?: LogRedactor; +}; + +/** 通常運用でログが肥大化しないようにした上限です。 */ +export const STANDARD_LOG_NORMALIZATION_LIMITS: LogNormalizationLimits = { + maxDepth: 6, + maxEntries: 100, + maxStringBytes: 8 * 1024, + maxBytes: 64 * 1024, +}; + +/** 障害調査時により多くの情報を残す上限です。 */ +export const DETAILED_LOG_NORMALIZATION_LIMITS: LogNormalizationLimits = { + maxDepth: 10, + maxEntries: 1000, + maxStringBytes: 64 * 1024, + maxBytes: 256 * 1024, +}; + +const REDACTED = '[REDACTED]'; +const CIRCULAR = '[Circular]'; +const TRUNCATED = '[Truncated]'; +const UNSUPPORTED = '[Unsupported]'; +const TRUNCATED_KEY = '[Truncated]'; + +const sensitiveKeyParts = [ + 'password', + 'passwd', + 'passphrase', + 'token', + 'secret', + 'authorization', + 'cookie', + 'apikey', + 'privatekey', + 'credential', + 'hcaptcharesponse', + 'grecaptcharesponse', + 'turnstileresponse', + 'mcaptcharesponse', + 'testcaptcharesponse', +]; + +/** キー名を比較用に揃え、区切り文字による表記揺れを吸収します。 */ +function normalizeKey(key: string): string { + return key.toLowerCase().replace(/[-_.\s]/g, ''); +} + +/** 既定の秘匿対象を判定します。Misskey APIの`i`も認証情報として扱います。 */ +export function defaultLogRedactor(_path: readonly string[], key: string): boolean { + const normalized = normalizeKey(key); + return normalized === 'i' || sensitiveKeyParts.some(part => normalized.includes(part)); +} + +/** 選択した方式と個別指定を合わせて、実際の上限値を決めます。 */ +export function resolveLogNormalizationLimits(options: LogNormalizationOptions = {}): LogNormalizationLimits { + const base = options.profile === 'detailed' + ? DETAILED_LOG_NORMALIZATION_LIMITS + : STANDARD_LOG_NORMALIZATION_LIMITS; + const limits = { + ...base, + ...options.limits, + }; + return { + maxDepth: Math.max(0, limits.maxDepth), + maxEntries: Math.max(0, limits.maxEntries), + maxStringBytes: Math.max(1, limits.maxStringBytes), + maxBytes: Math.max(2, limits.maxBytes), + }; +} + +/** 値が通常のオブジェクトとして読めるか判定します。 */ +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +/** 外部入力のキーを安全に格納するため、prototypeを持たない属性領域を作成します。 */ +function createAttributeMap(): Record { + return Object.create(null) as Record; +} + +/** 値を文字列化し、文字列化処理自体の例外もログ処理へ漏らさないようにします。 */ +function stringifySafely(value: unknown): string { + try { + return String(value); + } catch { + return UNSUPPORTED; + } +} + +/** UTF-8のバイト数を測ります。ログの上限を文字数ではなく出力サイズで揃えるために使います。 */ +function byteLength(value: string): number { + return Buffer.byteLength(value, 'utf8'); +} + +/** 文字列をUTF-8の上限内へ切り詰めます。 */ +function normalizeString(value: string, maxBytes: number): string { + if (byteLength(value) <= maxBytes) return value; + const suffix = `…${TRUNCATED}`; + if (byteLength(suffix) > maxBytes) { + const end = findMaxPrefixLength(value, '', maxBytes); + return value.slice(0, end); + } + const end = findMaxPrefixLength(value, suffix, maxBytes); + return value.slice(0, end) + suffix; +} + +/** 指定した後置文字列を含めて上限に収まる接頭辞の長さを二分探索します。 */ +function findMaxPrefixLength(value: string, suffix: string, maxBytes: number): number { + let lower = 0; + let upper = value.length; + while (lower < upper) { + const middle = Math.ceil((lower + upper) / 2); + if (byteLength(value.slice(0, middle) + suffix) <= maxBytes) { + lower = middle; + } else { + upper = middle - 1; + } + } + // UTF-16のサロゲート対を途中で切らないよう、必要なら1文字戻します。 + if (lower > 0 && lower < value.length) { + const code = value.charCodeAt(lower - 1); + if (code >= 0xd800 && code <= 0xdbff) lower--; + } + return lower; +} + +/** 特殊な値に対しても、エラー判定で例外を発生させないようにします。 */ +function isErrorValue(value: unknown): value is Error { + try { + return value instanceof Error; + } catch { + return false; + } +} + +/** 値の読み出しを安全に行い、壊れたログ属性が本処理を中断しないようにします。 */ +function readProperty(value: Record, key: string): unknown { + try { + return value[key]; + } catch { + return `${UNSUPPORTED}: property access failed`; + } +} + +/** 属性をJSONへ出力できる値へ変換します。 */ +function normalizeValue( + value: unknown, + path: readonly string[], + depth: number, + seen: WeakSet, + limits: LogNormalizationLimits, + redactor: LogRedactor, +): LogAttributeValue { + if (value === null) return null; + if (typeof value === 'string') return normalizeString(value, limits.maxStringBytes); + if (typeof value === 'boolean') return value; + if (typeof value === 'number') return Number.isFinite(value) ? value : stringifySafely(value); + if (typeof value === 'bigint') return value.toString(10); + if (typeof value === 'undefined') return `${UNSUPPORTED}: undefined`; + if (typeof value === 'function' || typeof value === 'symbol') return `${UNSUPPORTED}: ${typeof value}`; + let isArray = false; + try { + if (value instanceof Date) { + return normalizeString(value.toISOString(), limits.maxStringBytes); + } + isArray = Array.isArray(value); + if (!isArray) { + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return `${UNSUPPORTED}: object`; + } + } catch { + return `${UNSUPPORTED}: object access failed`; + } + if (seen.has(value)) return CIRCULAR; + if (depth >= limits.maxDepth) return TRUNCATED; + seen.add(value); + + try { + if (isArray) { + const arrayValue = value as readonly unknown[]; + const result: LogAttributeValue[] = []; + const entries = Math.min(arrayValue.length, limits.maxEntries); + for (let i = 0; i < entries; i++) { + result.push(normalizeValue(arrayValue[i], [...path, String(i)], depth + 1, seen, limits, redactor)); + } + if (arrayValue.length > entries) result.push(TRUNCATED); + return result; + } + + // 攻撃者が指定した`__proto__`を通常の属性として保持するため、null prototypeを使います。 + const result = createAttributeMap(); + const keys = Object.keys(value).sort(); + const entries = Math.min(keys.length, limits.maxEntries); + for (let i = 0; i < entries; i++) { + const key = keys[i]; + // 秘匿対象は値を読み出す前に置き換え、読み出し処理や巨大な値にも触れません。 + result[key] = redactor(path, key) + ? REDACTED + : normalizeValue(readProperty(value as Record, key), [...path, key], depth + 1, seen, limits, redactor); + } + if (keys.length > entries) result[TRUNCATED_KEY] = TRUNCATED; + return result; + } catch { + return `${UNSUPPORTED}: object access failed`; + } finally { + seen.delete(value); + } +} + +/** JSON文字列化した値のバイト数を測ります。正規化後の最終上限に使います。 */ +function serializedByteLength(value: LogAttributeValue): number { + return byteLength(JSON.stringify(value)); +} + +/** 正規化済みの値を上限内へ再帰的に縮めます。 */ +function trimToByteLimit(value: LogAttributeValue, maxBytes: number): LogAttributeValue { + if (serializedByteLength(value) <= maxBytes) return value; + if (typeof value === 'string') return normalizeString(value, maxBytes); + if (Array.isArray(value)) { + const result: LogAttributeValue[] = []; + for (const item of value) { + const trimmedItem = trimToByteLimit(item, maxBytes); + const candidate = [...result, trimmedItem]; + if (serializedByteLength(candidate) > maxBytes) break; + result.push(trimmedItem); + } + if (result.length < value.length && serializedByteLength([...result, TRUNCATED]) <= maxBytes) result.push(TRUNCATED); + return result; + } + if (isObject(value)) { + // 上限調整中も`__proto__`を安全に属性として扱えるようにします。 + const result = createAttributeMap(); + for (const key of Object.keys(value).sort()) { + const candidate = Object.assign(createAttributeMap(), result, { [key]: trimToByteLimit(value[key], maxBytes) }); + if (serializedByteLength(candidate) > maxBytes) break; + result[key] = candidate[key]; + } + if (Object.keys(result).length < Object.keys(value).length) { + const candidate = Object.assign(createAttributeMap(), result, { [TRUNCATED_KEY]: TRUNCATED }); + if (serializedByteLength(candidate) <= maxBytes) return candidate; + } + return result; + } + return TRUNCATED; +} + +/** エラーらしい原因情報かを安全に判定します。特殊な値もログ処理を壊しません。 */ +function isErrorLike(value: unknown): value is Record { + if (!isObject(value)) return false; + try { + return ['name', 'message', 'stack', 'cause'].some(key => key in value); + } catch { + return false; + } +} + +/** 属性をJSONへ出力できるオブジェクトへ正規化します。 */ +export function normalizeLogAttributes(value: unknown, options: LogNormalizationOptions = {}): LogAttributes { + const limits = resolveLogNormalizationLimits(options); + const redactor = options.redactor ?? defaultLogRedactor; + const normalized = normalizeValue(value, [], 0, new WeakSet(), limits, redactor); + const root = isObject(normalized) && !Array.isArray(normalized) ? normalized : { value: normalized }; + return trimToByteLimit(root as LogAttributes, limits.maxBytes) as LogAttributes; +} + +/** 旧APIのdata領域から、エラー本体を見つけて構造化した形へ渡します。 */ +export function findLegacyLogError(value: unknown): unknown { + if (isErrorValue(value)) return value; + if (!isObject(value)) return undefined; + for (const key of ['e', 'err', 'error', 'stack']) { + const candidate = readProperty(value, key); + if (isErrorValue(candidate)) return candidate; + } + return undefined; +} + +/** エラーの原因情報を含めて、一定の形へ正規化します。 */ +function serializeErrorValue( + value: unknown, + depth: number, + seen: WeakSet, + limits: LogNormalizationLimits, + redactor: LogRedactor, +): SerializedError { + if (!isObject(value)) { + return { + type: value === null ? 'null' : typeof value, + message: normalizeString(stringifySafely(value), limits.maxStringBytes), + }; + } + if (seen.has(value)) return { type: 'CircularError', message: CIRCULAR }; + seen.add(value); + + try { + const name = readProperty(value, 'name'); + const message = readProperty(value, 'message'); + const stack = readProperty(value, 'stack'); + const cause = readProperty(value, 'cause'); + const constructor = readProperty(value, 'constructor'); + const constructorName = typeof constructor === 'function' || isObject(constructor) + ? readProperty(constructor as Record, 'name') + : undefined; + const type = normalizeString( + typeof name === 'string' && name.length > 0 + ? name + : typeof constructorName === 'string' && constructorName.length > 0 ? constructorName : 'Error', + limits.maxStringBytes, + ); + const result: { type: string; message: string; stack?: string; cause?: SerializedError | LogAttributeValue } = { + type, + message: normalizeString(typeof message === 'string' ? message : stringifySafely(value), limits.maxStringBytes), + }; + if (typeof stack === 'string') result.stack = normalizeString(stack, limits.maxStringBytes); + if (typeof cause !== 'undefined') { + result.cause = depth < limits.maxDepth + ? isErrorValue(cause) || isErrorLike(cause) + ? serializeErrorValue(cause, depth + 1, seen, limits, redactor) + : normalizeValue(cause, ['cause'], depth + 1, seen, limits, redactor) + : TRUNCATED; + } + return result; + } finally { + seen.delete(value); + } +} + +/** エラーの必須項目を残したまま、全体の出力サイズを上限内へ縮めます。 */ +function trimSerializedError(value: SerializedError, maxBytes: number): SerializedError { + const requiredLimit = Math.max(0, Math.floor((maxBytes - 24) / 2)); + const result: { type: string; message: string; stack?: string; cause?: SerializedError | LogAttributeValue } = { + type: normalizeString(value.type, requiredLimit), + message: normalizeString(value.message, requiredLimit), + }; + if (serializedByteLength(result as unknown as LogAttributeValue) > maxBytes) { + return { type: '', message: '' }; + } + if (value.stack != null) { + const candidate = { ...result, stack: value.stack }; + if (serializedByteLength(candidate as unknown as LogAttributeValue) <= maxBytes) result.stack = value.stack; + } + if (value.cause != null) { + const cause = typeof value.cause === 'object' + ? trimToByteLimit(value.cause as LogAttributeValue, maxBytes) + : value.cause; + const candidate = { ...result, cause }; + if (serializedByteLength(candidate as unknown as LogAttributeValue) <= maxBytes) result.cause = cause as SerializedError | LogAttributeValue; + } + return result; +} + +/** 任意のエラー入力を、ログへ安全に埋め込める形へ変換します。 */ +export function serializeLogError(value: unknown, options: LogNormalizationOptions = {}): SerializedError | undefined { + if (typeof value === 'undefined' || value === null) return undefined; + const limits = resolveLogNormalizationLimits(options); + // 必須項目を含む最小のJSON形すら収まらない場合は、上限を超えないよう出力しません。 + if (limits.maxBytes < 24) return undefined; + const serialized = serializeErrorValue(value, 0, new WeakSet(), limits, options.redactor ?? defaultLogRedactor); + return trimSerializedError(serialized, limits.maxBytes); +} diff --git a/packages/backend/src/logging/PrettyConsoleBackend.ts b/packages/backend/src/logging/PrettyConsoleBackend.ts index 6af60e5efd..6e9bc313e3 100644 --- a/packages/backend/src/logging/PrettyConsoleBackend.ts +++ b/packages/backend/src/logging/PrettyConsoleBackend.ts @@ -10,7 +10,7 @@ import { envOption } from '@/env.js'; import type { LogBackend } from './LogBackend.js'; import type { LogRecord } from './types.js'; -/** Pretty形式の出力処理が外部から受け取る依存関係です。 */ +/** 見やすい形式の出力処理が外部から受け取る依存関係です。 */ export type PrettyConsoleBackendDependencies = { readonly output: (...args: unknown[]) => void; readonly withLogTime: () => boolean; @@ -75,7 +75,15 @@ export class PrettyConsoleBackend implements LogBackend { // `data`は文字列へ埋め込まず、第2引数として渡す従来の挙動を維持します。 const args: unknown[] = [important ? chalk.bold(log) : log]; if (record.compatibility?.data != null) { + // 旧形式の値はそのまま第2引数へ渡し、既存の表示と調査方法を保ちます。 args.push(record.compatibility.data); + } else if (record.eventName != null || record.attributes != null || record.error != null) { + // 構造化ログは、専用の出力先がなくても調査情報を確認できるようにします。 + args.push({ + ...(record.eventName != null ? { eventName: record.eventName } : {}), + ...(record.attributes != null ? { attributes: record.attributes } : {}), + ...(record.error != null ? { error: record.error } : {}), + }); } this.dependencies.output(...args); } diff --git a/packages/backend/src/logging/types.ts b/packages/backend/src/logging/types.ts index e65fd2195e..aa3b63edec 100644 --- a/packages/backend/src/logging/types.ts +++ b/packages/backend/src/logging/types.ts @@ -8,9 +8,30 @@ import type { Keyword } from 'color-convert'; /** ログの重要度を表します。 */ export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal'; +/** 正規化後にログ属性として扱えるJSONの値です。 */ +export type LogAttributeValue = + | string + | number + | boolean + | null + | readonly LogAttributeValue[] + | { readonly [key: string]: LogAttributeValue }; + +/** 正規化後のログ属性です。 */ +export type LogAttributes = Readonly>; + +/** ロガーの呼び出し側が構造化ログとして指定する入力です。 */ +export type LogWriteInput = { + readonly level: LogLevel; + readonly message: string; + readonly eventName?: string; + readonly attributes?: Readonly>; + readonly error?: unknown; +}; + /** * ロガー名を構成する一要素です。 - * 色はPretty形式での表示だけに使い、ログの意味には影響させません。 + * 色は見やすい形式での表示だけに使い、ログの意味には影響させません。 */ export type LoggerContext = { readonly name: string; @@ -20,31 +41,40 @@ export type LoggerContext = { /** * 従来のコンソール表示を維持するための情報です。 * 構造化ログの項目と混同しないよう、互換用の領域へ分離しています。 + * `data`は従来表示を保つため正規化せず、秘匿が必要な値は構造化属性へ移します。 */ export type LogCompatibility = { readonly legacyLevel?: 'success'; readonly important?: boolean; - readonly data?: Record | null; + readonly data?: unknown; }; /** * 呼び出し側からLogManagerへ渡す、時刻などを付加する前のログです。 */ -export type LogRecordInput = { - readonly level: LogLevel; - readonly message: string; +export type LogRecordInput = LogWriteInput & { readonly context: readonly LoggerContext[]; readonly compatibility?: LogCompatibility; }; +/** エラーをJSONへ出力するために正規化した形です。 */ +export type SerializedError = { + readonly type: string; + readonly message: string; + readonly stack?: string; + readonly cause?: SerializedError | LogAttributeValue; +}; + /** * 出力先へ渡すログです。 - * LogManagerが時刻やプロセス情報を付加するため、出力形式に依存せず利用できます。 + * `compatibility.data`は見やすい形式だけが使う従来値で、構造化した出力先は属性とエラーを利用します。 */ -export type LogRecord = LogRecordInput & { +export type LogRecord = Omit & { readonly timestamp: string; readonly loggerName: string; readonly processId: number; readonly isPrimary: boolean; readonly workerId: number | null; + readonly attributes?: LogAttributes; + readonly error?: SerializedError; }; diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts index 65b4abee05..685c8a8ab3 100644 --- a/packages/backend/src/server/api/ApiCallService.ts +++ b/packages/backend/src/server/api/ApiCallService.ts @@ -110,15 +110,16 @@ export class ApiCallService implements OnApplicationShutdown { throw err; } else { const errId = randomUUID(); - this.logger.error(`Internal error occurred in ${ep.name}: ${err.message}`, { - ep: ep.name, - ps: data, - e: { - message: err.message, - code: err.name, - stack: err.stack, - id: errId, + this.logger.write({ + level: 'error', + eventName: 'api.endpoint.failed', + message: `Internal error occurred in ${ep.name}: ${err.message}`, + attributes: { + 'api.endpoint': ep.name, + 'error.id': errId, + 'api.params': data, }, + error: err, }); this.telemetryService.captureMessage(`Internal error occurred in ${ep.name}: ${err.message}`, { @@ -126,7 +127,6 @@ export class ApiCallService implements OnApplicationShutdown { userId, extra: { ep: ep.name, - ps: data, e: { message: err.message, code: err.name, diff --git a/packages/backend/test/unit/logging/LogManager.ts b/packages/backend/test/unit/logging/LogManager.ts index 629be70d5c..9329059033 100644 --- a/packages/backend/test/unit/logging/LogManager.ts +++ b/packages/backend/test/unit/logging/LogManager.ts @@ -27,6 +27,7 @@ function createManager(options: { nodeEnv?: string; isPrimary?: boolean; workerId?: number | null; + normalizationProfile?: 'standard' | 'detailed'; } = {}) { const write = vi.fn(); const manager = new LogManager({ write }, { @@ -39,6 +40,8 @@ function createManager(options: { isQuiet: () => options.quiet ?? false, isVerbose: () => options.verbose ?? false, getNodeEnv: () => options.nodeEnv ?? 'development', + }, { + normalizationProfile: options.normalizationProfile, }); return { manager, write }; @@ -122,4 +125,79 @@ describe('LogManager', () => { expect(write).not.toHaveBeenCalled(); expect(replacementWrite).toHaveBeenCalledOnce(); }); + + test('normalizes structured attributes and errors before writing', () => { + const { manager, write } = createManager(); + const error = new TypeError('broken'); + + manager.write({ + ...createInput('error'), + eventName: 'api.endpoint.failed', + attributes: { i: 'secret', safe: 'value' }, + error, + }); + + expect(write.mock.calls[0][0]).toMatchObject({ + eventName: 'api.endpoint.failed', + attributes: { i: '[REDACTED]', safe: 'value' }, + error: { type: 'TypeError', message: 'broken' }, + }); + }); + + test('does not pass raw structured values when normalization omits an error', () => { + const { manager, write } = createManager(); + + manager.write({ + ...createInput('error'), + attributes: { detail: 'value' }, + error: null, + }); + + expect(write.mock.calls[0][0].attributes).toEqual({ detail: 'value' }); + expect(write.mock.calls[0][0]).not.toHaveProperty('error'); + }); + + test('keeps legacy data for the pretty output while serializing its Error separately', () => { + const { manager, write } = createManager(); + const error = new Error('legacy failure'); + const data = { detail: 'legacy', e: error }; + + manager.write({ + ...createInput('error'), + compatibility: { data }, + }); + + expect(write.mock.calls[0][0].compatibility?.data).toBe(data); + expect(write.mock.calls[0][0]).toMatchObject({ + error: { type: 'Error', message: 'legacy failure' }, + }); + expect(write.mock.calls[0][0].attributes).toBeUndefined(); + }); + + test('supports the detailed normalization profile', () => { + const { manager, write } = createManager({ normalizationProfile: 'detailed' }); + + manager.write({ + ...createInput(), + attributes: { nested: { level1: { level2: { level3: { value: 'kept' } } } } }, + }); + + expect(write.mock.calls[0][0].attributes).toEqual({ + nested: { level1: { level2: { level3: { value: 'kept' } } } }, + }); + }); + + test('switches the normalization profile for existing loggers', () => { + const { manager, write } = createManager(); + const nested = { level1: { level2: { level3: { level4: { level5: { level6: { value: 'kept' } } } } } } }; + + manager.write({ ...createInput(), attributes: nested }); + manager.setNormalizationProfile('detailed'); + manager.write({ ...createInput(), attributes: nested }); + + expect(write.mock.calls[0][0].attributes).toMatchObject({ + level1: { level2: { level3: { level4: { level5: { level6: '[Truncated]' } } } } }, + }); + expect(write.mock.calls[1][0].attributes).toEqual(nested); + }); }); diff --git a/packages/backend/test/unit/logging/LogNormalizer.ts b/packages/backend/test/unit/logging/LogNormalizer.ts new file mode 100644 index 0000000000..642887d225 --- /dev/null +++ b/packages/backend/test/unit/logging/LogNormalizer.ts @@ -0,0 +1,136 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, test } from 'vitest'; +import { + findLegacyLogError, + normalizeLogAttributes, + serializeLogError, +} from '@/logging/LogNormalizer.js'; + +describe('LogNormalizer', () => { + test('normalizes common non-JSON values', () => { + expect(normalizeLogAttributes({ + date: new Date('2025-01-02T03:04:05.678Z'), + bigint: 123n, + infinity: Infinity, + undefinedValue: undefined, + })).toEqual({ + bigint: '123', + date: '2025-01-02T03:04:05.678Z', + infinity: 'Infinity', + undefinedValue: '[Unsupported]: undefined', + }); + }); + + test('marks unsupported objects and invalid dates without throwing', () => { + expect(normalizeLogAttributes({ + map: new Map([['key', 'value']]), + invalidDate: new Date('invalid'), + })).toEqual({ + invalidDate: '[Unsupported]: object access failed', + map: '[Unsupported]: object', + }); + }); + + test('preserves __proto__ as a normal attribute key', () => { + const value = JSON.parse('{"__proto__":{"nested":"value"},"large":"' + 'x'.repeat(100) + '"}') as Record; + const normalized = normalizeLogAttributes(value, { limits: { maxBytes: 64 } }); + + expect(Object.keys(normalized)).toContain('__proto__'); + expect(normalized['__proto__']).toEqual({ nested: 'value' }); + expect(Object.getPrototypeOf(normalized)).toBeNull(); + }); + + test('redacts sensitive fields recursively, including the Misskey i token', () => { + expect(normalizeLogAttributes({ + i: 'top-level-token', + request: { + Authorization: 'bearer-token', + password: 'password', + nested: [{ api_key: 'api-key' }], + }, + visible: 'value', + })).toEqual({ + i: '[REDACTED]', + request: { + Authorization: '[REDACTED]', + password: '[REDACTED]', + nested: [{ api_key: '[REDACTED]' }], + }, + visible: 'value', + }); + }); + + test('keeps cycles finite and marks depth and entry truncation', () => { + const cycle: Record = { value: 'ok' }; + cycle.self = cycle; + + expect(normalizeLogAttributes({ + cycle, + deep: { level1: { level2: { level3: 'value' } } }, + }, { + limits: { maxDepth: 2, maxEntries: 10 }, + })).toEqual({ + cycle: { self: '[Circular]', value: 'ok' }, + deep: { level1: '[Truncated]' }, + }); + expect(normalizeLogAttributes({ entries: { a: 1, b: 2, c: 3 } }, { limits: { maxEntries: 2 } })).toEqual({ + entries: { a: 1, b: 2, '[Truncated]': '[Truncated]' }, + }); + }); + + test('uses the detailed profile while retaining the redaction policy', () => { + const value = { level1: { level2: { level3: { level4: 'value' } } }, token: 'secret' }; + + expect(normalizeLogAttributes(value, { limits: { maxDepth: 3 } })).toMatchObject({ level1: { level2: { level3: '[Truncated]' } }, token: '[REDACTED]' }); + expect(normalizeLogAttributes(value, { profile: 'detailed' })).toEqual({ + level1: { level2: { level3: { level4: 'value' } } }, + token: '[REDACTED]', + }); + }); + + test('keeps normalized attributes within the configured byte limit', () => { + const normalized = normalizeLogAttributes({ first: 'a'.repeat(100), second: 'b'.repeat(100) }, { limits: { maxBytes: 32 } }); + + expect(Buffer.byteLength(JSON.stringify(normalized), 'utf8')).toBeLessThanOrEqual(32); + }); + + test('truncates long multibyte strings without splitting characters', () => { + const normalized = normalizeLogAttributes({ value: '😀'.repeat(100) }, { limits: { maxStringBytes: 16, maxBytes: 100 } }); + + expect(Buffer.byteLength(JSON.stringify(normalized.value), 'utf8')).toBeLessThanOrEqual(16); + expect(JSON.stringify(normalized.value)).not.toContain('�'); + }); + + test('serializes Error and its cause consistently', () => { + const cause = new Error('root cause'); + const error = new TypeError('outer error', { cause }); + + expect(serializeLogError(error)).toMatchObject({ + type: 'TypeError', + message: 'outer error', + cause: { + type: 'Error', + message: 'root cause', + }, + }); + }); + + test('keeps serialized Error output within its byte limit', () => { + const serialized = serializeLogError(new Error('a long message'), { limits: { maxBytes: 32 } }); + + expect(serialized).toBeDefined(); + expect(Buffer.byteLength(JSON.stringify(serialized), 'utf8')).toBeLessThanOrEqual(32); + expect(serializeLogError(new Error('message'), { limits: { maxBytes: 20 } })).toBeUndefined(); + }); + + test('finds Error values in legacy data', () => { + const error = new Error('legacy'); + + expect(findLegacyLogError({ e: error })).toBe(error); + expect(findLegacyLogError({ stack: 'not-an-error' })).toBeUndefined(); + }); +}); diff --git a/packages/backend/test/unit/logging/Logger.ts b/packages/backend/test/unit/logging/Logger.ts index 21fa84e5bf..8cadbca592 100644 --- a/packages/backend/test/unit/logging/Logger.ts +++ b/packages/backend/test/unit/logging/Logger.ts @@ -60,6 +60,22 @@ describe('Logger', () => { }); }); + test('supports structured log input while preserving the context hierarchy', () => { + new Logger('root').createSubLogger('child').write({ + level: 'error', + eventName: 'example.failed', + message: 'failed', + attributes: { id: 'id' }, + error: new Error('broken'), + }); + + expect(mocks.write).toHaveBeenCalledWith(expect.objectContaining({ + level: 'error', + eventName: 'example.failed', + context: [{ name: 'root', color: undefined }, { name: 'child', color: undefined }], + })); + }); + test('uses Error.toString and adds the Error to existing data', () => { const logger = new Logger('root'); const error = new TypeError('broken'); @@ -72,6 +88,7 @@ describe('Logger', () => { level: 'error', message: 'TypeError: broken', context: [{ name: 'root', color: undefined }], + error, compatibility: { legacyLevel: undefined, important: true, diff --git a/packages/backend/test/unit/logging/PrettyConsoleBackend.ts b/packages/backend/test/unit/logging/PrettyConsoleBackend.ts index 512facbee0..8002b0a309 100644 --- a/packages/backend/test/unit/logging/PrettyConsoleBackend.ts +++ b/packages/backend/test/unit/logging/PrettyConsoleBackend.ts @@ -35,7 +35,7 @@ vi.mock('chalk', () => ({ import { PrettyConsoleBackend } from '@/logging/PrettyConsoleBackend.js'; -/** Pretty形式のテストで使う共通のログを作成します。 */ +/** 見やすい形式のテストで使う共通のログを作成します。 */ function createRecord(overrides: Partial = {}): LogRecord { return { level: 'info', @@ -144,4 +144,24 @@ describe('PrettyConsoleBackend', () => { expect(output).toHaveBeenCalledTimes(1); expect(output.mock.calls[0]).toHaveLength(1); }); + + test('passes structured event details as normalized output data', () => { + const output = vi.fn(); + const backend = new PrettyConsoleBackend({ output, withLogTime: () => false }); + + backend.write(createRecord({ + eventName: 'api.endpoint.failed', + attributes: { 'api.endpoint': 'notes/show' }, + error: { type: 'TypeError', message: 'broken' }, + })); + + expect(output).toHaveBeenCalledWith( + 'INFO *\t[root]\tmessage', + { + eventName: 'api.endpoint.failed', + attributes: { 'api.endpoint': 'notes/show' }, + error: { type: 'TypeError', message: 'broken' }, + }, + ); + }); }); diff --git a/packages/backend/test/unit/server/ApiCallService.ts b/packages/backend/test/unit/server/ApiCallService.ts new file mode 100644 index 0000000000..6c1f520207 --- /dev/null +++ b/packages/backend/test/unit/server/ApiCallService.ts @@ -0,0 +1,98 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, test, vi } from 'vitest'; +import { ApiCallService } from '@/server/api/ApiCallService.js'; +import Logger from '@/logger.js'; +import { envOption } from '@/env.js'; +import { logManager } from '@/logging/logging-runtime.js'; +import { PrettyConsoleBackend } from '@/logging/PrettyConsoleBackend.js'; +import type { LogBackend } from '@/logging/LogBackend.js'; +import type { LogRecord } from '@/logging/types.js'; + +/** API失敗ログを確認するための最小Fastify応答を作成します。 */ +function createReply() { + return { + code: vi.fn(), + header: vi.fn(), + send: vi.fn(), + }; +} + +/** APIサービスの依存関係を最小限の仮実装へ差し替えます。 */ +function createService() { + const authenticateService = { + authenticate: vi.fn().mockResolvedValue([null, null]), + }; + const telemetryService = { + startSpan: vi.fn((_name: string, callback: () => unknown) => callback()), + captureMessage: vi.fn(), + }; + const apiLoggerService = { logger: new Logger('api') }; + + const service = new ApiCallService( + {} as never, + {} as never, + {} as never, + authenticateService as never, + {} as never, + {} as never, + apiLoggerService as never, + telemetryService as never, + ); + return { service, telemetryService }; +} + +describe('ApiCallService structured error logging', () => { + test('redacts API credentials and serializes the endpoint error', async () => { + const write = vi.fn(); + logManager.setBackend({ write }); + const previousQuiet = envOption.quiet; + envOption.quiet = false; + const { service, telemetryService } = createService(); + try { + const reply = createReply(); + const endpoint = { + name: 'notes/show', + meta: {}, + params: {}, + exec: vi.fn().mockRejectedValue(new TypeError('broken endpoint')), + }; + const request = { + method: 'POST', + body: { + i: 'native-token', + password: 'password', + options: { visible: true }, + }, + query: {}, + headers: {}, + ip: '127.0.0.1', + }; + + await service.handleRequest(endpoint as never, request as never, reply as never); + + const record = write.mock.calls[0][0] as LogRecord; + expect(record).toMatchObject({ + eventName: 'api.endpoint.failed', + attributes: { + 'api.endpoint': 'notes/show', + 'api.params': { + i: '[REDACTED]', + password: '[REDACTED]', + options: { visible: true }, + }, + }, + error: { type: 'TypeError', message: 'broken endpoint' }, + }); + expect(record.attributes?.['error.id']).toEqual(expect.any(String)); + expect(telemetryService.captureMessage.mock.calls[0][1].extra).not.toHaveProperty('ps'); + } finally { + service.dispose(); + envOption.quiet = previousQuiet; + logManager.setBackend(new PrettyConsoleBackend({ output: () => undefined })); + } + }); +}); From ce411817b4716755676610e8a60a0a43405b2ec4 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:44:18 +0900 Subject: [PATCH 050/168] Update .coderabbit.yaml --- .coderabbit.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index be81490b64..ce726dd641 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -1,4 +1,9 @@ reviews: + profile: "chill" + high_level_summary: false + poem: true auto_review: + enabled: true + drafts: false base_branches: - "develop" From d74a7a515cfaf2236ce9f777cf315e9842ad1894 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:46:42 +0900 Subject: [PATCH 051/168] docs: design generated chunk handling --- ...frontend-bundle-generated-chunks-design.md | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-15-frontend-bundle-generated-chunks-design.md diff --git a/docs/superpowers/specs/2026-07-15-frontend-bundle-generated-chunks-design.md b/docs/superpowers/specs/2026-07-15-frontend-bundle-generated-chunks-design.md new file mode 100644 index 0000000000..95e0de006b --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-frontend-bundle-generated-chunks-design.md @@ -0,0 +1,153 @@ +# Frontend bundle report: generated chunk handling + +## Background + +The frontend bundle report compares the Vite manifests produced for the pull request base and head builds. Chunks with a `src` value are currently identified by that source path. Chunks without `src` are identified by Rolldown's generated `name`. + +Generated names are not unique or stable identities. Multiple unrelated shared chunks can all be called `dist`, `esm`, `index`, or a similar path-derived name. The current collection logic stores those chunks in a `Map` under `chunk:`, so later entries overwrite earlier entries. This produces two problems: + +1. unrelated chunks can be compared as if they were the same chunk; and +2. overwritten chunks are omitted from the reported total. + +Individual size changes for these generated shared chunks are not sufficiently actionable to justify heuristic matching. + +## Goals + +- Never compare unrelated generated chunks as the same chunk. +- Keep generated chunks out of the individual chunk-diff rows. +- Preserve the contribution of every physical JavaScript chunk in totals. +- Show the aggregate size change of generated chunks so unexplained bundle growth remains visible. +- Apply the same rules to the full chunk report and the startup chunk report. +- Continue comparing source-backed chunks and intentionally named chunks. + +## Non-goals + +- Inferring chunk identity from module-set similarity. +- Preserving an `updated` relationship for every shared chunk after code splitting changes. +- Changing the frontend's code-splitting strategy or output filenames. +- Suppressing specific names such as `esm` or `dist` with a denylist. + +## Approaches considered + +### Denylist generated-looking names + +Exclude names such as `esm`, `dist`, `lib`, and `index`. + +This is not selected because Rolldown can generate many other names, and a denylist can also hide an intentionally named chunk. It would leave the underlying name collision and total-size bug in place. + +### Compare only stable identities and aggregate the rest + +Classify chunks by whether the report has a stable semantic identity. Source-backed chunks and explicitly supported manual chunk names remain individually comparable. All other generated chunks are retained as physical files but shown only as an aggregate. + +This is the selected approach. It removes false comparisons without introducing matching heuristics, and it keeps all bytes visible. + +### Match shared chunks by their module sets + +Use visualizer metadata to compare exact or similar sets of module IDs. + +This could retain more individual diffs, but splits, merges, dependency-version paths, and small module movements make the matching policy complex and potentially misleading. It can be added later if aggregate data proves insufficient. + +## Design + +### Separate physical chunks from comparable identities + +`collectReport` will no longer use a single map keyed by the comparison identity as its source of truth. It will collect every resolved JavaScript output file exactly once into a physical chunk collection. + +Each physical chunk contains: + +- its resolved relative output path; +- its byte size; +- its manifest key, when present; +- its display name; and +- an optional comparison key. + +The physical output path is used only for de-duplication within one build. It is not used to match changed chunks across builds. + +### Comparison-key classification + +A chunk receives a comparison key only in one of these cases: + +1. `chunk.src` is present: use `src:`. +2. `chunk.name` is in an explicit allowlist of intentionally stable manual chunks: use `named:`. + +The initial stable-name allowlist is: + +- `vue` +- `i18n` + +These names correspond to the explicit `codeSplitting.groups` configuration in `packages/frontend/vite.config.ts`. + +All other manifest chunks without `src`, including generated names such as `esm` and `dist`, receive no comparison key. JavaScript files found in the localized output directory but absent from the manifest also receive no comparison key. + +If a supposedly stable comparison key occurs more than once within one build, the report must not silently overwrite it. Collection will fail with a descriptive error because duplicate `src` or allowlisted manual names violate the assumptions required for a correct comparison. + +### Full chunk report + +The report computes three independent values: + +1. **Total:** the sum of every unique physical JavaScript chunk. +2. **Generated chunk aggregate:** the sum of chunks without a comparison key. +3. **Individual rows:** before/after comparisons for stable comparison keys only. + +The table starts with the existing `(total)` row. When either build contains generated chunks, it then includes one aggregate row such as: + +```text +(other generated chunks) +``` + +This row compares aggregate sizes, not individual chunk identities. Generated chunks do not participate in the updated/added/removed row counts. + +The report includes a short note stating how many generated chunks were grouped on each side. This makes the scope of the aggregate explicit without listing noisy filenames. + +### Startup chunk report + +Startup traversal continues to follow the entry chunk's static `imports`, but it records manifest keys or resolved physical file paths rather than generated comparison keys. This prevents two startup chunks named `dist` from collapsing into one. + +Startup totals include every unique physical startup chunk. Stable startup chunks receive individual rows, while startup chunks without stable identities contribute to the same `(other generated chunks)` aggregate row within the startup table. + +### Displayed filenames + +For an individually comparable chunk, the details should expose both filenames when they differ. A single before-side filename must not imply that the after size belongs to that same file. + +The display can use one filename when unchanged and `before → after` when the content-hashed filename differs. + +The generated aggregate row does not display an arbitrary representative filename. + +## Data flow + +1. Read each build's Vite manifest. +2. Resolve localized output files and collect unique physical chunks. +3. Attach optional stable comparison keys using the classification rules. +4. Traverse startup imports using manifest identities and map them to physical chunks. +5. Calculate full and startup totals from physical chunks. +6. Calculate generated aggregates from chunks without comparison keys. +7. Compare only unique stable keys for individual rows. +8. Render totals, generated aggregates, and stable rows. + +## Error handling + +- A manifest entry whose expected localized JavaScript file is absent remains a fatal report-generation error. +- Duplicate physical output paths are de-duplicated and counted once. +- Duplicate stable comparison keys fail with an error that includes the key and conflicting files. +- Missing or malformed manifest data remains fatal rather than producing a partial report. + +## Testing + +Add focused fixtures or pure-function tests covering: + +- two unrelated `dist` chunks are both counted in total and grouped into the generated aggregate; +- `dist` and `esm` chunks never produce individual comparison rows; +- source-backed chunks with the same `src` are reported as updated; +- source-backed additions and removals remain visible; +- allowlisted `vue` and `i18n` chunks remain individually comparable; +- duplicate stable keys produce a descriptive error instead of overwriting; +- full totals equal the sum of all unique physical chunks; +- startup totals include multiple same-name generated chunks exactly once each; +- generated aggregate changes do not affect the updated/added/removed summary counts; and +- differing before/after filenames are rendered without attributing both sizes to one file. + +Validation should include the focused tests and repository lint. No CHANGELOG entry is required because this changes developer-facing CI reporting rather than Misskey user behavior. + +## Future extension + +If aggregate generated-chunk data is later found insufficient, visualizer module metadata can support a separate opt-in analysis. That extension must preserve the physical-chunk accounting introduced here and must not reintroduce name-based identity. From 9076a4ecf33ad5a8854973d47baa62b1f89db6aa Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:56:56 +0900 Subject: [PATCH 052/168] docs: plan generated chunk reporting fix --- ...-07-15-frontend-bundle-generated-chunks.md | 661 ++++++++++++++++++ 1 file changed, 661 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-15-frontend-bundle-generated-chunks.md diff --git a/docs/superpowers/plans/2026-07-15-frontend-bundle-generated-chunks.md b/docs/superpowers/plans/2026-07-15-frontend-bundle-generated-chunks.md new file mode 100644 index 0000000000..6a58d5e322 --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-frontend-bundle-generated-chunks.md @@ -0,0 +1,661 @@ +# Frontend Bundle Generated Chunks Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Prevent generated `esm`/`dist`-style chunks from being compared individually while retaining every JavaScript output file in full and startup totals. + +**Architecture:** Keep physical chunk accounting separate from stable before/after identities. Source-backed chunks and the explicit `vue`/`i18n` code-splitting groups remain individually comparable; all other generated chunks are grouped into one aggregate row. Exercise the report generator through filesystem fixtures so tests cover manifest parsing, localized file resolution, startup traversal, and Markdown rendering together. + +**Tech Stack:** Node.js 26.4, TypeScript `.mts` scripts with native type stripping, Node test runner, Vite manifest JSON, GitHub Actions. + +## Global Constraints + +- Never compare unrelated generated chunks as the same chunk. +- Keep generated chunks out of the individual chunk-diff rows. +- Preserve the contribution of every physical JavaScript chunk in totals. +- Show the aggregate size change of generated chunks so unexplained bundle growth remains visible. +- Apply the same rules to the full chunk report and the startup chunk report. +- Continue comparing source-backed chunks and intentionally named chunks. +- The only stable generated-name allowlist entries are `vue` and `i18n`. +- Do not infer chunk identity from module-set similarity. +- Do not change the frontend code-splitting strategy or output filenames. +- Do not suppress generated chunks with a name denylist. +- Do not manually edit locale files other than `locales/ja-JP.yml`; this change does not require any locale edit. +- New `.mts` files must include the repository SPDX header. +- This developer-facing report change does not require a `CHANGELOG.md` entry. + +## File Structure + +- Create `.github/scripts/frontend-js-size.test.mts`: end-to-end fixtures for manifest collection and Markdown output. +- Modify `.github/scripts/frontend-js-size.mts`: physical chunk collection, stable identity classification, generated aggregation, startup accounting, duplicate-key validation, and filename rendering. +- Modify `.github/workflows/lint.yml`: run the focused Node test whenever the report script or its utility changes. +- Reference `docs/superpowers/specs/2026-07-15-frontend-bundle-generated-chunks-design.md`: approved behavior and non-goals; no implementation edit is required. + +--- + +### Task 1: Preserve all physical chunks and aggregate generated chunks + +**Files:** +- Create: `.github/scripts/frontend-js-size.test.mts` +- Modify: `.github/scripts/frontend-js-size.mts:39-134` +- Modify: `.github/scripts/frontend-js-size.mts:315-426` + +**Interfaces:** +- Consumes: Vite `manifest.json`, localized JavaScript files under `built/_frontend_vite_/ja-JP`, and the existing five CLI arguments of `frontend-js-size.mts`. +- Produces: `CollectedReport` with `chunks: FileEntry[]`, `comparableChunks: Record`, `chunksByManifestKey: Record`, and `startupFiles: string[]`; Markdown with `(total)` and `(other generated chunks)` rows. + +- [ ] **Step 1: Write the failing end-to-end test** + +Create `.github/scripts/frontend-js-size.test.mts` with this initial content: + +```ts +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import assert from 'node:assert/strict'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test, { type TestContext } from 'node:test'; +import * as util from './utility.mts'; + +type Manifest = Record; + +const repoDir = path.resolve(import.meta.dirname, '../..'); +const reportScript = path.join(repoDir, '.github/scripts/frontend-js-size.mts'); + +async function writeBuild(repo: string, manifest: Manifest, sizes: Record) { + const outDir = path.join(repo, 'built/_frontend_vite_'); + await fs.mkdir(path.join(outDir, 'ja-JP'), { recursive: true }); + await fs.writeFile(path.join(outDir, 'manifest.json'), JSON.stringify(manifest)); + for (const [file, size] of Object.entries(sizes)) { + const localizedFile = file.replace(/^scripts\//, ''); + const outputFile = path.join(outDir, 'ja-JP', localizedFile); + await fs.mkdir(path.dirname(outputFile), { recursive: true }); + await fs.writeFile(outputFile, Buffer.alloc(size)); + } +} + +async function runReport(t: TestContext, before: { manifest: Manifest; sizes: Record }, after: { manifest: Manifest; sizes: Record }) { + 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, '{}'); + await util.run(process.execPath, [reportScript, beforeDir, afterDir, beforeStats, afterStats, reportFile]); + 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: 45, i18n: 50 }), + ); + + assert.match(report, /\| \(total\) \| 220 B \| 275 B \|/); + assert.equal(report.match(/\| \(other generated chunks\) \| 30 B \| 70 B \|/g)?.length, 2); + assert.equal(report.match(/_2 before \/ 2 after generated chunks are grouped\._/g)?.length, 2); + assert.doesNotMatch(report, /`(?:dist|esm)`<\/summary>/); + assert.match(report, /`src\/_boot_\.ts`<\/summary>/); + assert.match(report, /`vue`<\/summary>/); +}); +``` + +- [ ] **Step 2: Run the test and verify the current implementation fails** + +Run: + +```bash +node --test .github/scripts/frontend-js-size.test.mts +``` + +Expected: FAIL because `(total)` omits one of the duplicate generated-name chunks, no `(other generated chunks)` row exists, and an individual `dist` or `esm` row is rendered. + +- [ ] **Step 3: Replace name-based identity with physical accounting and stable classification** + +In `.github/scripts/frontend-js-size.mts`, replace `FileEntry`, `stableChunkKey`, `collectStartupKeys`, and `collectReport` with these structures and functions while retaining the existing `resolveBuiltFile` function: + +```ts +const stableNamedChunks = new Set(['vue', 'i18n']); + +type FileEntry = { + comparisonKey: string | null; + displayName: string; + file: string; + manifestKeys: string[]; + size: number; +}; + +type CollectedReport = { + manifest: Manifest; + chunks: FileEntry[]; + comparableChunks: Record; + chunksByManifestKey: Record; + startupFiles: string[]; +}; + +function stableChunkKey(chunk: Manifest[string]) { + if (chunk.src != null) return `src:${util.normalizePath(chunk.src)}`; + if (chunk.name != null && stableNamedChunks.has(chunk.name)) return `named:${chunk.name}`; + return null; +} + +function collectStartupManifestKeys(manifest: Manifest) { + const entryKey = findEntryKey(manifest); + const keys = new Set(); + if (entryKey == null) return keys; + + function visit(key: string) { + if (keys.has(key)) return; + const chunk = manifest[key]; + if (!chunk || !chunk.file?.endsWith('.js')) return; + keys.add(key); + for (const importKey of chunk.imports ?? []) visit(importKey); + } + + visit(entryKey); + return keys; +} + +async function collectReport(repoDir: string): Promise { + const outDir = path.join(repoDir, 'built/_frontend_vite_'); + const manifestPath = path.join(outDir, 'manifest.json'); + const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as Manifest; + const chunksByFile = new Map(); + const comparableChunks = new Map(); + const chunksByManifestKey = new Map(); + + for (const [manifestKey, chunk] of Object.entries(manifest)) { + if (!chunk.file?.endsWith('.js')) continue; + const builtFile = await resolveBuiltFile(outDir, chunk.file); + const comparisonKey = stableChunkKey(chunk); + let entry = chunksByFile.get(builtFile.relativePath); + if (entry == null) { + entry = { + comparisonKey, + displayName: chunk.src ?? chunk.name ?? manifestKey, + file: builtFile.relativePath, + manifestKeys: [manifestKey], + size: await util.fileSize(builtFile.absolutePath), + }; + chunksByFile.set(entry.file, entry); + } else if (entry.comparisonKey !== comparisonKey) { + throw new Error(`Conflicting identities for ${entry.file}`); + } else { + entry.manifestKeys.push(manifestKey); + } + chunksByManifestKey.set(manifestKey, entry); + if (comparisonKey != null) { + const existing = comparableChunks.get(comparisonKey); + if (existing != null && existing.file !== entry.file) { + throw new Error(`Duplicate stable chunk key "${comparisonKey}": ${existing.file}, ${entry.file}`); + } + comparableChunks.set(comparisonKey, entry); + } + } + + const localeDir = path.join(outDir, locale); + if (await util.fileExists(localeDir)) { + for await (const fullPath of util.traverseDirectory(localeDir)) { + if (!fullPath.endsWith('.js')) continue; + const relativePath = util.normalizePath(path.relative(outDir, fullPath)); + if (chunksByFile.has(relativePath)) continue; + chunksByFile.set(relativePath, { + comparisonKey: null, + displayName: relativePath, + file: relativePath, + manifestKeys: [], + size: await util.fileSize(fullPath), + }); + } + } + + const startupFiles = new Set(); + for (const manifestKey of collectStartupManifestKeys(manifest)) { + const entry = chunksByManifestKey.get(manifestKey); + if (entry != null) startupFiles.add(entry.file); + } + + return { + manifest, + chunks: [...chunksByFile.values()], + comparableChunks: Object.fromEntries(comparableChunks), + chunksByManifestKey: Object.fromEntries(chunksByManifestKey), + startupFiles: [...startupFiles], + }; +} +``` + +- [ ] **Step 4: Update comparison rows and table rendering** + +Change `getChunkComparisonRows` to consume comparable maps and retain both filenames: + +```ts +function getChunkComparisonRows(keys: string[], before: Record, after: Record) { + return keys.map(key => { + const beforeEntry = before[key]; + const afterEntry = after[key]; + const beforeSize = beforeEntry?.size ?? 0; + const afterSize = afterEntry?.size ?? 0; + return { + key, + name: entryDisplayName(beforeEntry ?? afterEntry), + beforeFile: beforeEntry?.file, + afterFile: afterEntry?.file, + beforeSize, + afterSize, + changeType: beforeEntry == null ? 'added' : afterEntry == null ? 'removed' : beforeSize !== afterSize ? 'updated' : 'unchanged', + sortSize: Math.max(beforeSize, afterSize), + }; + }); +} + +type ChunkAggregate = { + beforeSize: number; + afterSize: number; + beforeCount: number; + afterCount: number; +}; + +function sumChunkSizes(chunks: FileEntry[]) { + return chunks.reduce((sum, chunk) => sum + chunk.size, 0); +} + +function generatedAggregate(before: FileEntry[], after: FileEntry[]): ChunkAggregate { + const beforeGenerated = before.filter(chunk => chunk.comparisonKey == null); + const afterGenerated = after.filter(chunk => chunk.comparisonKey == null); + return { + beforeSize: sumChunkSizes(beforeGenerated), + afterSize: sumChunkSizes(afterGenerated), + beforeCount: beforeGenerated.length, + afterCount: afterGenerated.length, + }; +} + +function comparableMap(chunks: FileEntry[]) { + const entries: [string, FileEntry][] = []; + for (const chunk of chunks) { + if (chunk.comparisonKey != null) entries.push([chunk.comparisonKey, chunk]); + } + return Object.fromEntries(entries); +} +``` + +Replace `chunkMarkdownTable` with this version, which adds a third `generated` argument: + +```ts +function chunkMarkdownTable( + rows: ReturnType, + total?: { beforeSize: number; afterSize: number }, + generated?: ChunkAggregate, +) { + if (rows.length === 0 && total == null && generated == null) return '_No data_'; + + const lines = [ + '| Chunk | Before | After | Δ | Δ (%) |', + '| --- | ---: | ---: | ---: | ---: |', + ]; + let hasSummaryRow = false; + if (total != null) { + lines.push(`| (total) | ${util.formatBytes(total.beforeSize)} | ${util.formatBytes(total.afterSize)} | ${util.calcAndFormatDeltaBytes(total.beforeSize, total.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(total.beforeSize, total.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`); + hasSummaryRow = true; + } + if (generated != null && (generated.beforeCount > 0 || generated.afterCount > 0)) { + lines.push(`| (other generated chunks) | ${util.formatBytes(generated.beforeSize)} | ${util.formatBytes(generated.afterSize)} | ${util.calcAndFormatDeltaBytes(generated.beforeSize, generated.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(generated.beforeSize, generated.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`); + hasSummaryRow = true; + } + if (hasSummaryRow && rows.length > 0) lines.push('| | | | | |'); + + for (const row of rows) { + const chunkFile = row.beforeFile ?? row.afterFile ?? ''; + if (row.changeType === 'added') { + lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{orange}{\\text{( + )}}$ |`); + } else if (row.changeType === 'removed') { + lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{green}{\\text{( - )}}$ |`); + } else { + lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(row.beforeSize, row.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`); + } + } + if (generated != null && (generated.beforeCount > 0 || generated.afterCount > 0)) { + lines.push(''); + lines.push(`_${generated.beforeCount} before / ${generated.afterCount} after generated chunks are grouped._`); + } + return lines.join('\n'); +} +``` + +Update `renderFrontendChunkReport` so the full report uses all physical chunks for totals, stable maps for rows, and unidentifiable chunks for the aggregate: + +```ts +const beforeComparable = before.comparableChunks; +const afterComparable = after.comparableChunks; +const allChunkKeys = [...new Set([...Object.keys(beforeComparable), ...Object.keys(afterComparable)])]; +const allComparisonRows = getChunkComparisonRows(allChunkKeys, beforeComparable, afterComparable); +const diffTotal = { + beforeSize: sumChunkSizes(before.chunks), + afterSize: sumChunkSizes(after.chunks), +}; +const diffGenerated = generatedAggregate(before.chunks, after.chunks); +``` + +For startup accounting, filter physical chunks by `startupFiles`, rebuild comparable maps from those filtered arrays, and calculate totals and aggregate from the filtered arrays: + +```ts +const beforeStartupFiles = new Set(before.startupFiles); +const afterStartupFiles = new Set(after.startupFiles); +const beforeStartupChunks = before.chunks.filter(chunk => beforeStartupFiles.has(chunk.file)); +const afterStartupChunks = after.chunks.filter(chunk => afterStartupFiles.has(chunk.file)); +const beforeStartupComparable = comparableMap(beforeStartupChunks); +const afterStartupComparable = comparableMap(afterStartupChunks); +const startupKeys = [...new Set([...Object.keys(beforeStartupComparable), ...Object.keys(afterStartupComparable)])]; +const startupComparisonRows = getChunkComparisonRows(startupKeys, beforeStartupComparable, afterStartupComparable); +const startupTotal = { + beforeSize: sumChunkSizes(beforeStartupChunks), + afterSize: sumChunkSizes(afterStartupChunks), +}; +const startupGenerated = generatedAggregate(beforeStartupChunks, afterStartupChunks); +``` + +Pass the aggregates with these exact calls: + +```ts +chunkMarkdownTable(diffRows, diffTotal, diffGenerated) +chunkMarkdownTable(startupRows, startupTotal, startupGenerated) +``` + +- [ ] **Step 5: Run the focused test and verify it passes** + +Run: + +```bash +node --test .github/scripts/frontend-js-size.test.mts +``` + +Expected: PASS with `1` test and `0` failures. The Markdown contains two aggregate rows because the fixture makes all chunks startup chunks as well as full-report chunks. + +- [ ] **Step 6: Commit the physical-accounting change** + +```bash +git add .github/scripts/frontend-js-size.mts .github/scripts/frontend-js-size.test.mts +git commit -m "fix(dev): aggregate generated frontend chunks" +``` + +--- + +### Task 2: Reject duplicate stable identities and render honest filenames + +**Files:** +- Modify: `.github/scripts/frontend-js-size.test.mts` +- Modify: `.github/scripts/frontend-js-size.mts:315-372` + +**Interfaces:** +- Consumes: the `Duplicate stable chunk key` validation and comparison rows produced in Task 1. +- Produces: a fatal diagnostic for duplicate `src`/allowlisted identities and a file label that renders `before → after` when hashes differ. + +- [ ] **Step 1: Add failing coverage for duplicate stable names and filename changes** + +Append these tests to `.github/scripts/frontend-js-size.test.mts`: + +```ts +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; + + await assert.rejects( + runReport(t, duplicateVue, fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 })), + /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: 45, 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`/); +}); +``` + +- [ ] **Step 2: Run the tests and confirm the filename test fails** + +Run: + +```bash +node --test .github/scripts/frontend-js-size.test.mts +``` + +Expected: the duplicate-key test passes using Task 1 validation, while `shows both filenames for an updated stable chunk` fails because the table still shows only one file. + +- [ ] **Step 3: Render both filenames without changing identity logic** + +Add this helper near `chunkMarkdownTable`: + +```ts +function chunkFileDisplay(row: ReturnType[number]) { + if (row.beforeFile == null) return row.afterFile ?? ''; + if (row.afterFile == null || row.beforeFile === row.afterFile) return row.beforeFile; + return `${row.beforeFile} → ${row.afterFile}`; +} +``` + +At the start of each `for (const row of rows)` iteration, compute: + +```ts +const chunkFile = chunkFileDisplay(row); +``` + +Replace every `${escapeCell(row.chunkFile)}` interpolation in the three row variants with `${escapeCell(chunkFile)}`. Do not display any representative filename on the aggregate row. + +- [ ] **Step 4: Run all focused tests** + +Run: + +```bash +node --test .github/scripts/frontend-js-size.test.mts +``` + +Expected: PASS with `3` tests and `0` failures. + +- [ ] **Step 5: Commit the validation and display behavior** + +```bash +git add .github/scripts/frontend-js-size.mts .github/scripts/frontend-js-size.test.mts +git commit -m "fix(dev): clarify frontend chunk comparisons" +``` + +--- + +### Task 3: Run the report regression test in CI + +**Files:** +- Modify: `.github/workflows/lint.yml:3-33` +- Modify: `.github/workflows/lint.yml:139` + +**Interfaces:** +- Consumes: `.github/scripts/frontend-js-size.test.mts` from Tasks 1 and 2. +- Produces: a `frontend-bundle-report-test` GitHub Actions job using the repository's `.node-version`. + +- [ ] **Step 1: Verify the focused test is not currently referenced by CI** + +Run: + +```bash +rg -n "frontend-js-size.test|frontend-bundle-report-test" .github/workflows +``` + +Expected: no matches. + +- [ ] **Step 2: Add report-script paths to both lint workflow filters** + +Under both `on.push.paths` and `on.pull_request.paths` in `.github/workflows/lint.yml`, add: + +```yaml + - .github/scripts/frontend-js-size*.mts + - .github/scripts/utility.mts +``` + +Keep `.github/workflows/lint.yml` itself in both filters. + +- [ ] **Step 3: Add a focused CI job** + +Append this job under `jobs` at the same level as `check-dts`: + +```yaml + 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 +``` + +This job intentionally does not depend on `pnpm_install` because the test and report generator use only Node built-ins and the local `utility.mts` module. + +- [ ] **Step 4: Run the exact CI test command locally** + +Run: + +```bash +node --test .github/scripts/frontend-js-size.test.mts +``` + +Expected: PASS with `3` tests and `0` failures. + +- [ ] **Step 5: Confirm CI now references the test exactly once** + +Run: + +```bash +rg -n "node --test \.github/scripts/frontend-js-size\.test\.mts" .github/workflows/lint.yml +``` + +Expected: one match in the `frontend-bundle-report-test` job. + +- [ ] **Step 6: Commit the CI coverage** + +```bash +git add .github/workflows/lint.yml +git commit -m "test(dev): check frontend bundle report" +``` + +--- + +### Task 4: Complete Misskey pre-ship validation + +**Files:** +- Verify: `.github/scripts/frontend-js-size.mts` +- Verify: `.github/scripts/frontend-js-size.test.mts` +- Verify: `.github/workflows/lint.yml` +- Verify: `docs/superpowers/specs/2026-07-15-frontend-bundle-generated-chunks-design.md` + +**Interfaces:** +- Consumes: all implementation and CI changes from Tasks 1-3. +- Produces: fresh evidence that focused behavior, repository lint, SPDX, locale safety, and final diff checks pass. + +- [ ] **Step 1: Run the focused regression tests** + +Run: + +```bash +node --test .github/scripts/frontend-js-size.test.mts +``` + +Expected: PASS with `3` tests and `0` failures. + +- [ ] **Step 2: Run repository lint** + +Run: + +```bash +pnpm lint +``` + +Expected: exit code `0`. If an unrelated pre-existing failure occurs, record its exact command and output rather than claiming lint passed. + +- [ ] **Step 3: Check the new file's SPDX header** + +Run: + +```bash +rg -n "SPDX-FileCopyrightText: syuilo and misskey-project|SPDX-License-Identifier: AGPL-3.0-only" .github/scripts/frontend-js-size.test.mts +``` + +Expected: two matches, one for each required SPDX line. + +- [ ] **Step 4: Verify locale safety and unchanged migration/API surfaces** + +Run: + +```bash +git diff --name-only develop -- locales +git diff --name-only develop -- packages/backend/migration packages/backend/src packages/misskey-js/src/autogen +``` + +Expected: both commands produce no output. Therefore locale generation, migration checking, backend API review, and misskey-js regeneration are not applicable. + +- [ ] **Step 5: Verify the final diff and worktree** + +Run: + +```bash +git diff --check develop...HEAD +git status --short +git log --oneline develop..HEAD +``` + +Expected: `git diff --check` exits `0`, `git status --short` is empty, and the log contains the approved design commit plus the three implementation commits from this plan. + +- [ ] **Step 6: Review the final report behavior against the approved design** + +Confirm all of these from the focused test output and diff: + +```text +[x] Physical chunks are counted once by output path. +[x] Only src-backed, vue, and i18n chunks are individually compared. +[x] Generated chunks are aggregated in full and startup reports. +[x] Duplicate stable keys fail instead of overwriting. +[x] Updated stable rows show before and after filenames. +[x] No locale, migration, backend API, frontend Vue, or user-facing behavior changed. +[x] No CHANGELOG entry is needed. +``` From b6996a2766d0b853bbef7014bc64281855710b96 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:00:39 +0900 Subject: [PATCH 053/168] chore: ignore local worktrees --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index e975691b93..e1fdaa271a 100644 --- a/.gitignore +++ b/.gitignore @@ -83,3 +83,6 @@ vite.config.local-dev.ts.timestamp-* # Affinity *.af~lock~ + +# Local Git worktrees +/.worktrees/ From 945fd405786d8cbed98c69b5c5693e81d97d6795 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:15:17 +0900 Subject: [PATCH 054/168] fix(dev): aggregate generated frontend chunks --- .github/scripts/frontend-js-size.mts | 202 +++++++++++++++------- .github/scripts/frontend-js-size.test.mts | 85 +++++++++ 2 files changed, 226 insertions(+), 61 deletions(-) create mode 100644 .github/scripts/frontend-js-size.test.mts diff --git a/.github/scripts/frontend-js-size.mts b/.github/scripts/frontend-js-size.mts index 4d6dd0a0bc..b4a61e659a 100644 --- a/.github/scripts/frontend-js-size.mts +++ b/.github/scripts/frontend-js-size.mts @@ -40,13 +40,24 @@ function escapeCell(value: string) { type Manifest = Record; +const stableNamedChunks = new Set(['vue', 'i18n']); + type FileEntry = { - key: string; + comparisonKey: string | null; displayName: string; file: string; + manifestKeys: string[]; size: number; }; +type CollectedReport = { + manifest: Manifest; + chunks: FileEntry[]; + comparableChunks: Record; + chunksByManifestKey: Record; + startupFiles: string[]; +}; + function entryDisplayName(entry: FileEntry) { if (!entry) return ''; return entry.displayName || entry.file; @@ -60,11 +71,13 @@ function findEntryKey(manifest: Manifest) { ?? null; } -function stableChunkKey(manifestKey: string, chunk: Manifest[string]) { - return chunk.src ?? (chunk.name ? `chunk:${chunk.name}` : manifestKey); +function stableChunkKey(chunk: Manifest[string]) { + if (chunk.src != null) return `src:${util.normalizePath(chunk.src)}`; + if (chunk.name != null && stableNamedChunks.has(chunk.name)) return `named:${chunk.name}`; + return null; } -function collectStartupKeys(manifest: Manifest) { +function collectStartupManifestKeys(manifest: Manifest) { const entryKey = findEntryKey(manifest); const keys = new Set(); if (entryKey == null) return keys; @@ -73,10 +86,8 @@ function collectStartupKeys(manifest: Manifest) { if (keys.has(key)) return; const chunk = manifest[key]; if (!chunk || !chunk.file?.endsWith('.js')) return; - keys.add(stableChunkKey(key, chunk)); - for (const importKey of chunk.imports ?? []) { - visit(importKey); - } + keys.add(key); + for (const importKey of chunk.imports ?? []) visit(importKey); } visit(entryKey); @@ -102,26 +113,41 @@ async function resolveBuiltFile(outDir: string, file: string) { }; } -async function collectReport(repoDir: string) { +async function collectReport(repoDir: string): Promise { const outDir = path.join(repoDir, 'built/_frontend_vite_'); const manifestPath = path.join(outDir, 'manifest.json'); const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as Manifest; - const byKey = new Map(); - const byFile = new Set(); + const chunksByFile = new Map(); + const comparableChunks = new Map(); + const chunksByManifestKey = new Map(); - for (const [key, chunk] of Object.entries(manifest)) { + for (const [manifestKey, chunk] of Object.entries(manifest)) { if (!chunk.file?.endsWith('.js')) continue; const builtFile = await resolveBuiltFile(outDir, chunk.file); - const size = await util.fileSize(builtFile.absolutePath); - const stableKey = stableChunkKey(key, chunk); - const displayName = chunk.src ?? chunk.name ?? key; - byKey.set(stableKey, { - key: stableKey, - displayName, - file: builtFile.relativePath, - size, - }); - byFile.add(builtFile.relativePath); + const comparisonKey = stableChunkKey(chunk); + let entry = chunksByFile.get(builtFile.relativePath); + if (entry == null) { + entry = { + comparisonKey, + displayName: chunk.src ?? chunk.name ?? manifestKey, + file: builtFile.relativePath, + manifestKeys: [manifestKey], + size: await util.fileSize(builtFile.absolutePath), + }; + chunksByFile.set(entry.file, entry); + } else if (entry.comparisonKey !== comparisonKey) { + throw new Error(`Conflicting identities for ${entry.file}`); + } else { + entry.manifestKeys.push(manifestKey); + } + chunksByManifestKey.set(manifestKey, entry); + if (comparisonKey != null) { + const existing = comparableChunks.get(comparisonKey); + if (existing != null && existing.file !== entry.file) { + throw new Error(`Duplicate stable chunk key "${comparisonKey}": ${existing.file}, ${entry.file}`); + } + comparableChunks.set(comparisonKey, entry); + } } const localeDir = path.join(outDir, locale); @@ -129,21 +155,29 @@ async function collectReport(repoDir: string) { for await (const fullPath of util.traverseDirectory(localeDir)) { if (!fullPath.endsWith('.js')) continue; const relativePath = util.normalizePath(path.relative(outDir, fullPath)); - if (byFile.has(relativePath)) continue; - const size = await util.fileSize(fullPath); - byKey.set(relativePath, { - key: relativePath, + if (chunksByFile.has(relativePath)) continue; + chunksByFile.set(relativePath, { + comparisonKey: null, displayName: relativePath, file: relativePath, - size, + manifestKeys: [], + size: await util.fileSize(fullPath), }); } } + const startupFiles = new Set(); + for (const manifestKey of collectStartupManifestKeys(manifest)) { + const entry = chunksByManifestKey.get(manifestKey); + if (entry != null) startupFiles.add(entry.file); + } + return { manifest, - chunks: Object.fromEntries(byKey), - startupKeys: [...collectStartupKeys(manifest)], + chunks: [...chunksByFile.values()], + comparableChunks: Object.fromEntries(comparableChunks), + chunksByManifestKey: Object.fromEntries(chunksByManifestKey), + startupFiles: [...startupFiles], }; } @@ -312,16 +346,17 @@ function renderVisualizerSummaryTable(before: ReturnType>, after: Awaited>) { - return keys.map((key) => { - const beforeEntry = before.chunks[key]; - const afterEntry = after.chunks[key]; +function getChunkComparisonRows(keys: string[], before: Record, after: Record) { + return keys.map(key => { + const beforeEntry = before[key]; + const afterEntry = after[key]; const beforeSize = beforeEntry?.size ?? 0; const afterSize = afterEntry?.size ?? 0; return { key, name: entryDisplayName(beforeEntry ?? afterEntry), - chunkFile: beforeEntry?.file ?? afterEntry?.file, + beforeFile: beforeEntry?.file, + afterFile: afterEntry?.file, beforeSize, afterSize, changeType: beforeEntry == null ? 'added' : afterEntry == null ? 'removed' : beforeSize !== afterSize ? 'updated' : 'unchanged', @@ -330,6 +365,36 @@ function getChunkComparisonRows(keys: string[], before: Awaited sum + chunk.size, 0); +} + +function generatedAggregate(before: FileEntry[], after: FileEntry[]): ChunkAggregate { + const beforeGenerated = before.filter(chunk => chunk.comparisonKey == null); + const afterGenerated = after.filter(chunk => chunk.comparisonKey == null); + return { + beforeSize: sumChunkSizes(beforeGenerated), + afterSize: sumChunkSizes(afterGenerated), + beforeCount: beforeGenerated.length, + afterCount: afterGenerated.length, + }; +} + +function comparableMap(chunks: FileEntry[]) { + const entries: [string, FileEntry][] = []; + for (const chunk of chunks) { + if (chunk.comparisonKey != null) entries.push([chunk.comparisonKey, chunk]); + } + return Object.fromEntries(entries); +} + function summarizeChunkChanges(rows: ReturnType) { return { updated: rows.filter((row) => row.changeType === 'updated').length, @@ -349,60 +414,75 @@ function compareChunkComparisonRows(a: ReturnType || a.name.localeCompare(b.name); } -function chunkMarkdownTable(rows: ReturnType, total?: { beforeSize: number; afterSize: number }) { - if (rows.length === 0) return '_No data_'; +function chunkMarkdownTable( + rows: ReturnType, + total?: { beforeSize: number; afterSize: number }, + generated?: ChunkAggregate, +) { + if (rows.length === 0 && total == null && generated == null) return '_No data_'; const lines = [ '| Chunk | Before | After | Δ | Δ (%) |', '| --- | ---: | ---: | ---: | ---: |', ]; + let hasSummaryRow = false; if (total != null) { lines.push(`| (total) | ${util.formatBytes(total.beforeSize)} | ${util.formatBytes(total.afterSize)} | ${util.calcAndFormatDeltaBytes(total.beforeSize, total.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(total.beforeSize, total.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`); - lines.push('| | | | | |'); + hasSummaryRow = true; } + if (generated != null && (generated.beforeCount > 0 || generated.afterCount > 0)) { + lines.push(`| (other generated chunks) | ${util.formatBytes(generated.beforeSize)} | ${util.formatBytes(generated.afterSize)} | ${util.calcAndFormatDeltaBytes(generated.beforeSize, generated.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(generated.beforeSize, generated.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`); + hasSummaryRow = true; + } + if (hasSummaryRow && rows.length > 0) lines.push('| | | | | |'); + for (const row of rows) { + const chunkFile = row.beforeFile ?? row.afterFile ?? ''; if (row.changeType === 'added') { - lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(row.chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{orange}{\\text{( + )}}$ |`); + lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{orange}{\\text{( + )}}$ |`); } else if (row.changeType === 'removed') { - lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(row.chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{green}{\\text{( - )}}$ |`); + lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{green}{\\text{( - )}}$ |`); } else { - lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(row.chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(row.beforeSize, row.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`); + lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(row.beforeSize, row.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`); } } + if (generated != null && (generated.beforeCount > 0 || generated.afterCount > 0)) { + lines.push(''); + lines.push(`_${generated.beforeCount} before / ${generated.afterCount} after generated chunks are grouped._`); + } return lines.join('\n'); } function renderFrontendChunkReport(before: Awaited>, after: Awaited>) { - const commonChunkKeys = Object.keys(before.chunks).filter((key) => after.chunks[key] != null); - const addedChunkKeys = Object.keys(after.chunks).filter((key) => before.chunks[key] == null); - const removedChunkKeys = Object.keys(before.chunks).filter((key) => after.chunks[key] == null); - const allChunkKeys = [ - ...commonChunkKeys, - ...addedChunkKeys, - ...removedChunkKeys, - ]; - //const comparisonRows = getChunkComparisonRows(commonChunkKeys, before, after); - const allComparisonRows = getChunkComparisonRows(allChunkKeys, before, after); + const beforeComparable = before.comparableChunks; + const afterComparable = after.comparableChunks; + const allChunkKeys = [...new Set([...Object.keys(beforeComparable), ...Object.keys(afterComparable)])]; + const allComparisonRows = getChunkComparisonRows(allChunkKeys, beforeComparable, afterComparable); const changedRows = allComparisonRows.filter((row) => row.changeType !== 'unchanged'); const diffSummary = summarizeChunkChanges(changedRows); const diffTotal = { - beforeSize: allComparisonRows.reduce((sum, row) => sum + row.beforeSize, 0), - afterSize: allComparisonRows.reduce((sum, row) => sum + row.afterSize, 0), + beforeSize: sumChunkSizes(before.chunks), + afterSize: sumChunkSizes(after.chunks), }; + const diffGenerated = generatedAggregate(before.chunks, after.chunks); const diffRows = changedRows.sort(compareChunkComparisonRows).slice(0, 30); // TODO: 実際に30を超えて切り捨てられたrowがあった場合はその旨をmarkdown内に表示するようにする - const startupKeys = new Set([ - ...before.startupKeys, - ...after.startupKeys, - ]); - const startupComparisonRows = getChunkComparisonRows([...startupKeys], before, after); + const beforeStartupFiles = new Set(before.startupFiles); + const afterStartupFiles = new Set(after.startupFiles); + const beforeStartupChunks = before.chunks.filter(chunk => beforeStartupFiles.has(chunk.file)); + const afterStartupChunks = after.chunks.filter(chunk => afterStartupFiles.has(chunk.file)); + const beforeStartupComparable = comparableMap(beforeStartupChunks); + const afterStartupComparable = comparableMap(afterStartupChunks); + const startupKeys = [...new Set([...Object.keys(beforeStartupComparable), ...Object.keys(afterStartupComparable)])]; + const startupComparisonRows = getChunkComparisonRows(startupKeys, beforeStartupComparable, afterStartupComparable); const startupRows = startupComparisonRows.sort(compareChunkComparisonRows); const startupSummary = summarizeChunkChanges(startupComparisonRows); const startupTotal = { - beforeSize: startupComparisonRows.reduce((sum, row) => sum + row.beforeSize, 0), - afterSize: startupComparisonRows.reduce((sum, row) => sum + row.afterSize, 0), + beforeSize: sumChunkSizes(beforeStartupChunks), + afterSize: sumChunkSizes(afterStartupChunks), }; + const startupGenerated = generatedAggregate(beforeStartupChunks, afterStartupChunks); //const largeRows = comparisonRows // .sort((a, b) => b.sortSize - a.sortSize || a.name.localeCompare(b.name)) @@ -412,14 +492,14 @@ function renderFrontendChunkReport(before: Awaited', `${formatChunkChangeSummary('Chunk size diff', diffSummary)}`, '', - chunkMarkdownTable(diffRows, diffTotal), + chunkMarkdownTable(diffRows, diffTotal, diffGenerated), '', '', '', '
', `${formatChunkChangeSummary('Startup chunk size', startupSummary)}`, '', - chunkMarkdownTable(startupRows, startupTotal), + chunkMarkdownTable(startupRows, startupTotal, startupGenerated), '', `_Startup chunks are the Vite entry for \`src/_boot_.ts\` and its static imports._`, '', diff --git a/.github/scripts/frontend-js-size.test.mts b/.github/scripts/frontend-js-size.test.mts new file mode 100644 index 0000000000..51b71c075f --- /dev/null +++ b/.github/scripts/frontend-js-size.test.mts @@ -0,0 +1,85 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import assert from 'node:assert/strict'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test, { type TestContext } from 'node:test'; +import * as util from './utility.mts'; + +type Manifest = Record; + +const repoDir = path.resolve(import.meta.dirname, '../..'); +const reportScript = path.join(repoDir, '.github/scripts/frontend-js-size.mts'); + +async function writeBuild(repo: string, manifest: Manifest, sizes: Record) { + const outDir = path.join(repo, 'built/_frontend_vite_/'); + await fs.mkdir(path.join(outDir, 'ja-JP'), { recursive: true }); + await fs.writeFile(path.join(outDir, 'manifest.json'), JSON.stringify(manifest)); + for (const [file, size] of Object.entries(sizes)) { + const localizedFile = file.replace(/^scripts\//, ''); + const outputFile = path.join(outDir, 'ja-JP', localizedFile); + await fs.mkdir(path.dirname(outputFile), { recursive: true }); + await fs.writeFile(outputFile, Buffer.alloc(size)); + } +} + +async function runReport(t: TestContext, before: { manifest: Manifest; sizes: Record }, after: { manifest: Manifest; sizes: Record }) { + 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, '{}'); + await util.run(process.execPath, [reportScript, beforeDir, afterDir, beforeStats, afterStats, reportFile]); + 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: 45, i18n: 50 }), + ); + + assert.match(report, /\| \(total\) \| 220 B \| 275 B \|/); + assert.equal(report.match(/\| \(other generated chunks\) \| 30 B \| 70 B \|/g)?.length, 2); + assert.equal(report.match(/_2 before \/ 2 after generated chunks are grouped\._/g)?.length, 2); + assert.doesNotMatch(report, /`(?:dist|esm)`<\/summary>/); + assert.match(report, /`src\/_boot_\.ts`<\/summary>/); + assert.match(report, /`vue`<\/summary>/); +}); From afca066833c5844e24cfe3b6eb0d1c97d216facf Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:22:28 +0900 Subject: [PATCH 055/168] fix(dev): clarify frontend chunk comparisons --- .github/scripts/frontend-js-size.mts | 8 +++++++- .github/scripts/frontend-js-size.test.mts | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/.github/scripts/frontend-js-size.mts b/.github/scripts/frontend-js-size.mts index b4a61e659a..38c6fa07e9 100644 --- a/.github/scripts/frontend-js-size.mts +++ b/.github/scripts/frontend-js-size.mts @@ -414,6 +414,12 @@ function compareChunkComparisonRows(a: ReturnType || a.name.localeCompare(b.name); } +function chunkFileDisplay(row: ReturnType[number]) { + if (row.beforeFile == null) return row.afterFile ?? ''; + if (row.afterFile == null || row.beforeFile === row.afterFile) return row.beforeFile; + return `${row.beforeFile} → ${row.afterFile}`; +} + function chunkMarkdownTable( rows: ReturnType, total?: { beforeSize: number; afterSize: number }, @@ -437,7 +443,7 @@ function chunkMarkdownTable( if (hasSummaryRow && rows.length > 0) lines.push('| | | | | |'); for (const row of rows) { - const chunkFile = row.beforeFile ?? row.afterFile ?? ''; + const chunkFile = chunkFileDisplay(row); if (row.changeType === 'added') { lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{orange}{\\text{( + )}}$ |`); } else if (row.changeType === 'removed') { diff --git a/.github/scripts/frontend-js-size.test.mts b/.github/scripts/frontend-js-size.test.mts index 51b71c075f..3688948e94 100644 --- a/.github/scripts/frontend-js-size.test.mts +++ b/.github/scripts/frontend-js-size.test.mts @@ -83,3 +83,25 @@ test('groups generated chunks while preserving full and startup totals', async t assert.match(report, /`src\/_boot_\.ts`<\/summary>/); assert.match(report, /`vue`<\/summary>/); }); + +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; + + await assert.rejects( + runReport(t, duplicateVue, fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 })), + /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: 45, 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`/); +}); From 11c6fb1567a31e3eadb81879fb1f735a9465c652 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:29:01 +0900 Subject: [PATCH 056/168] test(dev): capture expected frontend report failures --- .github/scripts/frontend-js-size.test.mts | 26 ++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/.github/scripts/frontend-js-size.test.mts b/.github/scripts/frontend-js-size.test.mts index 3688948e94..d89fa153ba 100644 --- a/.github/scripts/frontend-js-size.test.mts +++ b/.github/scripts/frontend-js-size.test.mts @@ -4,6 +4,7 @@ */ 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'; @@ -33,7 +34,7 @@ async function writeBuild(repo: string, manifest: Manifest, sizes: Record }, after: { manifest: Manifest; sizes: Record }) { +async function runReport(t: TestContext, before: { manifest: Manifest; sizes: Record }, after: { manifest: Manifest; sizes: Record }, expectFailure = false) { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'frontend-js-size-')); t.after(() => fs.rm(root, { recursive: true, force: true })); const beforeDir = path.join(root, 'before'); @@ -45,7 +46,19 @@ async function runReport(t: TestContext, before: { manifest: Manifest; sizes: Re await writeBuild(afterDir, after.manifest, after.sizes); await fs.writeFile(beforeStats, '{}'); await fs.writeFile(afterStats, '{}'); - await util.run(process.execPath, [reportScript, beforeDir, afterDir, beforeStats, afterStats, reportFile]); + const args = [reportScript, beforeDir, afterDir, beforeStats, afterStats, reportFile]; + if (expectFailure) { + return new Promise((resolve, reject) => { + execFile(process.execPath, args, (error, _stdout, stderr) => { + if (error == null) { + reject(new Error('Expected frontend report script to fail')); + } else { + resolve(stderr); + } + }); + }); + } + await util.run(process.execPath, args); return fs.readFile(reportFile, 'utf8'); } @@ -89,10 +102,13 @@ test('fails instead of overwriting duplicate stable chunk keys', async t => { duplicateVue.manifest._vueDuplicate = { file: 'scripts/vue-duplicate-before.js', name: 'vue' }; duplicateVue.sizes['scripts/vue-duplicate-before.js'] = 60; - await assert.rejects( - runReport(t, duplicateVue, fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 })), - /Duplicate stable chunk key "named:vue".*vue-before\.js.*vue-duplicate-before\.js/, + 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 => { From 0ad72528da904e48a0da00556bfffead87c66b92 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:34:13 +0900 Subject: [PATCH 057/168] test(dev): check frontend bundle report --- .github/workflows/lint.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 63ae5c7397..d3c745aea6 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -18,6 +18,8 @@ on: - packages/misskey-reversi/** - packages/shared/eslint.config.js - scripts/check-dts*.mjs + - .github/scripts/frontend-js-size*.mts + - .github/scripts/utility.mts - .github/workflows/lint.yml - package.json pull_request: @@ -34,6 +36,8 @@ on: - packages/misskey-reversi/** - packages/shared/eslint.config.js - scripts/check-dts*.mjs + - .github/scripts/frontend-js-size*.mts + - .github/scripts/utility.mts - .github/workflows/lint.yml - package.json jobs: @@ -136,3 +140,12 @@ 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 From ef852e6ab0351f4c9ed0d7e8c33e8cd9098536e2 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:55:12 +0900 Subject: [PATCH 058/168] fix(dev): validate frontend startup manifest --- .github/scripts/frontend-js-size.mts | 11 +- .github/scripts/frontend-js-size.test.mts | 112 ++- .gitignore | 3 - ...-07-15-frontend-bundle-generated-chunks.md | 661 ------------------ 4 files changed, 118 insertions(+), 669 deletions(-) delete mode 100644 docs/superpowers/plans/2026-07-15-frontend-bundle-generated-chunks.md diff --git a/.github/scripts/frontend-js-size.mts b/.github/scripts/frontend-js-size.mts index 38c6fa07e9..04a92679c7 100644 --- a/.github/scripts/frontend-js-size.mts +++ b/.github/scripts/frontend-js-size.mts @@ -80,14 +80,17 @@ function stableChunkKey(chunk: Manifest[string]) { function collectStartupManifestKeys(manifest: Manifest) { const entryKey = findEntryKey(manifest); const keys = new Set(); - if (entryKey == null) return keys; + if (entryKey == null) throw new Error('Unable to find frontend startup entry in Vite manifest.'); - function visit(key: string) { + function visit(key: string, importedBy?: string) { if (keys.has(key)) return; const chunk = manifest[key]; - if (!chunk || !chunk.file?.endsWith('.js')) return; + const importContext = importedBy == null ? '' : ` imported by "${importedBy}"`; + if (chunk == null) throw new Error(`Startup manifest key "${key}"${importContext} is missing.`); + if (chunk.file == null || chunk.file.length === 0) throw new Error(`Startup manifest key "${key}"${importContext} has no output file.`); + if (!chunk.file.endsWith('.js')) throw new Error(`Startup manifest key "${key}"${importContext} resolves to non-JavaScript output "${chunk.file}".`); keys.add(key); - for (const importKey of chunk.imports ?? []) visit(importKey); + for (const importKey of chunk.imports ?? []) visit(importKey, key); } visit(entryKey); diff --git a/.github/scripts/frontend-js-size.test.mts b/.github/scripts/frontend-js-size.test.mts index d89fa153ba..347d21548c 100644 --- a/.github/scripts/frontend-js-size.test.mts +++ b/.github/scripts/frontend-js-size.test.mts @@ -12,7 +12,7 @@ import test, { type TestContext } from 'node:test'; import * as util from './utility.mts'; type Manifest = Record { assert.match(report, /`ja-JP\/entry-before\.js → ja-JP\/entry-after\.js`/); assert.match(report, /`ja-JP\/vue-before\.js → ja-JP\/vue-after\.js`/); }); + +test('fails descriptively when the startup entry is missing', async t => { + const before = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }); + delete before.manifest['src/_boot_.ts']; + delete before.sizes['scripts/entry-before.js']; + + const diagnostic = await runReport( + t, + before, + fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }), + true, + ); + assert.match(diagnostic, /Unable to find frontend startup entry in Vite manifest/); +}); + +test('fails descriptively when a static import is missing from the manifest', async t => { + const before = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }); + before.manifest['src/_boot_.ts'].imports = ['_missing']; + + const diagnostic = await runReport( + t, + before, + fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }), + true, + ); + assert.match(diagnostic, /Startup manifest key "_missing".*is missing/); +}); + +test('fails descriptively when a static import has no output file', async t => { + const before = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }); + before.manifest['src/_boot_.ts'].imports = ['_malformed']; + before.manifest._malformed = { name: 'malformed' }; + + const diagnostic = await runReport( + t, + before, + fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }), + true, + ); + assert.match(diagnostic, /Startup manifest key "_malformed".*has no output file/); +}); + +test('fails descriptively when a static import resolves to a non-JavaScript output', async t => { + const before = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }); + before.manifest['src/_boot_.ts'].imports = ['_malformed']; + before.manifest._malformed = { file: 'assets/malformed.css', name: 'malformed' }; + + const diagnostic = await runReport( + t, + before, + fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }), + true, + ); + assert.match(diagnostic, /Startup manifest key "_malformed".*non-JavaScript output "assets\/malformed\.css"/); +}); + +test('keeps source-backed additions and removals as individual rows', async t => { + const before = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }); + before.manifest._removed = { file: 'scripts/removed-before.js', src: 'src/removed.ts' }; + before.sizes['scripts/removed-before.js'] = 12; + const after = fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }); + after.manifest._added = { file: 'scripts/added-after.js', src: 'src/added.ts' }; + after.sizes['scripts/added-after.js'] = 13; + + const report = await runReport(t, before, after); + + assert.match(report, /`src\/added\.ts`<\/summary> `ja-JP\/added-after\.js` <\/details> \| 0 B \| 13 B \|/); + assert.match(report, /`src\/removed\.ts`<\/summary> `ja-JP\/removed-before\.js` <\/details> \| 12 B \| 0 B \|/); +}); + +test('counts an unmanifested localized JavaScript file in totals and the generated aggregate', async t => { + const before = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }); + before.sizes['scripts/unmanifested-before.js'] = 15; + + const report = await runReport( + t, + before, + fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }), + ); + + assert.match(report, /\| \(total\) \| 235 B \| 275 B \|/); + assert.match(report, /\| \(other generated chunks\) \| 45 B \| 70 B \|/); + assert.match(report, /_3 before \/ 2 after generated chunks are grouped\._/); +}); + +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 \|/); + assert.match(report, /_2 before \/ 2 after generated chunks are grouped\._/); +}); + +test('does not count generated-aggregate-only changes as individual chunk changes', async t => { + const report = await runReport( + t, + fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }), + fixture('after', 'esm', { entry: 100, generatedA: 30, generatedB: 40, vue: 40, i18n: 50 }), + ); + + assert.match(report, /Chunk size diff \(0 updated, 0 added, 0 removed\)<\/summary>/); + assert.match(report, /Startup chunk size \(0 updated, 0 added, 0 removed\)<\/summary>/); + assert.equal(report.match(/\| \(other generated chunks\) \| 30 B \| 70 B \|/g)?.length, 2); +}); diff --git a/.gitignore b/.gitignore index e1fdaa271a..e975691b93 100644 --- a/.gitignore +++ b/.gitignore @@ -83,6 +83,3 @@ vite.config.local-dev.ts.timestamp-* # Affinity *.af~lock~ - -# Local Git worktrees -/.worktrees/ diff --git a/docs/superpowers/plans/2026-07-15-frontend-bundle-generated-chunks.md b/docs/superpowers/plans/2026-07-15-frontend-bundle-generated-chunks.md deleted file mode 100644 index 6a58d5e322..0000000000 --- a/docs/superpowers/plans/2026-07-15-frontend-bundle-generated-chunks.md +++ /dev/null @@ -1,661 +0,0 @@ -# Frontend Bundle Generated Chunks Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Prevent generated `esm`/`dist`-style chunks from being compared individually while retaining every JavaScript output file in full and startup totals. - -**Architecture:** Keep physical chunk accounting separate from stable before/after identities. Source-backed chunks and the explicit `vue`/`i18n` code-splitting groups remain individually comparable; all other generated chunks are grouped into one aggregate row. Exercise the report generator through filesystem fixtures so tests cover manifest parsing, localized file resolution, startup traversal, and Markdown rendering together. - -**Tech Stack:** Node.js 26.4, TypeScript `.mts` scripts with native type stripping, Node test runner, Vite manifest JSON, GitHub Actions. - -## Global Constraints - -- Never compare unrelated generated chunks as the same chunk. -- Keep generated chunks out of the individual chunk-diff rows. -- Preserve the contribution of every physical JavaScript chunk in totals. -- Show the aggregate size change of generated chunks so unexplained bundle growth remains visible. -- Apply the same rules to the full chunk report and the startup chunk report. -- Continue comparing source-backed chunks and intentionally named chunks. -- The only stable generated-name allowlist entries are `vue` and `i18n`. -- Do not infer chunk identity from module-set similarity. -- Do not change the frontend code-splitting strategy or output filenames. -- Do not suppress generated chunks with a name denylist. -- Do not manually edit locale files other than `locales/ja-JP.yml`; this change does not require any locale edit. -- New `.mts` files must include the repository SPDX header. -- This developer-facing report change does not require a `CHANGELOG.md` entry. - -## File Structure - -- Create `.github/scripts/frontend-js-size.test.mts`: end-to-end fixtures for manifest collection and Markdown output. -- Modify `.github/scripts/frontend-js-size.mts`: physical chunk collection, stable identity classification, generated aggregation, startup accounting, duplicate-key validation, and filename rendering. -- Modify `.github/workflows/lint.yml`: run the focused Node test whenever the report script or its utility changes. -- Reference `docs/superpowers/specs/2026-07-15-frontend-bundle-generated-chunks-design.md`: approved behavior and non-goals; no implementation edit is required. - ---- - -### Task 1: Preserve all physical chunks and aggregate generated chunks - -**Files:** -- Create: `.github/scripts/frontend-js-size.test.mts` -- Modify: `.github/scripts/frontend-js-size.mts:39-134` -- Modify: `.github/scripts/frontend-js-size.mts:315-426` - -**Interfaces:** -- Consumes: Vite `manifest.json`, localized JavaScript files under `built/_frontend_vite_/ja-JP`, and the existing five CLI arguments of `frontend-js-size.mts`. -- Produces: `CollectedReport` with `chunks: FileEntry[]`, `comparableChunks: Record`, `chunksByManifestKey: Record`, and `startupFiles: string[]`; Markdown with `(total)` and `(other generated chunks)` rows. - -- [ ] **Step 1: Write the failing end-to-end test** - -Create `.github/scripts/frontend-js-size.test.mts` with this initial content: - -```ts -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import assert from 'node:assert/strict'; -import { promises as fs } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import test, { type TestContext } from 'node:test'; -import * as util from './utility.mts'; - -type Manifest = Record; - -const repoDir = path.resolve(import.meta.dirname, '../..'); -const reportScript = path.join(repoDir, '.github/scripts/frontend-js-size.mts'); - -async function writeBuild(repo: string, manifest: Manifest, sizes: Record) { - const outDir = path.join(repo, 'built/_frontend_vite_'); - await fs.mkdir(path.join(outDir, 'ja-JP'), { recursive: true }); - await fs.writeFile(path.join(outDir, 'manifest.json'), JSON.stringify(manifest)); - for (const [file, size] of Object.entries(sizes)) { - const localizedFile = file.replace(/^scripts\//, ''); - const outputFile = path.join(outDir, 'ja-JP', localizedFile); - await fs.mkdir(path.dirname(outputFile), { recursive: true }); - await fs.writeFile(outputFile, Buffer.alloc(size)); - } -} - -async function runReport(t: TestContext, before: { manifest: Manifest; sizes: Record }, after: { manifest: Manifest; sizes: Record }) { - 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, '{}'); - await util.run(process.execPath, [reportScript, beforeDir, afterDir, beforeStats, afterStats, reportFile]); - 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: 45, i18n: 50 }), - ); - - assert.match(report, /\| \(total\) \| 220 B \| 275 B \|/); - assert.equal(report.match(/\| \(other generated chunks\) \| 30 B \| 70 B \|/g)?.length, 2); - assert.equal(report.match(/_2 before \/ 2 after generated chunks are grouped\._/g)?.length, 2); - assert.doesNotMatch(report, /`(?:dist|esm)`<\/summary>/); - assert.match(report, /`src\/_boot_\.ts`<\/summary>/); - assert.match(report, /`vue`<\/summary>/); -}); -``` - -- [ ] **Step 2: Run the test and verify the current implementation fails** - -Run: - -```bash -node --test .github/scripts/frontend-js-size.test.mts -``` - -Expected: FAIL because `(total)` omits one of the duplicate generated-name chunks, no `(other generated chunks)` row exists, and an individual `dist` or `esm` row is rendered. - -- [ ] **Step 3: Replace name-based identity with physical accounting and stable classification** - -In `.github/scripts/frontend-js-size.mts`, replace `FileEntry`, `stableChunkKey`, `collectStartupKeys`, and `collectReport` with these structures and functions while retaining the existing `resolveBuiltFile` function: - -```ts -const stableNamedChunks = new Set(['vue', 'i18n']); - -type FileEntry = { - comparisonKey: string | null; - displayName: string; - file: string; - manifestKeys: string[]; - size: number; -}; - -type CollectedReport = { - manifest: Manifest; - chunks: FileEntry[]; - comparableChunks: Record; - chunksByManifestKey: Record; - startupFiles: string[]; -}; - -function stableChunkKey(chunk: Manifest[string]) { - if (chunk.src != null) return `src:${util.normalizePath(chunk.src)}`; - if (chunk.name != null && stableNamedChunks.has(chunk.name)) return `named:${chunk.name}`; - return null; -} - -function collectStartupManifestKeys(manifest: Manifest) { - const entryKey = findEntryKey(manifest); - const keys = new Set(); - if (entryKey == null) return keys; - - function visit(key: string) { - if (keys.has(key)) return; - const chunk = manifest[key]; - if (!chunk || !chunk.file?.endsWith('.js')) return; - keys.add(key); - for (const importKey of chunk.imports ?? []) visit(importKey); - } - - visit(entryKey); - return keys; -} - -async function collectReport(repoDir: string): Promise { - const outDir = path.join(repoDir, 'built/_frontend_vite_'); - const manifestPath = path.join(outDir, 'manifest.json'); - const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as Manifest; - const chunksByFile = new Map(); - const comparableChunks = new Map(); - const chunksByManifestKey = new Map(); - - for (const [manifestKey, chunk] of Object.entries(manifest)) { - if (!chunk.file?.endsWith('.js')) continue; - const builtFile = await resolveBuiltFile(outDir, chunk.file); - const comparisonKey = stableChunkKey(chunk); - let entry = chunksByFile.get(builtFile.relativePath); - if (entry == null) { - entry = { - comparisonKey, - displayName: chunk.src ?? chunk.name ?? manifestKey, - file: builtFile.relativePath, - manifestKeys: [manifestKey], - size: await util.fileSize(builtFile.absolutePath), - }; - chunksByFile.set(entry.file, entry); - } else if (entry.comparisonKey !== comparisonKey) { - throw new Error(`Conflicting identities for ${entry.file}`); - } else { - entry.manifestKeys.push(manifestKey); - } - chunksByManifestKey.set(manifestKey, entry); - if (comparisonKey != null) { - const existing = comparableChunks.get(comparisonKey); - if (existing != null && existing.file !== entry.file) { - throw new Error(`Duplicate stable chunk key "${comparisonKey}": ${existing.file}, ${entry.file}`); - } - comparableChunks.set(comparisonKey, entry); - } - } - - const localeDir = path.join(outDir, locale); - if (await util.fileExists(localeDir)) { - for await (const fullPath of util.traverseDirectory(localeDir)) { - if (!fullPath.endsWith('.js')) continue; - const relativePath = util.normalizePath(path.relative(outDir, fullPath)); - if (chunksByFile.has(relativePath)) continue; - chunksByFile.set(relativePath, { - comparisonKey: null, - displayName: relativePath, - file: relativePath, - manifestKeys: [], - size: await util.fileSize(fullPath), - }); - } - } - - const startupFiles = new Set(); - for (const manifestKey of collectStartupManifestKeys(manifest)) { - const entry = chunksByManifestKey.get(manifestKey); - if (entry != null) startupFiles.add(entry.file); - } - - return { - manifest, - chunks: [...chunksByFile.values()], - comparableChunks: Object.fromEntries(comparableChunks), - chunksByManifestKey: Object.fromEntries(chunksByManifestKey), - startupFiles: [...startupFiles], - }; -} -``` - -- [ ] **Step 4: Update comparison rows and table rendering** - -Change `getChunkComparisonRows` to consume comparable maps and retain both filenames: - -```ts -function getChunkComparisonRows(keys: string[], before: Record, after: Record) { - return keys.map(key => { - const beforeEntry = before[key]; - const afterEntry = after[key]; - const beforeSize = beforeEntry?.size ?? 0; - const afterSize = afterEntry?.size ?? 0; - return { - key, - name: entryDisplayName(beforeEntry ?? afterEntry), - beforeFile: beforeEntry?.file, - afterFile: afterEntry?.file, - beforeSize, - afterSize, - changeType: beforeEntry == null ? 'added' : afterEntry == null ? 'removed' : beforeSize !== afterSize ? 'updated' : 'unchanged', - sortSize: Math.max(beforeSize, afterSize), - }; - }); -} - -type ChunkAggregate = { - beforeSize: number; - afterSize: number; - beforeCount: number; - afterCount: number; -}; - -function sumChunkSizes(chunks: FileEntry[]) { - return chunks.reduce((sum, chunk) => sum + chunk.size, 0); -} - -function generatedAggregate(before: FileEntry[], after: FileEntry[]): ChunkAggregate { - const beforeGenerated = before.filter(chunk => chunk.comparisonKey == null); - const afterGenerated = after.filter(chunk => chunk.comparisonKey == null); - return { - beforeSize: sumChunkSizes(beforeGenerated), - afterSize: sumChunkSizes(afterGenerated), - beforeCount: beforeGenerated.length, - afterCount: afterGenerated.length, - }; -} - -function comparableMap(chunks: FileEntry[]) { - const entries: [string, FileEntry][] = []; - for (const chunk of chunks) { - if (chunk.comparisonKey != null) entries.push([chunk.comparisonKey, chunk]); - } - return Object.fromEntries(entries); -} -``` - -Replace `chunkMarkdownTable` with this version, which adds a third `generated` argument: - -```ts -function chunkMarkdownTable( - rows: ReturnType, - total?: { beforeSize: number; afterSize: number }, - generated?: ChunkAggregate, -) { - if (rows.length === 0 && total == null && generated == null) return '_No data_'; - - const lines = [ - '| Chunk | Before | After | Δ | Δ (%) |', - '| --- | ---: | ---: | ---: | ---: |', - ]; - let hasSummaryRow = false; - if (total != null) { - lines.push(`| (total) | ${util.formatBytes(total.beforeSize)} | ${util.formatBytes(total.afterSize)} | ${util.calcAndFormatDeltaBytes(total.beforeSize, total.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(total.beforeSize, total.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`); - hasSummaryRow = true; - } - if (generated != null && (generated.beforeCount > 0 || generated.afterCount > 0)) { - lines.push(`| (other generated chunks) | ${util.formatBytes(generated.beforeSize)} | ${util.formatBytes(generated.afterSize)} | ${util.calcAndFormatDeltaBytes(generated.beforeSize, generated.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(generated.beforeSize, generated.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`); - hasSummaryRow = true; - } - if (hasSummaryRow && rows.length > 0) lines.push('| | | | | |'); - - for (const row of rows) { - const chunkFile = row.beforeFile ?? row.afterFile ?? ''; - if (row.changeType === 'added') { - lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{orange}{\\text{( + )}}$ |`); - } else if (row.changeType === 'removed') { - lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{green}{\\text{( - )}}$ |`); - } else { - lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(row.beforeSize, row.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`); - } - } - if (generated != null && (generated.beforeCount > 0 || generated.afterCount > 0)) { - lines.push(''); - lines.push(`_${generated.beforeCount} before / ${generated.afterCount} after generated chunks are grouped._`); - } - return lines.join('\n'); -} -``` - -Update `renderFrontendChunkReport` so the full report uses all physical chunks for totals, stable maps for rows, and unidentifiable chunks for the aggregate: - -```ts -const beforeComparable = before.comparableChunks; -const afterComparable = after.comparableChunks; -const allChunkKeys = [...new Set([...Object.keys(beforeComparable), ...Object.keys(afterComparable)])]; -const allComparisonRows = getChunkComparisonRows(allChunkKeys, beforeComparable, afterComparable); -const diffTotal = { - beforeSize: sumChunkSizes(before.chunks), - afterSize: sumChunkSizes(after.chunks), -}; -const diffGenerated = generatedAggregate(before.chunks, after.chunks); -``` - -For startup accounting, filter physical chunks by `startupFiles`, rebuild comparable maps from those filtered arrays, and calculate totals and aggregate from the filtered arrays: - -```ts -const beforeStartupFiles = new Set(before.startupFiles); -const afterStartupFiles = new Set(after.startupFiles); -const beforeStartupChunks = before.chunks.filter(chunk => beforeStartupFiles.has(chunk.file)); -const afterStartupChunks = after.chunks.filter(chunk => afterStartupFiles.has(chunk.file)); -const beforeStartupComparable = comparableMap(beforeStartupChunks); -const afterStartupComparable = comparableMap(afterStartupChunks); -const startupKeys = [...new Set([...Object.keys(beforeStartupComparable), ...Object.keys(afterStartupComparable)])]; -const startupComparisonRows = getChunkComparisonRows(startupKeys, beforeStartupComparable, afterStartupComparable); -const startupTotal = { - beforeSize: sumChunkSizes(beforeStartupChunks), - afterSize: sumChunkSizes(afterStartupChunks), -}; -const startupGenerated = generatedAggregate(beforeStartupChunks, afterStartupChunks); -``` - -Pass the aggregates with these exact calls: - -```ts -chunkMarkdownTable(diffRows, diffTotal, diffGenerated) -chunkMarkdownTable(startupRows, startupTotal, startupGenerated) -``` - -- [ ] **Step 5: Run the focused test and verify it passes** - -Run: - -```bash -node --test .github/scripts/frontend-js-size.test.mts -``` - -Expected: PASS with `1` test and `0` failures. The Markdown contains two aggregate rows because the fixture makes all chunks startup chunks as well as full-report chunks. - -- [ ] **Step 6: Commit the physical-accounting change** - -```bash -git add .github/scripts/frontend-js-size.mts .github/scripts/frontend-js-size.test.mts -git commit -m "fix(dev): aggregate generated frontend chunks" -``` - ---- - -### Task 2: Reject duplicate stable identities and render honest filenames - -**Files:** -- Modify: `.github/scripts/frontend-js-size.test.mts` -- Modify: `.github/scripts/frontend-js-size.mts:315-372` - -**Interfaces:** -- Consumes: the `Duplicate stable chunk key` validation and comparison rows produced in Task 1. -- Produces: a fatal diagnostic for duplicate `src`/allowlisted identities and a file label that renders `before → after` when hashes differ. - -- [ ] **Step 1: Add failing coverage for duplicate stable names and filename changes** - -Append these tests to `.github/scripts/frontend-js-size.test.mts`: - -```ts -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; - - await assert.rejects( - runReport(t, duplicateVue, fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 })), - /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: 45, 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`/); -}); -``` - -- [ ] **Step 2: Run the tests and confirm the filename test fails** - -Run: - -```bash -node --test .github/scripts/frontend-js-size.test.mts -``` - -Expected: the duplicate-key test passes using Task 1 validation, while `shows both filenames for an updated stable chunk` fails because the table still shows only one file. - -- [ ] **Step 3: Render both filenames without changing identity logic** - -Add this helper near `chunkMarkdownTable`: - -```ts -function chunkFileDisplay(row: ReturnType[number]) { - if (row.beforeFile == null) return row.afterFile ?? ''; - if (row.afterFile == null || row.beforeFile === row.afterFile) return row.beforeFile; - return `${row.beforeFile} → ${row.afterFile}`; -} -``` - -At the start of each `for (const row of rows)` iteration, compute: - -```ts -const chunkFile = chunkFileDisplay(row); -``` - -Replace every `${escapeCell(row.chunkFile)}` interpolation in the three row variants with `${escapeCell(chunkFile)}`. Do not display any representative filename on the aggregate row. - -- [ ] **Step 4: Run all focused tests** - -Run: - -```bash -node --test .github/scripts/frontend-js-size.test.mts -``` - -Expected: PASS with `3` tests and `0` failures. - -- [ ] **Step 5: Commit the validation and display behavior** - -```bash -git add .github/scripts/frontend-js-size.mts .github/scripts/frontend-js-size.test.mts -git commit -m "fix(dev): clarify frontend chunk comparisons" -``` - ---- - -### Task 3: Run the report regression test in CI - -**Files:** -- Modify: `.github/workflows/lint.yml:3-33` -- Modify: `.github/workflows/lint.yml:139` - -**Interfaces:** -- Consumes: `.github/scripts/frontend-js-size.test.mts` from Tasks 1 and 2. -- Produces: a `frontend-bundle-report-test` GitHub Actions job using the repository's `.node-version`. - -- [ ] **Step 1: Verify the focused test is not currently referenced by CI** - -Run: - -```bash -rg -n "frontend-js-size.test|frontend-bundle-report-test" .github/workflows -``` - -Expected: no matches. - -- [ ] **Step 2: Add report-script paths to both lint workflow filters** - -Under both `on.push.paths` and `on.pull_request.paths` in `.github/workflows/lint.yml`, add: - -```yaml - - .github/scripts/frontend-js-size*.mts - - .github/scripts/utility.mts -``` - -Keep `.github/workflows/lint.yml` itself in both filters. - -- [ ] **Step 3: Add a focused CI job** - -Append this job under `jobs` at the same level as `check-dts`: - -```yaml - 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 -``` - -This job intentionally does not depend on `pnpm_install` because the test and report generator use only Node built-ins and the local `utility.mts` module. - -- [ ] **Step 4: Run the exact CI test command locally** - -Run: - -```bash -node --test .github/scripts/frontend-js-size.test.mts -``` - -Expected: PASS with `3` tests and `0` failures. - -- [ ] **Step 5: Confirm CI now references the test exactly once** - -Run: - -```bash -rg -n "node --test \.github/scripts/frontend-js-size\.test\.mts" .github/workflows/lint.yml -``` - -Expected: one match in the `frontend-bundle-report-test` job. - -- [ ] **Step 6: Commit the CI coverage** - -```bash -git add .github/workflows/lint.yml -git commit -m "test(dev): check frontend bundle report" -``` - ---- - -### Task 4: Complete Misskey pre-ship validation - -**Files:** -- Verify: `.github/scripts/frontend-js-size.mts` -- Verify: `.github/scripts/frontend-js-size.test.mts` -- Verify: `.github/workflows/lint.yml` -- Verify: `docs/superpowers/specs/2026-07-15-frontend-bundle-generated-chunks-design.md` - -**Interfaces:** -- Consumes: all implementation and CI changes from Tasks 1-3. -- Produces: fresh evidence that focused behavior, repository lint, SPDX, locale safety, and final diff checks pass. - -- [ ] **Step 1: Run the focused regression tests** - -Run: - -```bash -node --test .github/scripts/frontend-js-size.test.mts -``` - -Expected: PASS with `3` tests and `0` failures. - -- [ ] **Step 2: Run repository lint** - -Run: - -```bash -pnpm lint -``` - -Expected: exit code `0`. If an unrelated pre-existing failure occurs, record its exact command and output rather than claiming lint passed. - -- [ ] **Step 3: Check the new file's SPDX header** - -Run: - -```bash -rg -n "SPDX-FileCopyrightText: syuilo and misskey-project|SPDX-License-Identifier: AGPL-3.0-only" .github/scripts/frontend-js-size.test.mts -``` - -Expected: two matches, one for each required SPDX line. - -- [ ] **Step 4: Verify locale safety and unchanged migration/API surfaces** - -Run: - -```bash -git diff --name-only develop -- locales -git diff --name-only develop -- packages/backend/migration packages/backend/src packages/misskey-js/src/autogen -``` - -Expected: both commands produce no output. Therefore locale generation, migration checking, backend API review, and misskey-js regeneration are not applicable. - -- [ ] **Step 5: Verify the final diff and worktree** - -Run: - -```bash -git diff --check develop...HEAD -git status --short -git log --oneline develop..HEAD -``` - -Expected: `git diff --check` exits `0`, `git status --short` is empty, and the log contains the approved design commit plus the three implementation commits from this plan. - -- [ ] **Step 6: Review the final report behavior against the approved design** - -Confirm all of these from the focused test output and diff: - -```text -[x] Physical chunks are counted once by output path. -[x] Only src-backed, vue, and i18n chunks are individually compared. -[x] Generated chunks are aggregated in full and startup reports. -[x] Duplicate stable keys fail instead of overwriting. -[x] Updated stable rows show before and after filenames. -[x] No locale, migration, backend API, frontend Vue, or user-facing behavior changed. -[x] No CHANGELOG entry is needed. -``` From a7fd58e4a59c4ce2fceee7e220dca99f71c90577 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Thu, 16 Jul 2026 08:57:56 +0900 Subject: [PATCH 059/168] docs: define small frontend chunk aggregation --- ...frontend-bundle-generated-chunks-design.md | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/specs/2026-07-15-frontend-bundle-generated-chunks-design.md b/docs/superpowers/specs/2026-07-15-frontend-bundle-generated-chunks-design.md index 95e0de006b..1cadf1f3fa 100644 --- a/docs/superpowers/specs/2026-07-15-frontend-bundle-generated-chunks-design.md +++ b/docs/superpowers/specs/2026-07-15-frontend-bundle-generated-chunks-design.md @@ -89,7 +89,7 @@ The report computes three independent values: 2. **Generated chunk aggregate:** the sum of chunks without a comparison key. 3. **Individual rows:** before/after comparisons for stable comparison keys only. -The table starts with the existing `(total)` row. When either build contains generated chunks, it then includes one aggregate row such as: +The table starts with the existing `(total)` row. Generated chunks are rendered at the bottom of the table as one aggregate row such as: ```text (other generated chunks) @@ -99,6 +99,24 @@ This row compares aggregate sizes, not individual chunk identities. Generated ch The report includes a short note stating how many generated chunks were grouped on each side. This makes the scope of the aggregate explicit without listing noisy filenames. +### Small-delta aggregation + +Stable comparison rows whose absolute byte delta is at most `5 B` are grouped into an `(other)` aggregate instead of being rendered individually. The threshold is inclusive: deltas from `-5 B` through `+5 B` are grouped, while a `6 B` absolute delta remains an individual row. Small additions and removals are handled by the same rule. + +The `(other)` row reports the sum of the grouped chunks' before sizes and the sum of their after sizes. It does not expose an arbitrary representative filename. In the full chunk report, only changed rows are candidates because unchanged rows are already omitted. In the startup report, all rows currently eligible for display are candidates, so unchanged rows are also grouped instead of being listed individually. + +The updated/added/removed counts are calculated before small-delta rows are grouped. Therefore the summary continues to include every stable changed chunk, including chunks represented only by `(other)`. + +Table rows are ordered as follows: + +1. `(total)`; +2. individual stable comparison rows whose absolute delta is greater than `5 B`; +3. one empty separator row, when at least one aggregate row follows; +4. `(other generated chunks)`, when generated chunks exist; and +5. `(other)`, when small-delta stable chunks exist. + +The existing 30-row limit applies after small-delta rows have been removed from the individual-row candidates. + ### Startup chunk report Startup traversal continues to follow the entry chunk's static `imports`, but it records manifest keys or resolved physical file paths rather than generated comparison keys. This prevents two startup chunks named `dist` from collapsing into one. @@ -143,8 +161,12 @@ Add focused fixtures or pure-function tests covering: - duplicate stable keys produce a descriptive error instead of overwriting; - full totals equal the sum of all unique physical chunks; - startup totals include multiple same-name generated chunks exactly once each; -- generated aggregate changes do not affect the updated/added/removed summary counts; and -- differing before/after filenames are rendered without attributing both sizes to one file. +- generated aggregate changes do not affect the updated/added/removed summary counts; +- differing before/after filenames are rendered without attributing both sizes to one file; +- deltas of exactly `5 B` are grouped into `(other)`, while `6 B` deltas remain individual; +- small updated, added, and removed chunks remain included in the summary counts; +- `(other generated chunks)` and `(other)` appear below individual rows after an empty separator row; and +- the same small-delta and ordering rules apply to the full and startup tables. Validation should include the focused tests and repository lint. No CHANGELOG entry is required because this changes developer-facing CI reporting rather than Misskey user behavior. From 1e4260cfb192a75e36bd91f65d60c4e3999cf9e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=8A=E3=81=95=E3=82=80=E3=81=AE=E3=81=B2=E3=81=A8?= <46447427+samunohito@users.noreply.github.com> Date: Thu, 16 Jul 2026 08:59:31 +0900 Subject: [PATCH 060/168] feat: add Japanese language support and tone instructions (#17720) --- .coderabbit.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index ce726dd641..b10b001171 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -1,3 +1,5 @@ +language: "ja-JP" +tone_instructions: "常に丁寧語(です・ます調)を用い、敬意のある表現で応答してください。人格や能力への評価、皮肉、威圧的・高圧的・命令的な表現は避けてください。指摘では問題点と影響を明確にし、理由を簡潔に説明したうえで、具体的な改善案を提案してください。不確実な事項は推測と明示し、確認を促してください。重大なバグやセキュリティ上の懸念は重要度と根拠を明確に伝えてください。" reviews: profile: "chill" high_level_summary: false From 2199f9040d156f173af8fa908a987374283235d0 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Thu, 16 Jul 2026 09:01:17 +0900 Subject: [PATCH 061/168] docs: plan small frontend chunk aggregation --- .gitignore | 3 + ...frontend-bundle-small-delta-aggregation.md | 114 ++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-16-frontend-bundle-small-delta-aggregation.md diff --git a/.gitignore b/.gitignore index e975691b93..748b9f5d18 100644 --- a/.gitignore +++ b/.gitignore @@ -83,3 +83,6 @@ vite.config.local-dev.ts.timestamp-* # Affinity *.af~lock~ + +# Agent worktrees +/.worktrees/ diff --git a/docs/superpowers/plans/2026-07-16-frontend-bundle-small-delta-aggregation.md b/docs/superpowers/plans/2026-07-16-frontend-bundle-small-delta-aggregation.md new file mode 100644 index 0000000000..8b0ac359f3 --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-frontend-bundle-small-delta-aggregation.md @@ -0,0 +1,114 @@ +# Frontend Bundle Small-Delta Aggregation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Group stable frontend chunks with an absolute size delta of at most `5 B` into a bottom-of-table `(other)` row while preserving complete change counts. + +**Architecture:** Keep comparison and summary calculation unchanged, then partition render candidates into significant and small-delta rows. Render individual rows first and append generated/small-delta aggregates after one empty separator row in both full and startup tables. + +**Tech Stack:** TypeScript, Node.js test runner, Markdown report generation. + +## Global Constraints + +- The threshold is inclusive: `Math.abs(afterSize - beforeSize) <= 5` is grouped. +- Summary updated/added/removed counts use all stable changed rows before grouping. +- Full-report candidates are changed stable rows; startup candidates include every stable startup row, including unchanged rows. +- Aggregate order is `(other generated chunks)` followed by `(other)`. +- A single empty Markdown table row separates individual rows from aggregates. +- The existing individual-row limit is applied after small-delta rows are removed. +- Do not run code review or repository-wide lint, per the user's request. + +--- + +### Task 1: Aggregate and move small-delta rows + +**Files:** +- Modify: `.github/scripts/frontend-js-size.test.mts` +- Modify: `.github/scripts/frontend-js-size.mts:350-520` +- Verify: `docs/superpowers/specs/2026-07-15-frontend-bundle-generated-chunks-design.md` + +**Interfaces:** +- Consumes: stable comparison rows from `getChunkComparisonRows`, physical generated aggregates, and the existing `chunkMarkdownTable` renderer. +- Produces: a `ChunkAggregate` for small-delta rows and Markdown ordered as total, significant rows, separator, generated aggregate, small-delta aggregate. + +- [ ] **Step 1: Write the failing end-to-end test** + +Add a test based on the existing `fixture` helper. Use an entry delta of `+6 B`, a Vue delta of `+5 B`, unchanged i18n, a `5 B` removed source chunk in the before startup imports, and a `5 B` added source chunk in the after startup imports. + +Assert all of the following: + +```ts +assert.match(report, /Chunk size diff \(2 updated, 1 added, 1 removed\)<\/summary>/); +assert.match(report, /Startup chunk size \(2 updated, 1 added, 1 removed\)<\/summary>/); +assert.match(report, /`src\/_boot_\.ts`<\/summary>/); +assert.doesNotMatch(report, /`(?:vue|i18n|src\/added-small\.ts|src\/removed-small\.ts)`<\/summary>/); +assert.match(report, /\| \| \| \| \| \|\n\| \(other generated chunks\) \| 30 B \| 70 B \|[^\n]*\n\| \(other\) \| 45 B \| 50 B \|/); +assert.match(report, /\| \| \| \| \| \|\n\| \(other generated chunks\) \| 30 B \| 70 B \|[^\n]*\n\| \(other\) \| 95 B \| 100 B \|/); +``` + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: + +```powershell +& 'C:\Program Files\nodejs\node.exe' --test .github/scripts/frontend-js-size.test.mts +``` + +Expected: the new test fails because `(other)` is absent and `(other generated chunks)` is still directly below `(total)`. + +- [ ] **Step 3: Add the small-delta partition and aggregate** + +Add a constant and helpers near `ChunkAggregate`: + +```ts +const smallDeltaThreshold = 5; + +type ChunkComparisonRow = ReturnType[number]; + +function hasSmallDelta(row: ChunkComparisonRow) { + return Math.abs(row.afterSize - row.beforeSize) <= smallDeltaThreshold; +} + +function comparisonRowsAggregate(rows: ChunkComparisonRow[]): ChunkAggregate { + return { + beforeSize: rows.reduce((sum, row) => sum + row.beforeSize, 0), + afterSize: rows.reduce((sum, row) => sum + row.afterSize, 0), + beforeCount: rows.filter(row => row.beforeFile != null).length, + afterCount: rows.filter(row => row.afterFile != null).length, + }; +} +``` + +Calculate summaries before filtering. Partition `changedRows` and `startupComparisonRows`, aggregate small rows, and apply sorting/30-row limiting only to rows for which `hasSmallDelta` is false. + +- [ ] **Step 4: Render aggregate rows at the table bottom** + +Extend `chunkMarkdownTable` with an optional `other?: ChunkAggregate`. Render `(total)`, then individual rows. If either aggregate contains chunks, append one `| | | | | |` separator, followed by `(other generated chunks)` and `(other)` when present. + +Use the existing numeric formatting for both aggregates: + +```ts +`| (other) | ${util.formatBytes(other.beforeSize)} | ${util.formatBytes(other.afterSize)} | ${util.calcAndFormatDeltaBytes(other.beforeSize, other.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(other.beforeSize, other.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |` +``` + +Pass the full and startup small-delta aggregates to their corresponding table calls. + +- [ ] **Step 5: Run the focused suite and verify GREEN** + +Run: + +```powershell +& 'C:\Program Files\nodejs\node.exe' --test .github/scripts/frontend-js-size.test.mts +``` + +Expected: all existing tests plus the new test pass with zero failures and clean output. + +- [ ] **Step 6: Remove this transient implementation plan and commit** + +Delete `docs/superpowers/plans/2026-07-16-frontend-bundle-small-delta-aggregation.md`, then run: + +```powershell +git diff --check +git add .github/scripts/frontend-js-size.mts .github/scripts/frontend-js-size.test.mts +git commit -m "fix(dev): group small frontend chunk deltas" +``` From 112263135eb68c85009878c132800e229bcada74 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Thu, 16 Jul 2026 09:04:54 +0900 Subject: [PATCH 062/168] fix(dev): group small frontend chunk deltas --- .github/scripts/frontend-js-size.mts | 52 +++++--- .github/scripts/frontend-js-size.test.mts | 27 ++++- .gitignore | 3 - ...frontend-bundle-small-delta-aggregation.md | 114 ------------------ 4 files changed, 61 insertions(+), 135 deletions(-) delete mode 100644 docs/superpowers/plans/2026-07-16-frontend-bundle-small-delta-aggregation.md diff --git a/.github/scripts/frontend-js-size.mts b/.github/scripts/frontend-js-size.mts index 04a92679c7..370f49c082 100644 --- a/.github/scripts/frontend-js-size.mts +++ b/.github/scripts/frontend-js-size.mts @@ -368,6 +368,8 @@ function getChunkComparisonRows(keys: string[], before: Record[number]; + type ChunkAggregate = { beforeSize: number; afterSize: number; @@ -375,6 +377,8 @@ type ChunkAggregate = { afterCount: number; }; +const smallDeltaThreshold = 5; + function sumChunkSizes(chunks: FileEntry[]) { return chunks.reduce((sum, chunk) => sum + chunk.size, 0); } @@ -390,6 +394,19 @@ function generatedAggregate(before: FileEntry[], after: FileEntry[]): ChunkAggre }; } +function hasSmallDelta(row: ChunkComparisonRow) { + return Math.abs(row.afterSize - row.beforeSize) <= smallDeltaThreshold; +} + +function comparisonRowsAggregate(rows: ChunkComparisonRow[]): ChunkAggregate { + return { + beforeSize: rows.reduce((sum, row) => sum + row.beforeSize, 0), + afterSize: rows.reduce((sum, row) => sum + row.afterSize, 0), + beforeCount: rows.filter(row => row.beforeFile != null).length, + afterCount: rows.filter(row => row.afterFile != null).length, + }; +} + function comparableMap(chunks: FileEntry[]) { const entries: [string, FileEntry][] = []; for (const chunk of chunks) { @@ -410,14 +427,14 @@ function formatChunkChangeSummary(label: string, summary: ReturnType[number], b: ReturnType[number]) { +function compareChunkComparisonRows(a: ChunkComparisonRow, b: ChunkComparisonRow) { return Math.abs(b.afterSize - b.beforeSize) - Math.abs(a.afterSize - a.beforeSize) || (b.afterSize - b.beforeSize) - (a.afterSize - a.beforeSize) || b.sortSize - a.sortSize || a.name.localeCompare(b.name); } -function chunkFileDisplay(row: ReturnType[number]) { +function chunkFileDisplay(row: ChunkComparisonRow) { if (row.beforeFile == null) return row.afterFile ?? ''; if (row.afterFile == null || row.beforeFile === row.afterFile) return row.beforeFile; return `${row.beforeFile} → ${row.afterFile}`; @@ -427,23 +444,19 @@ function chunkMarkdownTable( rows: ReturnType, total?: { beforeSize: number; afterSize: number }, generated?: ChunkAggregate, + other?: ChunkAggregate, ) { - if (rows.length === 0 && total == null && generated == null) return '_No data_'; + const hasGenerated = generated != null && (generated.beforeCount > 0 || generated.afterCount > 0); + const hasOther = other != null && (other.beforeCount > 0 || other.afterCount > 0); + if (rows.length === 0 && total == null && !hasGenerated && !hasOther) return '_No data_'; const lines = [ '| Chunk | Before | After | Δ | Δ (%) |', '| --- | ---: | ---: | ---: | ---: |', ]; - let hasSummaryRow = false; if (total != null) { lines.push(`| (total) | ${util.formatBytes(total.beforeSize)} | ${util.formatBytes(total.afterSize)} | ${util.calcAndFormatDeltaBytes(total.beforeSize, total.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(total.beforeSize, total.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`); - hasSummaryRow = true; } - if (generated != null && (generated.beforeCount > 0 || generated.afterCount > 0)) { - lines.push(`| (other generated chunks) | ${util.formatBytes(generated.beforeSize)} | ${util.formatBytes(generated.afterSize)} | ${util.calcAndFormatDeltaBytes(generated.beforeSize, generated.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(generated.beforeSize, generated.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`); - hasSummaryRow = true; - } - if (hasSummaryRow && rows.length > 0) lines.push('| | | | | |'); for (const row of rows) { const chunkFile = chunkFileDisplay(row); @@ -455,7 +468,14 @@ function chunkMarkdownTable( lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(row.beforeSize, row.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`); } } - if (generated != null && (generated.beforeCount > 0 || generated.afterCount > 0)) { + if (hasGenerated || hasOther) lines.push('| | | | | |'); + if (hasGenerated) { + lines.push(`| (other generated chunks) | ${util.formatBytes(generated.beforeSize)} | ${util.formatBytes(generated.afterSize)} | ${util.calcAndFormatDeltaBytes(generated.beforeSize, generated.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(generated.beforeSize, generated.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`); + } + if (hasOther) { + lines.push(`| (other) | ${util.formatBytes(other.beforeSize)} | ${util.formatBytes(other.afterSize)} | ${util.calcAndFormatDeltaBytes(other.beforeSize, other.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(other.beforeSize, other.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`); + } + if (hasGenerated) { lines.push(''); lines.push(`_${generated.beforeCount} before / ${generated.afterCount} after generated chunks are grouped._`); } @@ -475,7 +495,8 @@ function renderFrontendChunkReport(before: Awaited !hasSmallDelta(row)).sort(compareChunkComparisonRows).slice(0, 30); // TODO: 実際に30を超えて切り捨てられたrowがあった場合はその旨をmarkdown内に表示するようにする const beforeStartupFiles = new Set(before.startupFiles); const afterStartupFiles = new Set(after.startupFiles); @@ -485,8 +506,9 @@ function renderFrontendChunkReport(before: Awaited !hasSmallDelta(row)).sort(compareChunkComparisonRows); const startupTotal = { beforeSize: sumChunkSizes(beforeStartupChunks), afterSize: sumChunkSizes(afterStartupChunks), @@ -501,14 +523,14 @@ function renderFrontendChunkReport(before: Awaited', `${formatChunkChangeSummary('Chunk size diff', diffSummary)}`, '', - chunkMarkdownTable(diffRows, diffTotal, diffGenerated), + chunkMarkdownTable(diffRows, diffTotal, diffGenerated, diffOther), '', '
', '', '
', `${formatChunkChangeSummary('Startup chunk size', startupSummary)}`, '', - chunkMarkdownTable(startupRows, startupTotal, startupGenerated), + chunkMarkdownTable(startupRows, startupTotal, startupGenerated, startupOther), '', `_Startup chunks are the Vite entry for \`src/_boot_.ts\` and its static imports._`, '', diff --git a/.github/scripts/frontend-js-size.test.mts b/.github/scripts/frontend-js-size.test.mts index 347d21548c..f688ae3270 100644 --- a/.github/scripts/frontend-js-size.test.mts +++ b/.github/scripts/frontend-js-size.test.mts @@ -86,10 +86,10 @@ 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: 45, i18n: 50 }), + fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 55, i18n: 50 }), ); - assert.match(report, /\| \(total\) \| 220 B \| 275 B \|/); + assert.match(report, /\| \(total\) \| 220 B \| 285 B \|/); assert.equal(report.match(/\| \(other generated chunks\) \| 30 B \| 70 B \|/g)?.length, 2); assert.equal(report.match(/_2 before \/ 2 after generated chunks are grouped\._/g)?.length, 2); assert.doesNotMatch(report, /`(?:dist|esm)`<\/summary>/); @@ -97,6 +97,27 @@ test('groups generated chunks while preserving full and startup totals', async t assert.match(report, /`vue`<\/summary>/); }); +test('groups small deltas at the bottom while preserving all change counts', async t => { + const before = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }); + before.manifest._removedSmall = { file: 'scripts/removed-small-before.js', src: 'src/removed-small.ts' }; + before.manifest['src/_boot_.ts'].imports?.push('_removedSmall'); + before.sizes['scripts/removed-small-before.js'] = 5; + + const after = fixture('after', 'esm', { entry: 106, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }); + after.manifest._addedSmall = { file: 'scripts/added-small-after.js', src: 'src/added-small.ts' }; + after.manifest['src/_boot_.ts'].imports?.push('_addedSmall'); + after.sizes['scripts/added-small-after.js'] = 5; + + const report = await runReport(t, before, after); + + assert.match(report, /Chunk size diff \(2 updated, 1 added, 1 removed\)<\/summary>/); + assert.match(report, /Startup chunk size \(2 updated, 1 added, 1 removed\)<\/summary>/); + assert.equal(report.match(/`src\/_boot_\.ts`<\/summary>/g)?.length, 2); + assert.doesNotMatch(report, /`(?:vue|i18n|src\/added-small\.ts|src\/removed-small\.ts)`<\/summary>/); + assert.match(report, /\| \(total\) \| 225 B \| 276 B \|[^\n]*\n\|
`src\/_boot_\.ts`<\/summary>[^\n]*\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\|
`src\/_boot_\.ts`<\/summary>[^\n]*\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' }; @@ -115,7 +136,7 @@ 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: 45, 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`/); diff --git a/.gitignore b/.gitignore index 748b9f5d18..e975691b93 100644 --- a/.gitignore +++ b/.gitignore @@ -83,6 +83,3 @@ vite.config.local-dev.ts.timestamp-* # Affinity *.af~lock~ - -# Agent worktrees -/.worktrees/ diff --git a/docs/superpowers/plans/2026-07-16-frontend-bundle-small-delta-aggregation.md b/docs/superpowers/plans/2026-07-16-frontend-bundle-small-delta-aggregation.md deleted file mode 100644 index 8b0ac359f3..0000000000 --- a/docs/superpowers/plans/2026-07-16-frontend-bundle-small-delta-aggregation.md +++ /dev/null @@ -1,114 +0,0 @@ -# Frontend Bundle Small-Delta Aggregation Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Group stable frontend chunks with an absolute size delta of at most `5 B` into a bottom-of-table `(other)` row while preserving complete change counts. - -**Architecture:** Keep comparison and summary calculation unchanged, then partition render candidates into significant and small-delta rows. Render individual rows first and append generated/small-delta aggregates after one empty separator row in both full and startup tables. - -**Tech Stack:** TypeScript, Node.js test runner, Markdown report generation. - -## Global Constraints - -- The threshold is inclusive: `Math.abs(afterSize - beforeSize) <= 5` is grouped. -- Summary updated/added/removed counts use all stable changed rows before grouping. -- Full-report candidates are changed stable rows; startup candidates include every stable startup row, including unchanged rows. -- Aggregate order is `(other generated chunks)` followed by `(other)`. -- A single empty Markdown table row separates individual rows from aggregates. -- The existing individual-row limit is applied after small-delta rows are removed. -- Do not run code review or repository-wide lint, per the user's request. - ---- - -### Task 1: Aggregate and move small-delta rows - -**Files:** -- Modify: `.github/scripts/frontend-js-size.test.mts` -- Modify: `.github/scripts/frontend-js-size.mts:350-520` -- Verify: `docs/superpowers/specs/2026-07-15-frontend-bundle-generated-chunks-design.md` - -**Interfaces:** -- Consumes: stable comparison rows from `getChunkComparisonRows`, physical generated aggregates, and the existing `chunkMarkdownTable` renderer. -- Produces: a `ChunkAggregate` for small-delta rows and Markdown ordered as total, significant rows, separator, generated aggregate, small-delta aggregate. - -- [ ] **Step 1: Write the failing end-to-end test** - -Add a test based on the existing `fixture` helper. Use an entry delta of `+6 B`, a Vue delta of `+5 B`, unchanged i18n, a `5 B` removed source chunk in the before startup imports, and a `5 B` added source chunk in the after startup imports. - -Assert all of the following: - -```ts -assert.match(report, /Chunk size diff \(2 updated, 1 added, 1 removed\)<\/summary>/); -assert.match(report, /Startup chunk size \(2 updated, 1 added, 1 removed\)<\/summary>/); -assert.match(report, /`src\/_boot_\.ts`<\/summary>/); -assert.doesNotMatch(report, /`(?:vue|i18n|src\/added-small\.ts|src\/removed-small\.ts)`<\/summary>/); -assert.match(report, /\| \| \| \| \| \|\n\| \(other generated chunks\) \| 30 B \| 70 B \|[^\n]*\n\| \(other\) \| 45 B \| 50 B \|/); -assert.match(report, /\| \| \| \| \| \|\n\| \(other generated chunks\) \| 30 B \| 70 B \|[^\n]*\n\| \(other\) \| 95 B \| 100 B \|/); -``` - -- [ ] **Step 2: Run the focused test and verify RED** - -Run: - -```powershell -& 'C:\Program Files\nodejs\node.exe' --test .github/scripts/frontend-js-size.test.mts -``` - -Expected: the new test fails because `(other)` is absent and `(other generated chunks)` is still directly below `(total)`. - -- [ ] **Step 3: Add the small-delta partition and aggregate** - -Add a constant and helpers near `ChunkAggregate`: - -```ts -const smallDeltaThreshold = 5; - -type ChunkComparisonRow = ReturnType[number]; - -function hasSmallDelta(row: ChunkComparisonRow) { - return Math.abs(row.afterSize - row.beforeSize) <= smallDeltaThreshold; -} - -function comparisonRowsAggregate(rows: ChunkComparisonRow[]): ChunkAggregate { - return { - beforeSize: rows.reduce((sum, row) => sum + row.beforeSize, 0), - afterSize: rows.reduce((sum, row) => sum + row.afterSize, 0), - beforeCount: rows.filter(row => row.beforeFile != null).length, - afterCount: rows.filter(row => row.afterFile != null).length, - }; -} -``` - -Calculate summaries before filtering. Partition `changedRows` and `startupComparisonRows`, aggregate small rows, and apply sorting/30-row limiting only to rows for which `hasSmallDelta` is false. - -- [ ] **Step 4: Render aggregate rows at the table bottom** - -Extend `chunkMarkdownTable` with an optional `other?: ChunkAggregate`. Render `(total)`, then individual rows. If either aggregate contains chunks, append one `| | | | | |` separator, followed by `(other generated chunks)` and `(other)` when present. - -Use the existing numeric formatting for both aggregates: - -```ts -`| (other) | ${util.formatBytes(other.beforeSize)} | ${util.formatBytes(other.afterSize)} | ${util.calcAndFormatDeltaBytes(other.beforeSize, other.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(other.beforeSize, other.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |` -``` - -Pass the full and startup small-delta aggregates to their corresponding table calls. - -- [ ] **Step 5: Run the focused suite and verify GREEN** - -Run: - -```powershell -& 'C:\Program Files\nodejs\node.exe' --test .github/scripts/frontend-js-size.test.mts -``` - -Expected: all existing tests plus the new test pass with zero failures and clean output. - -- [ ] **Step 6: Remove this transient implementation plan and commit** - -Delete `docs/superpowers/plans/2026-07-16-frontend-bundle-small-delta-aggregation.md`, then run: - -```powershell -git diff --check -git add .github/scripts/frontend-js-size.mts .github/scripts/frontend-js-size.test.mts -git commit -m "fix(dev): group small frontend chunk deltas" -``` From 0f3210946d6621f5bfaede5c8fa31f251d521a90 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Thu, 16 Jul 2026 09:27:46 +0900 Subject: [PATCH 063/168] fix(dev): refine frontend chunk report layout --- .github/scripts/frontend-js-size.mts | 6 +----- .github/scripts/frontend-js-size.test.mts | 8 +++----- ...07-15-frontend-bundle-generated-chunks-design.md | 13 +++++++------ 3 files changed, 11 insertions(+), 16 deletions(-) diff --git a/.github/scripts/frontend-js-size.mts b/.github/scripts/frontend-js-size.mts index 370f49c082..db134bab79 100644 --- a/.github/scripts/frontend-js-size.mts +++ b/.github/scripts/frontend-js-size.mts @@ -456,6 +456,7 @@ function chunkMarkdownTable( ]; if (total != null) { lines.push(`| (total) | ${util.formatBytes(total.beforeSize)} | ${util.formatBytes(total.afterSize)} | ${util.calcAndFormatDeltaBytes(total.beforeSize, total.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(total.beforeSize, total.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`); + lines.push('| | | | | |'); } for (const row of rows) { @@ -468,17 +469,12 @@ function chunkMarkdownTable( lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(row.beforeSize, row.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`); } } - if (hasGenerated || hasOther) lines.push('| | | | | |'); if (hasGenerated) { lines.push(`| (other generated chunks) | ${util.formatBytes(generated.beforeSize)} | ${util.formatBytes(generated.afterSize)} | ${util.calcAndFormatDeltaBytes(generated.beforeSize, generated.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(generated.beforeSize, generated.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`); } if (hasOther) { lines.push(`| (other) | ${util.formatBytes(other.beforeSize)} | ${util.formatBytes(other.afterSize)} | ${util.calcAndFormatDeltaBytes(other.beforeSize, other.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(other.beforeSize, other.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`); } - if (hasGenerated) { - lines.push(''); - lines.push(`_${generated.beforeCount} before / ${generated.afterCount} after generated chunks are grouped._`); - } return lines.join('\n'); } diff --git a/.github/scripts/frontend-js-size.test.mts b/.github/scripts/frontend-js-size.test.mts index f688ae3270..d3b3db9af6 100644 --- a/.github/scripts/frontend-js-size.test.mts +++ b/.github/scripts/frontend-js-size.test.mts @@ -91,7 +91,7 @@ test('groups generated chunks while preserving full and startup totals', async t assert.match(report, /\| \(total\) \| 220 B \| 285 B \|/); assert.equal(report.match(/\| \(other generated chunks\) \| 30 B \| 70 B \|/g)?.length, 2); - assert.equal(report.match(/_2 before \/ 2 after generated chunks are grouped\._/g)?.length, 2); + assert.doesNotMatch(report, /generated chunks are grouped/); assert.doesNotMatch(report, /`(?:dist|esm)`<\/summary>/); assert.match(report, /`src\/_boot_\.ts`<\/summary>/); assert.match(report, /`vue`<\/summary>/); @@ -114,8 +114,8 @@ test('groups small deltas at the bottom while preserving all change counts', asy assert.match(report, /Startup chunk size \(2 updated, 1 added, 1 removed\)<\/summary>/); assert.equal(report.match(/`src\/_boot_\.ts`<\/summary>/g)?.length, 2); assert.doesNotMatch(report, /`(?:vue|i18n|src\/added-small\.ts|src\/removed-small\.ts)`<\/summary>/); - assert.match(report, /\| \(total\) \| 225 B \| 276 B \|[^\n]*\n\|
`src\/_boot_\.ts`<\/summary>[^\n]*\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\|
`src\/_boot_\.ts`<\/summary>[^\n]*\n\| \| \| \| \| \|\n\| \(other generated chunks\) \| 30 B \| 70 B \|[^\n]*\n\| \(other\) \| 95 B \| 100 B \|/); + assert.match(report, /\| \(total\) \| 225 B \| 276 B \|[^\n]*\n\| \| \| \| \| \|\n\|
`src\/_boot_\.ts`<\/summary>[^\n]*\n\| \(other generated chunks\) \| 30 B \| 70 B \|[^\n]*\n\| \(other\) \| 45 B \| 50 B \|/); + assert.match(report, /\| \(total\) \| 225 B \| 276 B \|[^\n]*\n\| \| \| \| \| \|\n\|
`src\/_boot_\.ts`<\/summary>[^\n]*\n\| \(other generated chunks\) \| 30 B \| 70 B \|[^\n]*\n\| \(other\) \| 95 B \| 100 B \|/); }); test('fails instead of overwriting duplicate stable chunk keys', async t => { @@ -224,7 +224,6 @@ test('counts an unmanifested localized JavaScript file in totals and the generat assert.match(report, /\| \(total\) \| 235 B \| 275 B \|/); assert.match(report, /\| \(other generated chunks\) \| 45 B \| 70 B \|/); - assert.match(report, /_3 before \/ 2 after generated chunks are grouped\._/); }); test('counts duplicate manifest entries for one physical output only once', async t => { @@ -238,7 +237,6 @@ test('counts duplicate manifest entries for one physical output only once', asyn ); assert.match(report, /\| \(total\) \| 220 B \| 275 B \|/); - assert.match(report, /_2 before \/ 2 after generated chunks are grouped\._/); }); test('does not count generated-aggregate-only changes as individual chunk changes', async t => { diff --git a/docs/superpowers/specs/2026-07-15-frontend-bundle-generated-chunks-design.md b/docs/superpowers/specs/2026-07-15-frontend-bundle-generated-chunks-design.md index 1cadf1f3fa..3a8c404a3e 100644 --- a/docs/superpowers/specs/2026-07-15-frontend-bundle-generated-chunks-design.md +++ b/docs/superpowers/specs/2026-07-15-frontend-bundle-generated-chunks-design.md @@ -95,9 +95,7 @@ The table starts with the existing `(total)` row. Generated chunks are rendered (other generated chunks) ``` -This row compares aggregate sizes, not individual chunk identities. Generated chunks do not participate in the updated/added/removed row counts. - -The report includes a short note stating how many generated chunks were grouped on each side. This makes the scope of the aggregate explicit without listing noisy filenames. +This row compares aggregate sizes, not individual chunk identities. Generated chunks do not participate in the updated/added/removed row counts. No additional generated-chunk count note is rendered below the table. ### Small-delta aggregation @@ -110,11 +108,13 @@ The updated/added/removed counts are calculated before small-delta rows are grou Table rows are ordered as follows: 1. `(total)`; -2. individual stable comparison rows whose absolute delta is greater than `5 B`; -3. one empty separator row, when at least one aggregate row follows; +2. one empty separator row; +3. individual stable comparison rows whose absolute delta is greater than `5 B`; 4. `(other generated chunks)`, when generated chunks exist; and 5. `(other)`, when small-delta stable chunks exist. +There is no additional separator row before the aggregate rows. If no individual stable row exists, the empty row after `(total)` is therefore immediately followed by the aggregate rows. + The existing 30-row limit applies after small-delta rows have been removed from the individual-row candidates. ### Startup chunk report @@ -165,7 +165,8 @@ Add focused fixtures or pure-function tests covering: - differing before/after filenames are rendered without attributing both sizes to one file; - deltas of exactly `5 B` are grouped into `(other)`, while `6 B` deltas remain individual; - small updated, added, and removed chunks remain included in the summary counts; -- `(other generated chunks)` and `(other)` appear below individual rows after an empty separator row; and +- an empty separator row appears immediately after `(total)`, with no additional separator before `(other generated chunks)` or `(other)`; +- no generated-chunk grouping note is rendered below the table; and - the same small-delta and ordering rules apply to the full and startup tables. Validation should include the focused tests and repository lint. No CHANGELOG entry is required because this changes developer-facing CI reporting rather than Misskey user behavior. From 3c1f01af10f1c10849eb4016c539158ee1c2016c Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:21:30 +0900 Subject: [PATCH 064/168] feat(dev): frontend browser metrics report (#17630) * wip * Update measure-frontend-browser-comparison.mts * Update frontend-browser-report.mts * Update frontend-browser-report.mts * Update frontend-browser-report.mts * Update frontend-browser-report.mts * Update frontend-browser-report.mts * Update frontend-browser-report.mts * Update frontend-browser-report.mts * refactor * fix * Update chrome.mts * refactor * wip * fix * Update frontend-browser-metrics-report.yml * Update frontend-browser-report.mts * refactor * wip * Update frontend-browser-report.mts * wip * wip * wip * Revert "wip" This reverts commit ce7657081bdd44393315d41a0c979b5527d879dd. * Update frontend-browser-metrics-report.yml * Update frontend-browser-report.mts * Update frontend-browser-report.mts * Update frontend-browser-report.mts * playwright * Update chrome.mts * refactor * wip * Update measure-frontend-browser-comparison.mts * clean up * Update measure-frontend-browser-comparison.mts * fix * Update chrome.mts * refactor * Update frontend-browser-metrics-report.yml --- .github/scripts/chrome.mts | 523 ++++++++++++++++++ .../frontend-browser-detailed-html.mts | 448 +++++++++++++++ .github/scripts/frontend-browser-report.mts | 378 +++++++++++++ .github/scripts/heap-snapshot-util.mts | 320 +++++++++++ .../measure-backend-memory-comparison.mts | 80 +-- .../measure-frontend-browser-comparison.mts | 274 +++++++++ .github/scripts/utility.mts | 94 +++- ...rontend-browser-metrics-report-comment.yml | 44 ++ .../frontend-browser-metrics-report.yml | 200 +++++++ packages/backend/scripts/measure-memory.mts | 246 +------- packages/frontend/test/e2e/basic.spec.ts | 12 +- packages/frontend/test/e2e/router.spec.ts | 7 +- packages/frontend/test/e2e/shared.ts | 135 +++++ packages/frontend/test/e2e/utils.ts | 94 +--- 14 files changed, 2456 insertions(+), 399 deletions(-) create mode 100644 .github/scripts/chrome.mts create mode 100644 .github/scripts/frontend-browser-detailed-html.mts create mode 100644 .github/scripts/frontend-browser-report.mts create mode 100644 .github/scripts/measure-frontend-browser-comparison.mts create mode 100644 .github/workflows/frontend-browser-metrics-report-comment.yml create mode 100644 .github/workflows/frontend-browser-metrics-report.yml create mode 100644 packages/frontend/test/e2e/shared.ts diff --git a/.github/scripts/chrome.mts b/.github/scripts/chrome.mts new file mode 100644 index 0000000000..5d07ac6efe --- /dev/null +++ b/.github/scripts/chrome.mts @@ -0,0 +1,523 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +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'; + +export type NetworkRequest = { + requestId: string; + url: string; + method: string; + resourceType: string; + startedAt: number; + documentUrl?: string; + requestHeaders?: Record; + requestBody?: string; + hasRequestBody: boolean; + status?: number; + statusText?: string; + mimeType?: string; + responseHeaders?: Record; + protocol?: string; + remoteIPAddress?: string; + remotePort?: number; + encodedDataLength: number; + decodedBodyLength: number; + fromDiskCache: boolean; + fromServiceWorker: boolean; + finished: boolean; + failed: boolean; + errorText?: string; +}; + +export type WebSocketConnection = { + requestId: string; + url: string; + createdAt: number; + handshakeRequestHeaders?: Record; + handshakeResponseStatus?: number; + handshakeResponseStatusText?: string; + handshakeResponseHeaders?: Record; + closedAt?: number; + sentFrameCount: number; + receivedFrameCount: number; + sentBytes: number; + receivedBytes: number; + errorCount: number; +}; + +export type NetworkSummary = { + requestCount: number; + webSocketConnectionCount: number; + webSocketSentBytes: number; + webSocketReceivedBytes: number; + finishedRequestCount: number; + failedRequestCount: number; + cachedRequestCount: number; + serviceWorkerRequestCount: number; + totalEncodedBytes: number; + totalDecodedBodyBytes: number; + sameOriginEncodedBytes: number; + thirdPartyEncodedBytes: number; + byResourceType: Record; + largestRequests: { + url: string; + method: string; + resourceType: string; + status?: number; + encodedBytes: number; + decodedBodyBytes: number; + }[]; + failedRequests: { + url: string; + method: string; + resourceType: string; + errorText?: string; + status?: number; + }[]; +}; + +export type TabMemory = { + totalBytes: number; +}; + +export type BrowserMeasurement = { + label: string; + timestamp: string; + url: string; + scenario: string; + durationMs: number; + network: NetworkSummary; + performance: { + cdpMetrics: Record; + runtimeHeap?: { + usedSize: number; + totalSize: number; + }; + tabMemory: TabMemory; + webVitals: { + firstPaintMs?: number; + firstContentfulPaintMs?: number; + domContentLoadedEventEndMs?: number; + loadEventEndMs?: number; + longTaskCount: number; + longTaskDurationMs: number; + maxLongTaskDurationMs: number; + resourceEntryCount: number; + domElements: number; + }; + }; + heapSnapshot: HeapSnapshotData; +}; + +type PlaywrightModule = typeof import('playwright'); + +const requireFromFrontend = createRequire(new URL('../../packages/frontend/package.json', import.meta.url)); + +function loadPlaywright(): PlaywrightModule { + return requireFromFrontend('playwright') as PlaywrightModule; +} + +function normalizeHeaders(headers: Record | undefined) { + if (headers == null) return undefined; + const normalized = {} as Record; + for (const [key, value] of Object.entries(headers)) { + normalized[key] = String(value); + } + return normalized; +} + +function webSocketFramePayloadBytes(frame: { opcode?: number; payloadData?: string } | undefined) { + if (frame?.payloadData == null) return 0; + if (frame.opcode === 1) return Buffer.byteLength(frame.payloadData, 'utf8'); + return Buffer.byteLength(frame.payloadData, 'base64'); +} + +type PlaywrightBrowserOptions = { + scenarioTimeoutMs: number; + baseUrl: string; +}; + +export class HeadlessChromeController { + public networkRequests: NetworkRequest[] = []; + public webSocketConnections: WebSocketConnection[] = []; + private readonly browser: Browser; + private readonly context: BrowserContext; + public readonly page: Page; + private readonly cdp: CDPSession; + private pendingNetworkDetailReads: Promise[] = []; + + private constructor( + browser: Browser, + context: BrowserContext, + page: Page, + cdp: CDPSession, + options: PlaywrightBrowserOptions, + ) { + this.browser = browser; + this.context = context; + this.page = page; + this.cdp = cdp; + this.page.setDefaultTimeout(options.scenarioTimeoutMs); + this.page.setDefaultNavigationTimeout(options.scenarioTimeoutMs); + } + + static async create(label: string, options: PlaywrightBrowserOptions): Promise { + process.stderr.write(`[${label}] Launching Playwright Chromium\n`); + const { chromium } = loadPlaywright(); + const browser = await chromium.launch({ + channel: 'chromium', + headless: true, + args: [ + '--disable-gpu', + '--disable-dev-shm-usage', + '--disable-background-networking', + '--disable-default-apps', + '--disable-extensions', + '--disable-sync', + '--metrics-recording-only', + '--no-first-run', + '--no-default-browser-check', + '--no-sandbox', + ], + }); + + try { + const context = await browser.newContext({ + baseURL: options.baseUrl, + locale: 'en-US', + }); + await context.addInitScript(() => { + // @ts-expect-error Test-only runtime hint consumed by Misskey frontend code. + window.isPlaywright = true; + }); + + const page = await context.newPage(); + const cdp = await context.newCDPSession(page); + return new HeadlessChromeController(browser, context, page, cdp, options); + } catch (error) { + await browser.close().catch(() => undefined); + throw error; + } + } + + static async with(label: string, options: PlaywrightBrowserOptions, callback: (browser: HeadlessChromeController) => T | Promise): Promise { + const browser = await HeadlessChromeController.create(label, options); + try { + return await callback(browser); + } finally { + await browser.close(); + } + } + + public async enableNetworkTracking() { + const requests = new Map(); + const webSockets = new Map(); + + const readRequestBody = (row: NetworkRequest) => { + if (!row.hasRequestBody || row.requestBody != null) return; + const pending = this.cdp.send<{ postData: string }>('Network.getRequestPostData', { + requestId: row.requestId, + }).then(result => { + row.requestBody = result.postData; + }).catch(() => { + // Some requests expose hasPostData but no longer have retrievable body data. + }); + this.pendingNetworkDetailReads.push(pending); + }; + + this.cdp.on('Network.requestWillBeSent', params => { + if (params.request?.url == null) return; + const row: NetworkRequest = { + requestId: params.requestId, + url: params.request.url, + method: params.request.method ?? 'GET', + resourceType: params.type ?? 'Other', + startedAt: params.timestamp ?? 0, + documentUrl: params.documentURL, + requestHeaders: normalizeHeaders(params.request.headers), + requestBody: typeof params.request.postData === 'string' ? params.request.postData : undefined, + hasRequestBody: params.request.hasPostData === true || typeof params.request.postData === 'string', + encodedDataLength: 0, + decodedBodyLength: 0, + fromDiskCache: false, + fromServiceWorker: false, + finished: false, + failed: false, + }; + requests.set(params.requestId, row); + this.networkRequests.push(row); + }); + + this.cdp.on('Network.webSocketCreated', params => { + if (params.requestId == null || params.url == null) return; + const row: WebSocketConnection = { + requestId: params.requestId, + url: params.url, + createdAt: params.timestamp ?? 0, + sentFrameCount: 0, + receivedFrameCount: 0, + sentBytes: 0, + receivedBytes: 0, + errorCount: 0, + }; + webSockets.set(params.requestId, row); + this.webSocketConnections.push(row); + }); + + this.cdp.on('Network.webSocketWillSendHandshakeRequest', params => { + const row = webSockets.get(params.requestId); + if (row == null) return; + row.handshakeRequestHeaders = normalizeHeaders(params.request?.headers); + }); + + this.cdp.on('Network.webSocketHandshakeResponseReceived', params => { + const row = webSockets.get(params.requestId); + if (row == null) return; + row.handshakeResponseStatus = params.response?.status; + row.handshakeResponseStatusText = params.response?.statusText; + row.handshakeResponseHeaders = normalizeHeaders(params.response?.headers); + }); + + this.cdp.on('Network.webSocketFrameSent', params => { + const row = webSockets.get(params.requestId); + if (row == null) return; + row.sentFrameCount += 1; + row.sentBytes += webSocketFramePayloadBytes(params.response); + }); + + this.cdp.on('Network.webSocketFrameReceived', params => { + const row = webSockets.get(params.requestId); + if (row == null) return; + row.receivedFrameCount += 1; + row.receivedBytes += webSocketFramePayloadBytes(params.response); + }); + + this.cdp.on('Network.webSocketFrameError', params => { + const row = webSockets.get(params.requestId); + if (row == null) return; + row.errorCount += 1; + }); + + this.cdp.on('Network.webSocketClosed', params => { + const row = webSockets.get(params.requestId); + if (row == null) return; + row.closedAt = params.timestamp ?? 0; + }); + + this.cdp.on('Network.responseReceived', params => { + const row = requests.get(params.requestId); + if (row == null) return; + row.status = params.response?.status; + row.statusText = params.response?.statusText; + row.mimeType = params.response?.mimeType; + row.responseHeaders = normalizeHeaders(params.response?.headers); + row.protocol = params.response?.protocol; + row.remoteIPAddress = params.response?.remoteIPAddress; + row.remotePort = params.response?.remotePort; + row.requestHeaders ??= normalizeHeaders(params.response?.requestHeaders); + row.fromDiskCache = params.response?.fromDiskCache === true; + row.fromServiceWorker = params.response?.fromServiceWorker === true; + }); + + this.cdp.on('Network.dataReceived', params => { + const row = requests.get(params.requestId); + if (row == null) return; + row.decodedBodyLength += params.dataLength ?? 0; + row.encodedDataLength += params.encodedDataLength ?? 0; + }); + + this.cdp.on('Network.loadingFinished', params => { + const row = requests.get(params.requestId); + if (row == null) return; + row.finished = true; + row.encodedDataLength = Math.max(row.encodedDataLength, params.encodedDataLength ?? 0); + readRequestBody(row); + }); + + this.cdp.on('Network.loadingFailed', params => { + const row = requests.get(params.requestId); + if (row == null) return; + row.failed = true; + row.finished = true; + row.errorText = params.errorText; + readRequestBody(row); + }); + + await this.cdp.send('Network.enable'); + await this.cdp.send('Network.setCacheDisabled', { cacheDisabled: true }); + await this.cdp.send('Network.setBypassServiceWorker', { bypass: true }); + await this.cdp.send('Page.enable'); + await this.cdp.send('Runtime.enable'); + await this.cdp.send('Performance.enable'); + } + + public async waitForNetworkDetails() { + let settledCount = 0; + while (settledCount < this.pendingNetworkDetailReads.length) { + const pending = this.pendingNetworkDetailReads.slice(settledCount); + settledCount = this.pendingNetworkDetailReads.length; + await Promise.allSettled(pending); + } + } + + public async evaluate(expression: string, timeoutMs = 30_000): Promise { + return await Promise.race([ + this.page.evaluate(expression), + new Promise((_, reject) => setTimeout(() => reject(new Error(`Playwright evaluate timed out after ${timeoutMs}ms`)), timeoutMs).unref()), + ]) as T; + } + + public async collectPerformance(): Promise { + const cdpMetricsResult = await this.cdp.send<{ metrics: { name: string; value: number }[] }>('Performance.getMetrics'); + const cdpMetrics = Object.fromEntries(cdpMetricsResult.metrics.map(metric => [metric.name, metric.value])); + const runtimeHeap = await this.cdp.send<{ usedSize: number; totalSize: number }>('Runtime.getHeapUsage').catch(() => undefined); + const tabMemory = await this.collectTabMemory(); + const webVitals = await this.evaluate(`(() => { + const navigation = performance.getEntriesByType('navigation')[0]; + const paintEntries = Object.fromEntries(performance.getEntriesByType('paint').map(entry => [entry.name, entry.startTime])); + const longTasks = performance.getEntriesByType('longtask'); + const resourceEntries = performance.getEntriesByType('resource'); + return { + firstPaintMs: paintEntries['first-paint'], + firstContentfulPaintMs: paintEntries['first-contentful-paint'], + domContentLoadedEventEndMs: navigation?.domContentLoadedEventEnd, + loadEventEndMs: navigation?.loadEventEnd, + longTaskCount: longTasks.length, + longTaskDurationMs: longTasks.reduce((sum, entry) => sum + entry.duration, 0), + maxLongTaskDurationMs: longTasks.reduce((max, entry) => Math.max(max, entry.duration), 0), + resourceEntryCount: resourceEntries.length, + domElements: document.getElementsByTagName('*').length, + }; + })()`); + + return { + cdpMetrics, + runtimeHeap, + tabMemory, + webVitals, + }; + } + + public async collectTabMemory(): Promise { + const userAgentSpecificMemory = await this.evaluate<{ bytes?: number }>(`(async () => { + const measureMemory = performance.measureUserAgentSpecificMemory; + if (typeof measureMemory !== 'function') return {}; + const result = await measureMemory.call(performance); + return { bytes: result.bytes }; + })()`, 60_000); + + const userAgentSpecificBytes = userAgentSpecificMemory?.bytes; + if (!Number.isFinite(userAgentSpecificBytes)) { + throw new Error('performance.measureUserAgentSpecificMemory() did not return finite bytes'); + } + + return { + totalBytes: userAgentSpecificBytes as number, + }; + } + + public async takeHeapSnapshot(savePath?: string) { + const chunks: string[] = []; + this.cdp.on('HeapProfiler.addHeapSnapshotChunk', params => { + chunks.push(params.chunk); + }); + + await this.cdp.send('HeapProfiler.enable'); + await this.cdp.send('HeapProfiler.collectGarbage'); + await this.cdp.send('HeapProfiler.takeHeapSnapshot', { reportProgress: false }); + + const content = chunks.join(''); + if (savePath != null) { + await writeFile(savePath, content); + } + + return JSON.parse(content); + } + + public async close() { + await this.cdp.detach().catch(() => undefined); + await this.context.close().catch(() => undefined); + await this.browser.close().catch(() => undefined); + } +} + +function isMeasurableRequest(row: NetworkRequest) { + return !row.url.startsWith('data:') && !row.url.startsWith('blob:') && !row.url.startsWith('devtools:'); +} + +export function summarizeNetwork(requestRows: NetworkRequest[], baseUrl: string, webSocketRows?: WebSocketConnection[]): NetworkSummary { + const origin = new URL(baseUrl).origin; + const rows = requestRows.filter(isMeasurableRequest); + const byResourceType = {} as NetworkSummary['byResourceType']; + + for (const row of rows) { + const summary = byResourceType[row.resourceType] ?? { + requests: 0, + encodedBytes: 0, + decodedBodyBytes: 0, + }; + summary.requests += 1; + summary.encodedBytes += row.encodedDataLength; + summary.decodedBodyBytes += row.decodedBodyLength; + byResourceType[row.resourceType] = summary; + } + + function isSameOrigin(url: string) { + try { + return new URL(url).origin === origin; + } catch { + return false; + } + } + + return { + requestCount: rows.length, + webSocketConnectionCount: webSocketRows == null + ? rows.filter(row => row.resourceType === 'WebSocket').length + : webSocketRows.length, + webSocketSentBytes: webSocketRows?.reduce((sum, row) => sum + row.sentBytes, 0) ?? 0, + webSocketReceivedBytes: webSocketRows?.reduce((sum, row) => sum + row.receivedBytes, 0) ?? 0, + finishedRequestCount: rows.filter(row => row.finished).length, + failedRequestCount: rows.filter(row => row.failed).length, + cachedRequestCount: rows.filter(row => row.fromDiskCache).length, + serviceWorkerRequestCount: rows.filter(row => row.fromServiceWorker).length, + totalEncodedBytes: rows.reduce((sum, row) => sum + row.encodedDataLength, 0), + totalDecodedBodyBytes: rows.reduce((sum, row) => sum + row.decodedBodyLength, 0), + sameOriginEncodedBytes: rows + .filter(row => isSameOrigin(row.url)) + .reduce((sum, row) => sum + row.encodedDataLength, 0), + thirdPartyEncodedBytes: rows + .filter(row => !isSameOrigin(row.url)) + .reduce((sum, row) => sum + row.encodedDataLength, 0), + byResourceType, + largestRequests: rows + .toSorted((a, b) => b.encodedDataLength - a.encodedDataLength) + .slice(0, 15) + .map(row => ({ + url: row.url, + method: row.method, + resourceType: row.resourceType, + status: row.status, + encodedBytes: row.encodedDataLength, + decodedBodyBytes: row.decodedBodyLength, + })), + failedRequests: rows + .filter(row => row.failed) + .map(row => ({ + url: row.url, + method: row.method, + resourceType: row.resourceType, + errorText: row.errorText, + status: row.status, + })), + }; +} diff --git a/.github/scripts/frontend-browser-detailed-html.mts b/.github/scripts/frontend-browser-detailed-html.mts new file mode 100644 index 0000000000..34240eefc6 --- /dev/null +++ b/.github/scripts/frontend-browser-detailed-html.mts @@ -0,0 +1,448 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +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 { NetworkRequest } from './chrome.mts'; + +type DiffDirection = 'added' | 'removed'; + +type RequestDiff = { + direction: DiffDirection; + round: number; + baseCount: number; + headCount: number; + request: NetworkRequest; +}; + +function escapeHtml(value: unknown) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function escapeAttribute(value: unknown) { + return escapeHtml(value); +} + +function isHttpRequest(request: NetworkRequest) { + try { + const { protocol } = new URL(request.url); + return protocol === 'http:' || protocol === 'https:'; + } catch { + return false; + } +} + +function requestKey(request: NetworkRequest) { + return [ + request.method, + request.resourceType, + request.url, + ].join('\u0000'); +} + +function groupRequests(requests: NetworkRequest[] | undefined) { + const grouped = new Map(); + for (const request of requests ?? []) { + if (!isHttpRequest(request)) continue; + const key = requestKey(request); + const rows = grouped.get(key) ?? []; + rows.push(request); + grouped.set(key, rows); + } + return grouped; +} + +function byRound(samples: BrowserMeasurementSample[]) { + return new Map(samples.map(sample => [sample.round, sample])); +} + +function diffRound(round: number, baseSample: BrowserMeasurementSample | undefined, headSample: BrowserMeasurementSample | undefined) { + const baseRequests = groupRequests(baseSample?.networkRequests); + const headRequests = groupRequests(headSample?.networkRequests); + const keys = [...new Set([ + ...baseRequests.keys(), + ...headRequests.keys(), + ])].toSorted(); + const diffs: RequestDiff[] = []; + + for (const key of keys) { + const baseRows = baseRequests.get(key) ?? []; + const headRows = headRequests.get(key) ?? []; + if (headRows.length > baseRows.length) { + for (const request of headRows.slice(baseRows.length)) { + diffs.push({ + direction: 'added', + round, + baseCount: baseRows.length, + headCount: headRows.length, + request, + }); + } + } else if (baseRows.length > headRows.length) { + for (const request of baseRows.slice(headRows.length)) { + diffs.push({ + direction: 'removed', + round, + baseCount: baseRows.length, + headCount: headRows.length, + request, + }); + } + } + } + + return diffs; +} + +function diffReports(base: BrowserMetricsReport, head: BrowserMetricsReport) { + const baseSamples = byRound(base.samples); + const headSamples = byRound(head.samples); + const rounds = [...new Set([ + ...baseSamples.keys(), + ...headSamples.keys(), + ])].toSorted((a, b) => a - b); + return rounds.flatMap(round => diffRound(round, baseSamples.get(round), headSamples.get(round))); +} + +function formatMaybeJson(value: string | undefined) { + if (value == null || value === '') return null; + try { + return JSON.stringify(JSON.parse(value), null, '\t'); + } catch { + return value; + } +} + +function formatHeaders(headers: Record | undefined) { + if (headers == null || Object.keys(headers).length === 0) return null; + return JSON.stringify(headers, null, '\t'); +} + +function countBy(diffs: RequestDiff[], getKey: (diff: RequestDiff) => T) { + const counts = new Map(); + for (const diff of diffs) { + counts.set(getKey(diff), (counts.get(getKey(diff)) ?? 0) + 1); + } + return [...counts].toSorted((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])); +} + +function renderSummary(base: BrowserMetricsReport, head: BrowserMetricsReport, diffs: RequestDiff[]) { + const added = diffs.filter(diff => diff.direction === 'added').length; + const removed = diffs.filter(diff => diff.direction === 'removed').length; + const typeRows = countBy(diffs, diff => diff.request.resourceType).map(([type, count]) => ` + + ${escapeHtml(type)} + ${util.formatNumber(count)} + `).join(''); + + return ` +
+
+ Base samples + ${util.formatNumber(base.sampleCount)} +
+
+ Head samples + ${util.formatNumber(head.sampleCount)} +
+
+ Added in Head + ${util.formatNumber(added)} +
+
+ Removed in Head + ${util.formatNumber(removed)} +
+
+ ${typeRows === '' ? '' : ` +
+

Diffs by Resource Type

+ + + ${typeRows} + +
TypeDiff requests
+
`}`; +} + +function renderDetails(title: string, content: string | null, open = false) { + if (content == null || content === '') return ''; + return ` + + ${escapeHtml(title)} +
${escapeHtml(content)}
+
`; +} + +function renderRequest(diff: RequestDiff) { + const { request } = diff; + const requestBody = formatMaybeJson(request.requestBody); + const requestHeaders = formatHeaders(request.requestHeaders); + const responseHeaders = formatHeaders(request.responseHeaders); + const bodyNote = requestBody == null && request.hasRequestBody === true + ? '

Request body was present but could not be retrieved from CDP.

' + : ''; + + return ` +
+
+ ${diff.direction === 'added' ? 'Added in Head' : 'Removed in Head'} + ${escapeHtml(request.method)} + ${escapeHtml(request.resourceType)} + ${escapeHtml(request.status ?? '-')} +
+ ${escapeHtml(request.url)} +
+
Round
${util.formatNumber(diff.round)}
+
Base count
${util.formatNumber(diff.baseCount)}
+
Head count
${util.formatNumber(diff.headCount)}
+
Encoded
${util.formatBytes(request.encodedDataLength ?? 0)}
+
Decoded body
${util.formatBytes(request.decodedBodyLength ?? 0)}
+
MIME
${escapeHtml(request.mimeType ?? '-')}
+
Protocol
${escapeHtml(request.protocol ?? '-')}
+
Remote
${escapeHtml(request.remoteIPAddress == null ? '-' : `${request.remoteIPAddress}:${request.remotePort ?? ''}`)}
+
Failed
${request.failed ? escapeHtml(request.errorText ?? 'yes') : 'no'}
+
+ ${bodyNote} + ${renderDetails('Request body', requestBody, requestBody != null)} + ${renderDetails('Request headers', requestHeaders)} + ${renderDetails('Response headers', responseHeaders)} +
`; +} + +function renderRound(round: number, diffs: RequestDiff[]) { + const added = diffs.filter(diff => diff.direction === 'added').length; + const removed = diffs.filter(diff => diff.direction === 'removed').length; + return ` +
+

Round ${util.formatNumber(round)}

+

${util.formatNumber(added)} added, ${util.formatNumber(removed)} removed

+
+ ${diffs.map(renderRequest).join('\n')} +
+
`; +} + +function renderHtml(base: BrowserMetricsReport, head: BrowserMetricsReport) { + const diffs = diffReports(base, head); + const rounds = [...new Set(diffs.map(diff => diff.round))].toSorted((a, b) => a - b); + const generatedAt = new Date().toISOString(); + const content = diffs.length === 0 + ? '

No added or removed HTTP(S) requests were found in paired samples.

' + : rounds.map(round => renderRound(round, diffs.filter(diff => diff.round === round))).join('\n'); + + return ` + + + + + Frontend Browser Network Request Diff + + + +
+

Frontend Browser Network Request Diff

+

Generated at ${escapeHtml(generatedAt)}. Requests are compared per paired round by method, resource type, and exact URL. Bodies are shown for added/removed request instances when CDP exposes them.

+ ${renderSummary(base, head, diffs)} + ${content} +
+ + +`; +} + +async function main() { + const [baseFile, headFile, outputFile] = process.argv.slice(2); + if (baseFile == null || headFile == null || outputFile == null) { + throw new Error('Usage: node frontend-browser-detailed-html.mts '); + } + + const base = JSON.parse(await readFile(baseFile, 'utf8')) as BrowserMetricsReport; + const head = JSON.parse(await readFile(headFile, 'utf8')) as BrowserMetricsReport; + await writeFile(outputFile, renderHtml(base, head)); +} + +if (process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main(); +} diff --git a/.github/scripts/frontend-browser-report.mts b/.github/scripts/frontend-browser-report.mts new file mode 100644 index 0000000000..755b29bc88 --- /dev/null +++ b/.github/scripts/frontend-browser-report.mts @@ -0,0 +1,378 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { readFile, writeFile } from 'node:fs/promises'; +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'; + +export type BrowserMeasurement = { + label: string; + timestamp: string; + url: string; + scenario: string; + durationMs: number; + network: { + requestCount: number; + webSocketConnectionCount: number; + webSocketSentBytes: number; + webSocketReceivedBytes: number; + finishedRequestCount: number; + failedRequestCount: number; + cachedRequestCount: number; + serviceWorkerRequestCount: number; + totalEncodedBytes: number; + totalDecodedBodyBytes: number; + sameOriginEncodedBytes: number; + thirdPartyEncodedBytes: number; + byResourceType: Record; + largestRequests: { + url: string; + method: string; + resourceType: string; + status?: number; + encodedBytes: number; + decodedBodyBytes: number; + }[]; + failedRequests: { + url: string; + method: string; + resourceType: string; + errorText?: string; + status?: number; + }[]; + }; + performance: { + cdpMetrics: Record; + runtimeHeap?: { + usedSize: number; + totalSize: number; + }; + tabMemory: { + totalBytes: number; + }; + webVitals: { + firstPaintMs?: number; + firstContentfulPaintMs?: number; + domContentLoadedEventEndMs?: number; + loadEventEndMs?: number; + longTaskCount: number; + longTaskDurationMs: number; + maxLongTaskDurationMs: number; + resourceEntryCount: number; + domElements: number; + }; + }; + heapSnapshot: HeapSnapshotData; +}; + +export type BrowserMeasurementSample = BrowserMeasurement & { + round: number; + networkRequests?: NetworkRequest[]; +}; + +export type BrowserMetricsReport = { + label: string; + timestamp: string; + url: string; + scenario: string; + sampleCount: number; + aggregation: 'median'; + summary: BrowserMeasurement; + samples: BrowserMeasurementSample[]; +}; + +function escapeCell(value: string) { + return String(value).replaceAll('|', '\\|').replaceAll('\n', '
'); +} + +function truncate(value: string, maxLength = 140) { + if (value.length <= maxLength) return value; + return `${value.slice(0, maxLength - 3)}...`; +} + +function formatMs(value: number | null | undefined) { + if (value == null || !Number.isFinite(value)) return '-'; + if (value >= 1_000) return `${util.formatNumber(value / 1_000)} s`; + return `${util.formatNumber(value)} ms`; +} + +function formatSecondsAsMs(value: number | null | undefined) { + if (value == null || !Number.isFinite(value)) return '-'; + return formatMs(value * 1_000); +} + +function formatDelta(delta: number, formatter: (value: number) => string, colorThreshold = 0) { + if (delta === 0) return formatter(0); + return util.formatColoredDelta(delta, v => formatter(v), colorThreshold); +} + +function finiteValues(values: (number | null | undefined)[]) { + return values.filter(value => Number.isFinite(value)) as number[]; +} + +function sampleSpread(report: BrowserMetricsReport, getValue: (sample: BrowserMeasurementSample) => number | null | undefined) { + const values = finiteValues(report.samples.map(sample => getValue(sample))); + if (values.length < 2) return null; + + const center = util.median(values); + return util.median(values.map(value => Math.abs(value - center))); +} + +function formatValueWithSpread(report: BrowserMetricsReport, value: number, getSampleValue: (sample: BrowserMeasurementSample) => number | null | undefined, formatter: (value: number) => string) { + const spread = sampleSpread(report, getSampleValue); + if (spread == null) return formatter(value); + return `${formatter(value)}
± ${formatter(spread)}`; +} + +function metricRow( + label: string, + base: BrowserMetricsReport, + head: BrowserMetricsReport, + getSummaryValue: (summary: BrowserMeasurement) => number, + getSampleValue: (sample: BrowserMeasurementSample) => number, + formatter: (value: number) => string, + significantThreshold = 0, + skipIfNotSignificant = true +) { + const baseValue = getSummaryValue(base.summary); + const headValue = getSummaryValue(head.summary); + if (baseValue == null || headValue == null || !Number.isFinite(baseValue) || !Number.isFinite(headValue)) return null; + + const summary = util.pairedDeltaSummary(base.samples, head.samples, sample => getSampleValue(sample)); + // 有意な閾値に満たない場合はそもそもrowとして出力しない + if (skipIfNotSignificant && (Math.abs(summary.median) < significantThreshold)) return null; + + const percent = baseValue === 0 ? null : summary.median * 100 / baseValue; + //const deltaMedian = `${formatDelta(summary.median, formatter, colorThreshold)}
${percent == null ? '-' : util.formatDeltaPercent(percent, 0.1).replaceAll('\\%', '\\\\%')}`; + const deltaMedian = formatDelta(summary.median, formatter, significantThreshold); + + //return `| **${label}** | ${formatValueWithSpread(base, baseValue, getSampleValue, formatter)} | ${formatValueWithSpread(head, headValue, getSampleValue, formatter)} | ${deltaMedian} | ${summary == null ? '-' : formatter(summary.mad)} | ${summary == null ? '-' : formatDelta(summary.min, formatter)} | ${summary == null ? '-' : formatDelta(summary.max, formatter)} |`; + return `| **${label}** | ${formatter(baseValue)} | ${formatter(headValue)} | ${deltaMedian} | ${summary == null ? '-' : formatter(summary.mad)} | ${summary == null ? '-' : formatDelta(summary.min, formatter, significantThreshold)} | ${summary == null ? '-' : formatDelta(summary.max, formatter, significantThreshold)} |`; +} + +function resourceTypeBytes(report: BrowserMeasurement, resourceTypes: string[]) { + return resourceTypes.reduce((sum, resourceType) => sum + (report.network.byResourceType[resourceType]?.encodedBytes ?? 0), 0); +} + +function resourceTypeSampleBytes(sample: BrowserMeasurementSample, resourceTypes: string[]) { + return resourceTypeBytes(sample, resourceTypes); +} + +function getMetric(report: BrowserMeasurement, key: string) { + return report.performance.cdpMetrics[key]; +} + +function renderSummaryTable(base: BrowserMetricsReport, head: BrowserMetricsReport, all = false) { + const rows = [ + //metricRow('Scenario duration', base, head, summary => summary.durationMs, sample => sample.durationMs, formatMs), + metricRow('Requests', base, head, summary => summary.network.requestCount, sample => sample.network.requestCount, util.formatNumber, 1, !all), + //metricRow('Failed requests', base, head, summary => summary.network.failedRequestCount, sample => sample.network.failedRequestCount, util.formatNumber), + metricRow('Encoded network', base, head, summary => summary.network.totalEncodedBytes, sample => sample.network.totalEncodedBytes, util.formatBytes, 10000, !all), + metricRow('Decoded body', base, head, summary => summary.network.totalDecodedBodyBytes, sample => sample.network.totalDecodedBodyBytes, util.formatBytes, 10000, !all), + metricRow('Same-origin encoded', base, head, summary => summary.network.sameOriginEncodedBytes, sample => sample.network.sameOriginEncodedBytes, util.formatBytes, 10000, !all), + metricRow('Third-party encoded', base, head, summary => summary.network.thirdPartyEncodedBytes, sample => sample.network.thirdPartyEncodedBytes, util.formatBytes, 10000, !all), + metricRow('Script encoded', base, head, summary => resourceTypeBytes(summary, ['Script']), sample => resourceTypeSampleBytes(sample, ['Script']), util.formatBytes, 10000, !all), + metricRow('Stylesheet encoded', base, head, summary => resourceTypeBytes(summary, ['Stylesheet']), sample => resourceTypeSampleBytes(sample, ['Stylesheet']), util.formatBytes, 10000, !all), + metricRow('Fetch/XHR encoded', base, head, summary => resourceTypeBytes(summary, ['Fetch', 'XHR']), sample => resourceTypeSampleBytes(sample, ['Fetch', 'XHR']), util.formatBytes, 10000, !all), + metricRow('Image encoded', base, head, summary => resourceTypeBytes(summary, ['Image']), sample => resourceTypeSampleBytes(sample, ['Image']), util.formatBytes, 10000, !all), + metricRow('Font encoded', base, head, summary => resourceTypeBytes(summary, ['Font']), sample => resourceTypeSampleBytes(sample, ['Font']), util.formatBytes, 10000, !all), + //metricRow('First contentful paint', base, head, summary => summary.performance.webVitals.firstContentfulPaintMs, sample => sample.performance.webVitals.firstContentfulPaintMs, formatMs), + //metricRow('Load event end', base, head, summary => summary.performance.webVitals.loadEventEndMs, sample => sample.performance.webVitals.loadEventEndMs, formatMs), + //metricRow('Long tasks', base, head, summary => summary.performance.webVitals.longTaskCount, sample => sample.performance.webVitals.longTaskCount, util.formatNumber), + //metricRow('Long task duration', base, head, summary => summary.performance.webVitals.longTaskDurationMs, sample => sample.performance.webVitals.longTaskDurationMs, formatMs), + //metricRow('Max long task', base, head, summary => summary.performance.webVitals.maxLongTaskDurationMs, sample => sample.performance.webVitals.maxLongTaskDurationMs, formatMs), + //metricRow('JS heap used', base, head, summary => summary.performance.runtimeHeap?.usedSize ?? getMetric(summary, 'JSHeapUsedSize'), sample => sample.performance.runtimeHeap?.usedSize ?? getMetric(sample, 'JSHeapUsedSize'), util.formatBytes), + //metricRow('JS heap total', base, head, summary => summary.performance.runtimeHeap?.totalSize ?? getMetric(summary, 'JSHeapTotalSize'), sample => sample.performance.runtimeHeap?.totalSize ?? getMetric(sample, 'JSHeapTotalSize'), util.formatBytes), + //metricRow('V8 heap snapshot total', base, head, summary => summary.heapSnapshot.categories.total, sample => sample.heapSnapshot.categories.total, util.formatBytes, 10000), + //metricRow('DOM elements', base, head, summary => summary.performance.webVitals.domElements, sample => sample.performance.webVitals.domElements, util.formatNumber), + //metricRow('CDP nodes', base, head, summary => getMetric(summary, 'Nodes'), sample => getMetric(sample, 'Nodes'), util.formatNumber), + //metricRow('JS event listeners', base, head, summary => getMetric(summary, 'JSEventListeners'), sample => getMetric(sample, 'JSEventListeners'), util.formatNumber), + //metricRow('Layout count', base, head, summary => getMetric(summary, 'LayoutCount'), sample => getMetric(sample, 'LayoutCount'), util.formatNumber), + //metricRow('Recalc style count', base, head, summary => getMetric(summary, 'RecalcStyleCount'), sample => getMetric(sample, 'RecalcStyleCount'), util.formatNumber), + //metricRow('Script duration', base, head, summary => getMetric(summary, 'ScriptDuration'), sample => getMetric(sample, 'ScriptDuration'), formatSecondsAsMs), + //metricRow('Task duration', base, head, summary => getMetric(summary, 'TaskDuration'), sample => getMetric(sample, 'TaskDuration'), formatSecondsAsMs), + 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), + ].filter(row => row != null); + + return [ + '| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |', + '| --- | ---: | ---: | ---: | ---: | ---: | ---: |', + ...rows, + ].join('\n'); +} + +function renderResourceTypeTable(base: BrowserMetricsReport, head: BrowserMetricsReport) { + const preferredOrder = ['Document', 'Script', 'Stylesheet', 'Fetch', 'XHR', 'Image', 'Font', 'Media', 'WebSocket', 'EventSource', 'Other']; + const keys = [...new Set([ + ...preferredOrder, + ...Object.keys(base.summary.network.byResourceType), + ...Object.keys(head.summary.network.byResourceType), + ])].filter(key => base.summary.network.byResourceType[key] != null || head.summary.network.byResourceType[key] != null); + + const lines = [ + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + ]; + + for (const key of keys) { + const baseRow = base.summary.network.byResourceType[key] ?? { requests: 0, encodedBytes: 0 }; + const headRow = head.summary.network.byResourceType[key] ?? { requests: 0, encodedBytes: 0 }; + lines.push(''); + lines.push(``); + lines.push(``); + lines.push(``); + lines.push(``); + lines.push(``); + lines.push(``); + lines.push(``); + lines.push(''); + } + + lines.push(''); + lines.push('
TypeRequestsEncoded bytes
BaseHeadΔBaseHeadΔ
${key}${util.formatNumber(baseRow.requests)}${util.formatNumber(headRow.requests)}${formatDelta(headRow.requests - baseRow.requests, util.formatNumber)}${util.formatBytes(baseRow.encodedBytes)}${util.formatBytes(headRow.encodedBytes)}${formatDelta(headRow.encodedBytes - baseRow.encodedBytes, util.formatBytes)}
'); + + return lines.join('\n'); +} + +function renderLargestRequests(report: BrowserMetricsReport, title: string) { + if (report.summary.network.largestRequests.length === 0) return null; + + const lines = [ + `
${title}`, + '', + '| Resource | Type | Status | Encoded | Decoded |', + '| --- | --- | ---: | ---: | ---: |', + ]; + + for (const request of report.summary.network.largestRequests.slice(0, 10)) { + lines.push(`| \`${escapeCell(truncate(request.url))}\` | ${escapeCell(request.resourceType)} | ${request.status ?? '-'} | ${util.formatBytes(request.encodedBytes)} | ${util.formatBytes(request.decodedBodyBytes)} |`); + } + + lines.push('', '
'); + return lines.join('\n'); +} + +function renderFailedRequests(report: BrowserMetricsReport, title: string) { + if (report.summary.network.failedRequests.length === 0) return null; + + const lines = [ + `
${title}`, + '', + '| Resource | Type | Status | Error |', + '| --- | --- | ---: | --- |', + ]; + + for (const request of report.summary.network.failedRequests.slice(0, 20)) { + lines.push(`| \`${escapeCell(truncate(request.url))}\` | ${escapeCell(request.resourceType)} | ${request.status ?? '-'} | ${escapeCell(request.errorText ?? '')} |`); + } + + lines.push('', '
'); + return lines.join('\n'); +} + +function toHeapSnapshotReport(report: BrowserMetricsReport): HeapSnapshotReport { + return { + summary: report.summary.heapSnapshot, + samples: report.samples.map(sample => ({ + round: sample.round, + data: sample.heapSnapshot, + })), + }; +} + +export function renderFrontendBrowserReport(base: BrowserMetricsReport, head: BrowserMetricsReport, options: { + 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.', + '', + 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.`, + //'', + detailedHtmlUrl == null || detailedHtmlUrl === '' ? null : `[View details](${detailedHtmlUrl})`, + detailedHtmlUrl == null || detailedHtmlUrl === '' ? null : '', + '
', + 'Requests by resource type', + '', + renderResourceTypeTable(base, head), + '', + '
', + '', + '
', + 'V8 heap snapshot statistics', + '', + heapSnapshotTable ?? '_No V8 heap snapshot data._', + '', + heapSnapshotUtil.renderHeapSnapshotSankey(toHeapSnapshotReport(head), 'Head'), + '', + `[Download representative head heap snapshot](${headHeapSnapshotUrl})`, + '
', + '', + ]; + + for (const section of [ + //renderLargestRequests(head, 'Largest representative head requests'), + //renderFailedRequests(base, 'Failed representative base requests'), + //renderFailedRequests(head, 'Failed representative head requests'), + ]) { + if (section == null) continue; + lines.push(section, ''); + } + + return lines.filter(line => line != null).join('\n').trimEnd() + '\n'; +} + +async function main() { + const [baseFile, headFile, outputFile] = process.argv.slice(2); + if (baseFile == null || headFile == null || outputFile == null) { + throw new Error('Usage: node frontend-browser-report.mts '); + } + + const base = JSON.parse(await readFile(baseFile, 'utf8')) as BrowserMetricsReport; + const head = JSON.parse(await readFile(headFile, 'utf8')) as BrowserMetricsReport; + await writeFile(outputFile, renderFrontendBrowserReport(base, head, { + headHeapSnapshotUrl: process.env.FRONTEND_BROWSER_HEAD_HEAP_SNAPSHOT_ARTIFACT_URL, + detailedHtmlUrl: process.env.FRONTEND_BROWSER_DETAILED_HTML_ARTIFACT_URL, + })); +} + +if (process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main(); +} diff --git a/.github/scripts/heap-snapshot-util.mts b/.github/scripts/heap-snapshot-util.mts index c99ce5f441..92e36188c1 100644 --- a/.github/scripts/heap-snapshot-util.mts +++ b/.github/scripts/heap-snapshot-util.mts @@ -32,6 +32,326 @@ export type HeapSnapshotReport = { }[]; }; +export const defaultHeapSnapshotBreakdownTopN = 6; + +export function createEmptyHeapSnapshotData(): HeapSnapshotData { + const categories = {} as HeapSnapshotData['categories']; + const nodeCounts = {} as HeapSnapshotData['nodeCounts']; + for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) { + categories[category] = 0; + nodeCounts[category] = 0; + } + return { + categories, + nodeCounts, + breakdowns: {} as HeapSnapshotData['breakdowns'], + }; +} + +function sanitizeHeapSnapshotBreakdownLabel(value: unknown, fallback = 'unknown') { + const label = String(value ?? '').replace(/\s+/g, ' ').trim(); + if (label === '') return fallback; + if (label.length <= 80) return label; + return `${label.slice(0, 77)}...`; +} + +function classifyHeapSnapshotBreakdown(category: keyof typeof heapSnapshotCategory, type: string, name: string) { + if (category === 'strings') return type; + + if (category === 'jsArrays') { + if (type === 'array elements') return 'Array elements'; + if (type === 'object' && name === 'Array') return 'Array objects'; + return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`); + } + + if (category === 'typedArrays') { + if (name === 'system / JSArrayBufferData') return 'ArrayBuffer data'; + return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`); + } + + if (category === 'systemObjects') { + if (name.startsWith('system /')) return sanitizeHeapSnapshotBreakdownLabel(name); + if (name.startsWith('(system ')) return sanitizeHeapSnapshotBreakdownLabel(name); + return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type); + } + + if (category === 'otherJsObjects') { + if (type === 'object') return sanitizeHeapSnapshotBreakdownLabel(`object: ${name}`, 'object: unknown'); + return type; + } + + if (category === 'otherNonJsObjects') { + if (type === 'extra native bytes') return 'Extra native bytes'; + if (type === 'native') return sanitizeHeapSnapshotBreakdownLabel(`native: ${name}`, 'native: unknown'); + return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type); + } + + if (category === 'code') { + const lowerName = name.toLowerCase(); + if (lowerName.includes('bytecode')) return 'bytecode'; + if (lowerName.includes('builtin')) return 'builtins'; + if (lowerName.includes('regexp')) return 'regexp code'; + if (lowerName.includes('stub')) return 'stubs'; + return sanitizeHeapSnapshotBreakdownLabel(`code: ${name}`, 'code: unknown'); + } + + return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type); +} + +export function collapseHeapSnapshotBreakdown(breakdown: Record, topN = defaultHeapSnapshotBreakdownTopN) { + const entries = Object.entries(breakdown) + .filter(([, value]) => value > 0) + .toSorted((a, b) => b[1] - a[1]); + + const topEntries = entries.slice(0, topN); + const otherValue = entries + .slice(topN) + .reduce((sum, [, value]) => sum + value, 0); + + const collapsed = Object.fromEntries(topEntries); + if (otherValue > 0) collapsed.Other = otherValue; + return collapsed; +} + +export function collapseHeapSnapshotBreakdowns( + breakdowns: Partial>>, + topN = defaultHeapSnapshotBreakdownTopN, +) { + const collapsed = {} as NonNullable; + for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) { + if (category === 'total') continue; + + const categoryBreakdown = breakdowns[category]; + if (categoryBreakdown == null) continue; + + const collapsedCategory = collapseHeapSnapshotBreakdown(categoryBreakdown, topN); + if (Object.keys(collapsedCategory).length > 0) { + collapsed[category] = collapsedCategory; + } + } + + return collapsed; +} + +// Keep these buckets aligned with Chrome DevTools' heap snapshot Statistics view. +export function analyzeHeapSnapshot(snapshot: any, options: { breakdownTopN?: number } = {}): HeapSnapshotData { + const meta = snapshot?.snapshot?.meta; + const nodes = snapshot?.nodes; + const edges = snapshot?.edges; + const strings = snapshot?.strings; + if (meta == null || !Array.isArray(nodes) || !Array.isArray(edges) || !Array.isArray(strings)) { + throw new Error('Invalid heap snapshot format'); + } + + const nodeFields = meta.node_fields; + if (!Array.isArray(nodeFields)) throw new Error('Invalid heap snapshot node fields'); + const edgeFields = meta.edge_fields; + if (!Array.isArray(edgeFields)) throw new Error('Invalid heap snapshot edge fields'); + + const typeOffset = nodeFields.indexOf('type'); + const nameOffset = nodeFields.indexOf('name'); + const selfSizeOffset = nodeFields.indexOf('self_size'); + const edgeCountOffset = nodeFields.indexOf('edge_count'); + if (typeOffset < 0 || nameOffset < 0 || selfSizeOffset < 0 || edgeCountOffset < 0) { + throw new Error('Heap snapshot is missing required node fields'); + } + const edgeTypeOffset = edgeFields.indexOf('type'); + const edgeNameOffset = edgeFields.indexOf('name_or_index'); + const edgeToNodeOffset = edgeFields.indexOf('to_node'); + if (edgeTypeOffset < 0 || edgeNameOffset < 0 || edgeToNodeOffset < 0) { + throw new Error('Heap snapshot is missing required edge fields'); + } + + const nodeTypeNames = meta.node_types?.[typeOffset]; + if (!Array.isArray(nodeTypeNames)) throw new Error('Invalid heap snapshot node types'); + const edgeTypeNames = meta.edge_types?.[edgeTypeOffset]; + if (!Array.isArray(edgeTypeNames)) throw new Error('Invalid heap snapshot edge types'); + + const nodeFieldCount = nodeFields.length; + const edgeFieldCount = edgeFields.length; + const nativeType = nodeTypeNames.indexOf('native'); + const codeType = nodeTypeNames.indexOf('code'); + const hiddenType = nodeTypeNames.indexOf('hidden'); + const stringTypes = new Set([ + nodeTypeNames.indexOf('string'), + nodeTypeNames.indexOf('concatenated string'), + nodeTypeNames.indexOf('sliced string'), + ]); + const internalEdgeType = edgeTypeNames.indexOf('internal'); + const extraNativeBytes = Number.isFinite(snapshot.snapshot.extra_native_bytes) ? snapshot.snapshot.extra_native_bytes : 0; + const { categories, nodeCounts } = createEmptyHeapSnapshotData(); + const breakdowns = {} as Record>; + for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) { + if (category !== 'total') breakdowns[category] = {}; + } + + function addValue(map: Record, key: string, value: number) { + map[key] = (map[key] ?? 0) + value; + } + + const edgeStartIndexes = new Map(); + const retainerCounts = new Map(); + let edgeIndex = 0; + for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) { + edgeStartIndexes.set(nodeIndex, edgeIndex); + const edgeCount = nodes[nodeIndex + edgeCountOffset] ?? 0; + for (let i = 0; i < edgeCount; i++, edgeIndex += edgeFieldCount) { + const toNodeIndex = edges[edgeIndex + edgeToNodeOffset]; + retainerCounts.set(toNodeIndex, (retainerCounts.get(toNodeIndex) ?? 0) + 1); + } + } + + const jsArrayElementNodeIndexes = new Set(); + + function addCategoryValue(category: keyof typeof heapSnapshotCategory, value: number, type: string, name: string, nodeIndex: number | null = null) { + if (value <= 0) return; + categories[category] += value; + addValue(breakdowns[category], classifyHeapSnapshotBreakdown(category, type, name), value); + if (nodeIndex != null) nodeCounts[category]++; + } + + function addJsArrayElementSize(nodeIndex: number) { + const beginEdgeIndex = edgeStartIndexes.get(nodeIndex) ?? 0; + const edgeCount = nodes[nodeIndex + edgeCountOffset] ?? 0; + for (let i = 0, currentEdgeIndex = beginEdgeIndex; i < edgeCount; i++, currentEdgeIndex += edgeFieldCount) { + const edgeType = edges[currentEdgeIndex + edgeTypeOffset]; + if (edgeType !== internalEdgeType) continue; + + const edgeName = strings[edges[currentEdgeIndex + edgeNameOffset]]; + if (edgeName !== 'elements') continue; + + const elementsNodeIndex = edges[currentEdgeIndex + edgeToNodeOffset]; + if ((retainerCounts.get(elementsNodeIndex) ?? 0) === 1) { + const elementsSize = nodes[elementsNodeIndex + selfSizeOffset] ?? 0; + addCategoryValue('jsArrays', elementsSize, 'array elements', 'Array elements', elementsNodeIndex); + jsArrayElementNodeIndexes.add(elementsNodeIndex); + } + break; + } + } + + if (extraNativeBytes > 0) { + addCategoryValue('otherNonJsObjects', extraNativeBytes, 'extra native bytes', 'extra native bytes'); + } + + for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) { + const typeId = nodes[nodeIndex + typeOffset]; + const type = nodeTypeNames[typeId] ?? 'unknown'; + const name = strings[nodes[nodeIndex + nameOffset]] ?? ''; + const selfSize = nodes[nodeIndex + selfSizeOffset] ?? 0; + categories.total += selfSize; + nodeCounts.total++; + + if (typeId === hiddenType) { + addCategoryValue('systemObjects', selfSize, type, name, nodeIndex); + continue; + } + + if (typeId === nativeType) { + if (name === 'system / JSArrayBufferData') { + addCategoryValue('typedArrays', selfSize, type, name, nodeIndex); + } else { + addCategoryValue('otherNonJsObjects', selfSize, type, name, nodeIndex); + } + continue; + } + + if (typeId === codeType) { + addCategoryValue('code', selfSize, type, name, nodeIndex); + continue; + } + + if (stringTypes.has(typeId)) { + addCategoryValue('strings', selfSize, type, name, nodeIndex); + continue; + } + + if (name === 'Array') { + addCategoryValue('jsArrays', selfSize, type, name, nodeIndex); + addJsArrayElementSize(nodeIndex); + continue; + } + } + + categories.total += extraNativeBytes; + + for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) { + if (jsArrayElementNodeIndexes.has(nodeIndex)) continue; + + const typeId = nodes[nodeIndex + typeOffset]; + if (typeId === hiddenType || typeId === nativeType || typeId === codeType || stringTypes.has(typeId)) continue; + + const name = strings[nodes[nodeIndex + nameOffset]] ?? ''; + if (name === 'Array') continue; + + const type = nodeTypeNames[typeId] ?? 'unknown'; + const selfSize = nodes[nodeIndex + selfSizeOffset] ?? 0; + addCategoryValue('otherJsObjects', selfSize, type, name, nodeIndex); + } + + return { + categories, + nodeCounts, + breakdowns: collapseHeapSnapshotBreakdowns(breakdowns, options.breakdownTopN), + }; +} + +function finiteMedian(values: (number | null | undefined)[]) { + const finiteValues = values.filter(value => Number.isFinite(value)) as number[]; + if (finiteValues.length === 0) return null; + return util.median(finiteValues); +} + +export function summarizeHeapSnapshotDataSamples( + samples: T[], + getData: (sample: T) => HeapSnapshotData | null | undefined, + options: { breakdownTopN?: number } = {}, +) { + const data = samples.map(getData); + const categories = {} as HeapSnapshotData['categories']; + for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) { + const value = finiteMedian(data.map(snapshot => snapshot?.categories?.[category])); + if (value != null) categories[category] = value; + } + + const nodeCounts = {} as HeapSnapshotData['nodeCounts']; + for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) { + const value = finiteMedian(data.map(snapshot => snapshot?.nodeCounts?.[category])); + if (value != null) nodeCounts[category] = value; + } + + if (Object.keys(categories).length === 0) return null; + + const breakdowns = {} as NonNullable; + for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) { + if (category === 'total') continue; + + const childKeys = new Set(); + for (const snapshot of data) { + for (const childKey of Object.keys(snapshot?.breakdowns?.[category] ?? {})) { + childKeys.add(childKey); + } + } + + const categoryBreakdown = {} as Record; + for (const childKey of childKeys) { + const value = finiteMedian(data.map(snapshot => snapshot?.breakdowns?.[category]?.[childKey])); + if (value != null) categoryBreakdown[childKey] = value; + } + + const collapsed = collapseHeapSnapshotBreakdown(categoryBreakdown, options.breakdownTopN); + if (Object.keys(collapsed).length > 0) { + breakdowns[category] = collapsed; + } + } + + return { + categories, + nodeCounts, + ...(Object.keys(breakdowns).length > 0 ? { breakdowns } : {}), + }; +} + function getHeapSnapshotCategoryValue(report: HeapSnapshotReport, category: keyof typeof heapSnapshotCategory) { return report.summary.categories[category]; } diff --git a/.github/scripts/measure-backend-memory-comparison.mts b/.github/scripts/measure-backend-memory-comparison.mts index 3dce74dc0a..2e7e67fe5a 100644 --- a/.github/scripts/measure-backend-memory-comparison.mts +++ b/.github/scripts/measure-backend-memory-comparison.mts @@ -38,7 +38,7 @@ export type MemoryReport = { const [baseDirArg, headDirArg, baseOutputArg, headOutputArg] = process.argv.slice(2); -const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = util.readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', 6, 1); +const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = util.readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', heapSnapshotUtil.defaultHeapSnapshotBreakdownTopN, 1); const HEAD_HEAP_SNAPSHOT_WORK_DIR = resolve('head-heap-snapshots'); const HEAD_HEAP_SNAPSHOT_OUTPUT_PATH = resolve('head-heap-snapshot.heapsnapshot'); @@ -70,51 +70,6 @@ async function resetState(repoDir: string) { } } -function summarizeHeapSnapshotBreakdowns(samples: MemoryReport['samples'], phase: typeof phases[number]) { - const breakdowns = {} as Record>; - - for (const category of Object.keys(heapSnapshotUtil.heapSnapshotCategory) as (keyof typeof heapSnapshotUtil.heapSnapshotCategory)[]) { - if (category === 'total') continue; - - const childKeys = new Set(); - for (const sample of samples) { - for (const childKey of Object.keys(sample.phases[phase].heapSnapshot?.breakdowns?.[category] ?? {})) { - childKeys.add(childKey); - } - } - - const categoryBreakdown = {} as Record; - for (const childKey of childKeys) { - const values = samples - .map(sample => sample.phases[phase].heapSnapshot?.breakdowns?.[category]?.[childKey]) - .filter(value => Number.isFinite(value)) as number[]; - - if (values.length > 0) categoryBreakdown[childKey] = util.median(values); - } - - if (Object.keys(categoryBreakdown).length > 0) { - breakdowns[category] = collapseHeapSnapshotBreakdown(categoryBreakdown); - } - } - - return breakdowns; -} - -function collapseHeapSnapshotBreakdown(breakdown: Record) { - const entries = Object.entries(breakdown) - .filter(([, value]) => value > 0) - .toSorted((a, b) => b[1] - a[1]); - - const topEntries = entries.slice(0, HEAP_SNAPSHOT_BREAKDOWN_TOP_N); - const otherValue = entries - .slice(HEAP_SNAPSHOT_BREAKDOWN_TOP_N) - .reduce((sum, [, value]) => sum + value, 0); - - const collapsed = Object.fromEntries(topEntries); - if (otherValue > 0) collapsed.Other = otherValue; - return collapsed; -} - function summarizeSamples(samples: MemoryReport['samples']) { const summary = {} as MemoryReport['summary']; @@ -135,33 +90,12 @@ function summarizeSamples(samples: MemoryReport['samples']) { summary[phase].memoryUsage[key] = util.median(values); } - const heapSnapshotCategoryValues = {} as Record; - for (const category of Object.keys(heapSnapshotUtil.heapSnapshotCategory) as (keyof typeof heapSnapshotUtil.heapSnapshotCategory)[]) { - const values = samples - .map(sample => sample.phases[phase].heapSnapshot?.categories?.[category]) - .filter(value => Number.isFinite(value)) as number[]; - - if (values.length > 0) heapSnapshotCategoryValues[category] = util.median(values); - } - - const heapSnapshotNodeCountValues = {} as Record; - for (const category of Object.keys(heapSnapshotUtil.heapSnapshotCategory) as (keyof typeof heapSnapshotUtil.heapSnapshotCategory)[]) { - const values = samples - .map(sample => sample.phases[phase].heapSnapshot?.nodeCounts?.[category]) - .filter(value => Number.isFinite(value)) as number[]; - - if (values.length > 0) heapSnapshotNodeCountValues[category] = util.median(values); - } - - if (Object.keys(heapSnapshotCategoryValues).length > 0) { - const heapSnapshotBreakdowns = summarizeHeapSnapshotBreakdowns(samples, phase); - - summary[phase].heapSnapshot = { - categories: heapSnapshotCategoryValues, - nodeCounts: heapSnapshotNodeCountValues, - ...(Object.keys(heapSnapshotBreakdowns).length > 0 ? { breakdowns: heapSnapshotBreakdowns } : {}), - }; - } + const heapSnapshot = heapSnapshotUtil.summarizeHeapSnapshotDataSamples( + samples, + sample => sample.phases[phase].heapSnapshot, + { breakdownTopN: HEAP_SNAPSHOT_BREAKDOWN_TOP_N }, + ); + if (heapSnapshot != null) summary[phase].heapSnapshot = heapSnapshot; } return summary; diff --git a/.github/scripts/measure-frontend-browser-comparison.mts b/.github/scripts/measure-frontend-browser-comparison.mts new file mode 100644 index 0000000000..0825133b1d --- /dev/null +++ b/.github/scripts/measure-frontend-browser-comparison.mts @@ -0,0 +1,274 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +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 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 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'); + +type BrowserMeasurementSample = BrowserMeasurement & { + round: number; + networkRequests: NetworkRequest[]; +}; + +type BrowserMetricsReport = { + label: string; + timestamp: string; + url: string; + scenario: string; + sampleCount: number; + aggregation: 'median'; + summary: BrowserMeasurement; + samples: BrowserMeasurementSample[]; +}; + +async function runSignupAndPostScenario(chrome: HeadlessChromeController) { + const page = chrome.page; + const noteText = `Frontend browser metrics ${Date.now()}`; + + await visitHome(page, baseUrl); + await signupThroughUi(page, { username: 'alice', password: 'password' }); + await closeUserSetupDialog(page); + await postNote(page, noteText, 10_000); + + await util.sleep(1000); +} + +function finiteMedian(values: (number | null | undefined)[], defaultValue = 0) { + const finiteValues = values.filter(value => Number.isFinite(value)) as number[]; + if (finiteValues.length === 0) return defaultValue; + return util.median(finiteValues); +} + +function selectRepresentativeSample(samples: BrowserMeasurementSample[], getValue: (sample: BrowserMeasurementSample) => number) { + const medianValue = finiteMedian(samples.map(getValue)); + let selected: { sample: BrowserMeasurementSample; distance: number } | null = null; + + for (const sample of samples) { + const value = getValue(sample); + if (!Number.isFinite(value)) continue; + const distance = Math.abs(value - medianValue); + if (selected == null || distance < selected.distance || (distance === selected.distance && sample.round < selected.sample.round)) { + selected = { + sample, + distance, + }; + } + } + + return selected?.sample ?? samples[0]; +} + +function summarizeResourceType(samples: BrowserMeasurementSample[], resourceType: string) { + return { + requests: finiteMedian(samples.map(sample => sample.network.byResourceType[resourceType]?.requests)), + encodedBytes: finiteMedian(samples.map(sample => sample.network.byResourceType[resourceType]?.encodedBytes)), + decodedBodyBytes: finiteMedian(samples.map(sample => sample.network.byResourceType[resourceType]?.decodedBodyBytes)), + }; +} + +function summarizeNetworkSamples(samples: BrowserMeasurementSample[]): NetworkSummary { + const resourceTypes = new Set(); + for (const sample of samples) { + for (const resourceType of Object.keys(sample.network.byResourceType)) { + resourceTypes.add(resourceType); + } + } + + const representative = selectRepresentativeSample(samples, sample => sample.network.totalEncodedBytes); + const byResourceType = {} as NetworkSummary['byResourceType']; + for (const resourceType of resourceTypes) { + byResourceType[resourceType] = summarizeResourceType(samples, resourceType); + } + + return { + requestCount: finiteMedian(samples.map(sample => sample.network.requestCount)), + webSocketConnectionCount: finiteMedian(samples.map(sample => sample.network.webSocketConnectionCount)), + webSocketSentBytes: finiteMedian(samples.map(sample => sample.network.webSocketSentBytes)), + webSocketReceivedBytes: finiteMedian(samples.map(sample => sample.network.webSocketReceivedBytes)), + finishedRequestCount: finiteMedian(samples.map(sample => sample.network.finishedRequestCount)), + failedRequestCount: finiteMedian(samples.map(sample => sample.network.failedRequestCount)), + cachedRequestCount: finiteMedian(samples.map(sample => sample.network.cachedRequestCount)), + serviceWorkerRequestCount: finiteMedian(samples.map(sample => sample.network.serviceWorkerRequestCount)), + totalEncodedBytes: finiteMedian(samples.map(sample => sample.network.totalEncodedBytes)), + totalDecodedBodyBytes: finiteMedian(samples.map(sample => sample.network.totalDecodedBodyBytes)), + sameOriginEncodedBytes: finiteMedian(samples.map(sample => sample.network.sameOriginEncodedBytes)), + thirdPartyEncodedBytes: finiteMedian(samples.map(sample => sample.network.thirdPartyEncodedBytes)), + byResourceType, + largestRequests: representative.network.largestRequests, + failedRequests: representative.network.failedRequests, + }; +} + +function summarizePerformanceSamples(samples: BrowserMeasurementSample[]): BrowserMeasurement['performance'] { + const cdpMetricKeys = new Set(); + for (const sample of samples) { + for (const key of Object.keys(sample.performance.cdpMetrics)) { + cdpMetricKeys.add(key); + } + } + + const cdpMetrics = {} as Record; + for (const key of cdpMetricKeys) { + cdpMetrics[key] = finiteMedian(samples.map(sample => sample.performance.cdpMetrics[key])); + } + + const webVitalKeys = [ + 'firstPaintMs', + 'firstContentfulPaintMs', + 'domContentLoadedEventEndMs', + 'loadEventEndMs', + 'longTaskCount', + 'longTaskDurationMs', + 'maxLongTaskDurationMs', + 'resourceEntryCount', + 'domElements', + ] as const satisfies (keyof BrowserMeasurement['performance']['webVitals'])[]; + + const webVitals = {} as BrowserMeasurement['performance']['webVitals']; + for (const key of webVitalKeys) { + webVitals[key] = finiteMedian(samples.map(sample => sample.performance.webVitals[key])); + } + + return { + cdpMetrics, + runtimeHeap: { + usedSize: finiteMedian(samples.map(sample => sample.performance.runtimeHeap?.usedSize)), + totalSize: finiteMedian(samples.map(sample => sample.performance.runtimeHeap?.totalSize)), + }, + tabMemory: { + totalBytes: finiteMedian(samples.map(sample => sample.performance.tabMemory.totalBytes)), + }, + webVitals, + }; +} + +function summarizeHeapSnapshotSamples(samples: BrowserMeasurementSample[]) { + const summary = heapSnapshotUtil.summarizeHeapSnapshotDataSamples( + samples, + sample => sample.heapSnapshot, + { breakdownTopN: heapSnapshotBreakdownTopN }, + ); + if (summary == null) throw new Error('No heap snapshot samples'); + return summary; +} + +function summarizeSamples(label: 'base' | 'head', samples: BrowserMeasurementSample[]): BrowserMetricsReport { + if (samples.length === 0) throw new Error(`No browser metric samples for ${label}`); + const representative = selectRepresentativeSample(samples, sample => sample.network.totalEncodedBytes); + const summary: BrowserMeasurement = { + label, + timestamp: new Date().toISOString(), + url: baseUrl, + scenario: representative.scenario, + durationMs: finiteMedian(samples.map(sample => sample.durationMs)), + network: summarizeNetworkSamples(samples), + performance: summarizePerformanceSamples(samples), + heapSnapshot: summarizeHeapSnapshotSamples(samples), + }; + + return { + label, + timestamp: new Date().toISOString(), + url: baseUrl, + scenario: representative.scenario, + sampleCount: samples.length, + aggregation: 'median', + summary, + samples, + }; +} + +async function measureSample(label: 'base' | 'head', round: number, heapSnapshotSavePath?: string) { + await util.prepareInstance(baseUrl); + + return await HeadlessChromeController.with(label, { scenarioTimeoutMs: 120000, baseUrl }, async chrome => { + await chrome.enableNetworkTracking(); + + const startedAt = Date.now(); + await runSignupAndPostScenario(chrome); + const durationMs = Date.now() - startedAt; + await chrome.waitForNetworkDetails(); + const performance = await chrome.collectPerformance(); + const heapSnapshotRaw = await chrome.takeHeapSnapshot(heapSnapshotSavePath); + const heapSnapshot = heapSnapshotUtil.analyzeHeapSnapshot(heapSnapshotRaw, { breakdownTopN: heapSnapshotBreakdownTopN }); + const measurement: BrowserMeasurementSample = { + label, + round, + timestamp: new Date().toISOString(), + url: baseUrl, + scenario: 'fresh browser signup, first timeline note, after the note becomes visible', + durationMs, + network: summarizeNetwork(chrome.networkRequests, baseUrl, chrome.webSocketConnections), + networkRequests: chrome.networkRequests, + performance, + heapSnapshot, + }; + + return measurement; + }); +} + +function headHeapSnapshotPath(round: number) { + return join(headHeapSnapshotWorkDir, `round-${round}.heapsnapshot`); +} + +async function saveRepresentativeHeadHeapSnapshot(report: BrowserMetricsReport, outputPath: string) { + 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 }); +} + +async function measureRepo(label: 'base' | 'head', repoDir: string, outputPath: string, heapSnapshotSavePath?: string) { + let server: ReturnType | null = null; + + try { + server = util.startServer(label, repoDir); + await util.waitForServer(baseUrl, server!); + + if (label === 'head' && heapSnapshotSavePath != null) { + await rm(headHeapSnapshotWorkDir, { recursive: true, force: true }); + await mkdir(headHeapSnapshotWorkDir, { recursive: true }); + } + + const samples: BrowserMeasurementSample[] = []; + for (let round = 1; round <= sampleCount; round++) { + process.stderr.write(`[${label}] Measuring browser metrics sample ${round}/${sampleCount}\n`); + samples.push(await measureSample( + label, + round, + label === 'head' && heapSnapshotSavePath != null ? headHeapSnapshotPath(round) : undefined, + )); + } + + const report = summarizeSamples(label, samples); + 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); + } + } 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 main(); diff --git a/.github/scripts/utility.mts b/.github/scripts/utility.mts index fb65d9191d..caec5555ef 100644 --- a/.github/scripts/utility.mts +++ b/.github/scripts/utility.mts @@ -5,10 +5,14 @@ // NOTE: このファイルはworkflow上でバックエンドからも参照されるため、side effectがあってはならない -import { spawn } from 'node:child_process'; +import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from 'node:child_process'; import { promises as fs } from 'node:fs'; import path from 'node:path'; +export function sleep(ms: number) { + return new Promise(resolvePromise => setTimeout(resolvePromise, ms)); +} + export function median(values: number[]) { const sorted = values.toSorted((a, b) => a - b); const center = Math.floor(sorted.length / 2); @@ -202,3 +206,91 @@ export function run(command: string, args: string[], options: { cwd?: string; en }); }); } + +export function startServer(label: string, repoDir: string) { + process.stderr.write(`[${label}] Starting Misskey test server\n`); + const child = spawn(commandName('pnpm'), ['start:test'], { + cwd: repoDir, + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + detached: process.platform !== 'win32', + }); + child.stdout.on('data', data => process.stderr.write(`[server:${label}] ${data}`)); + child.stderr.on('data', data => process.stderr.write(`[server:${label}] ${data}`)); + return child; +} + +export async function waitForServer(baseUrl: string, child: ChildProcessWithoutNullStreams) { + const startedAt = Date.now(); + while (Date.now() - startedAt < 120_000) { + if (child.exitCode != null) throw new Error(`Misskey server exited early with code ${child.exitCode}`); + try { + const response = await fetch(`${baseUrl}/`, { redirect: 'manual' }); + if (response.status < 500) return; + } catch { + // retry + } + await sleep(1_000); + } + throw new Error(`Timed out waiting for ${baseUrl}`); +} + +export async function api(baseUrl: string, endpoint: string, body: Record) { + const response = await fetch(`${baseUrl}/api/${endpoint}`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify(body), + }); + if (!response.ok) { + throw new Error(`/api/${endpoint} returned ${response.status}: ${await response.text()}`); + } + if (response.status === 204) return null; + return await response.json(); +} + +export async function prepareInstance(baseUrl: string) { + await api(baseUrl, 'reset-db', {}); + await api(baseUrl, 'admin/accounts/create', { + username: 'admin', + password: 'admin1234', + setupPassword: 'example_password_please_change_this_or_you_will_get_hacked', + }); +} + +export async function stopServer(child: ChildProcessWithoutNullStreams) { + if (child.exitCode != null) return; + + if (process.platform === 'win32') { + spawnSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' }); + } else if (child.pid != null) { + try { + process.kill(-child.pid, 'SIGTERM'); + } catch { + child.kill('SIGTERM'); + } + } + + await new Promise(resolvePromise => { + if (child.exitCode != null) { + resolvePromise(); + return; + } + child.once('exit', () => resolvePromise()); + setTimeout(() => { + if (child.pid != null) { + try { + if (process.platform === 'win32') { + spawnSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' }); + } else { + process.kill(-child.pid, 'SIGKILL'); + } + } catch { + child.kill('SIGKILL'); + } + } + resolvePromise(); + }, 10_000).unref(); + }); +} diff --git a/.github/workflows/frontend-browser-metrics-report-comment.yml b/.github/workflows/frontend-browser-metrics-report-comment.yml new file mode 100644 index 0000000000..0312092a5b --- /dev/null +++ b/.github/workflows/frontend-browser-metrics-report-comment.yml @@ -0,0 +1,44 @@ +name: frontend-browser-metrics-report-comment + +on: + workflow_run: + workflows: + - frontend-browser-metrics-report + types: + - completed + +permissions: + actions: read + contents: read + issues: write + pull-requests: write + +jobs: + comment: + name: Comment frontend browser metrics 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 }} + 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 + 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-browser-metrics-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_browser_metrics_report + file-path: ${{ runner.temp }}/frontend-browser-metrics-report/frontend-browser-metrics-report.md diff --git a/.github/workflows/frontend-browser-metrics-report.yml b/.github/workflows/frontend-browser-metrics-report.yml new file mode 100644 index 0000000000..863c7bb155 --- /dev/null +++ b/.github/workflows/frontend-browser-metrics-report.yml @@ -0,0 +1,200 @@ +name: frontend-browser-metrics-report + +on: + pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review + paths: + - packages/frontend/** + - packages/frontend-shared/** + - packages/frontend-builder/** + - packages/backend/** + - 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/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 + +permissions: + contents: read + pull-requests: read + +concurrency: + group: frontend-browser-metrics-report-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + report: + name: Measure frontend browser metrics + runs-on: ubuntu-latest + timeout-minutes: 90 + + services: + postgres: + image: postgres:18 + ports: + - 54312:5432 + env: + POSTGRES_DB: test-misskey + POSTGRES_HOST_AUTH_METHOD: trust + redis: + image: redis:8 + ports: + - 56312:6379 + + steps: + - name: Checkout base + uses: actions/checkout@v6.0.2 + with: + persist-credentials: false + repository: ${{ github.event.pull_request.base.repo.full_name }} + ref: ${{ github.event.pull_request.base.sha }} + path: before + submodules: true + + - name: Checkout pull request + uses: actions/checkout@v6.0.2 + with: + persist-credentials: false + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} + path: after + submodules: true + + - name: Setup pnpm + uses: pnpm/action-setup@v6.0.3 + with: + package_json_file: after/package.json + + - name: Setup Node.js + uses: actions/setup-node@v6.4.0 + with: + node-version-file: after/.node-version + cache: pnpm + cache-dependency-path: | + before/pnpm-lock.yaml + after/pnpm-lock.yaml + + - name: Install dependencies for base + working-directory: before + run: pnpm i --frozen-lockfile + + - name: Configure base + working-directory: before + run: cp .github/misskey/test.yml .config + + - name: Build base + working-directory: before + run: pnpm build + + - name: Install dependencies for pull request + working-directory: after + run: pnpm i --frozen-lockfile + + - name: Configure pull request + working-directory: after + run: cp .github/misskey/test.yml .config + + - name: Build pull request + working-directory: after + run: pnpm build + + - name: Install Playwright browsers + working-directory: after/packages/frontend + run: pnpm exec playwright install --with-deps --no-shell chromium + + - name: Measure frontend browser metrics + shell: bash + env: + FRONTEND_BROWSER_METRICS_SAMPLE_COUNT: 5 + MK_ENABLE_CROSS_ORIGIN_ISOLATION: "true" + run: | + REPORT_DIR="$RUNNER_TEMP/frontend-browser-metrics-report" + 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" + + - 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 + if-no-files-found: error + retention-days: 7 + + - name: Generate browser detailed html + shell: bash + run: | + REPORT_DIR="$RUNNER_TEMP/frontend-browser-metrics-report" + 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" + + - 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 + if-no-files-found: error + archive: false + retention-days: 7 + + - name: Generate browser metrics report + shell: bash + env: + 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_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" + 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" + 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 browser metrics report + shell: bash + run: | + REPORT_DIR="$RUNNER_TEMP/frontend-browser-metrics-report" + test -s "$REPORT_DIR/frontend-browser-metrics-report.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" + + - name: Upload browser metrics report + uses: actions/upload-artifact@v7 + with: + name: frontend-browser-metrics-report + 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 + if-no-files-found: error + retention-days: 7 diff --git a/packages/backend/scripts/measure-memory.mts b/packages/backend/scripts/measure-memory.mts index 74b84d998f..eb7a9c037a 100644 --- a/packages/backend/scripts/measure-memory.mts +++ b/packages/backend/scripts/measure-memory.mts @@ -10,7 +10,7 @@ import { dirname, join } from 'node:path'; import { tmpdir } from 'node:os'; //import * as http from 'node:http'; import * as fs from 'node:fs/promises'; -import { heapSnapshotCategory, type HeapSnapshotData } from '../../../.github/scripts/heap-snapshot-util.mts'; +import { analyzeHeapSnapshot, defaultHeapSnapshotBreakdownTopN, type HeapSnapshotData } from '../../../.github/scripts/heap-snapshot-util.mts'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -40,7 +40,7 @@ const IPC_TIMEOUT = readIntegerEnv('MK_MEMORY_IPC_TIMEOUT_MS', 30000, 1); // Tim const REQUEST_COUNT = readIntegerEnv('MK_MEMORY_REQUEST_COUNT', 10, 0); const HEAP_SNAPSHOT = readBooleanEnv('MK_MEMORY_HEAP_SNAPSHOT', false); const HEAP_SNAPSHOT_TIMEOUT = readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_TIMEOUT_MS', 120000, 1); -const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', 6, 1); +const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', defaultHeapSnapshotBreakdownTopN, 1); const HEAP_SNAPSHOT_SAVE_PATH = process.env.MK_MEMORY_HEAP_SNAPSHOT_SAVE_PATH; const procStatusKeys = ['VmPeak', 'VmSize', 'VmHWM', 'VmRSS', 'VmData', 'VmStk', 'VmExe', 'VmLib', 'VmPTE', 'VmSwap'] as const; @@ -79,246 +79,6 @@ function bytesToKiB(value: number) { return Math.round(value / 1024); } -function sanitizeHeapSnapshotBreakdownLabel(value, fallback = 'unknown') { - const label = String(value ?? '').replace(/\s+/g, ' ').trim(); - if (label === '') return fallback; - if (label.length <= 80) return label; - return `${label.slice(0, 77)}...`; -} - -function classifyHeapSnapshotBreakdown(category: keyof typeof heapSnapshotCategory, type, name) { - if (category === 'strings') return type; - - if (category === 'jsArrays') { - if (type === 'array elements') return 'Array elements'; - if (type === 'object' && name === 'Array') return 'Array objects'; - return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`); - } - - if (category === 'typedArrays') { - if (name === 'system / JSArrayBufferData') return 'ArrayBuffer data'; - return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`); - } - - if (category === 'systemObjects') { - if (name.startsWith('system /')) return sanitizeHeapSnapshotBreakdownLabel(name); - if (name.startsWith('(system ')) return sanitizeHeapSnapshotBreakdownLabel(name); - return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type); - } - - if (category === 'otherJsObjects') { - if (type === 'object') return sanitizeHeapSnapshotBreakdownLabel(`object: ${name}`, 'object: unknown'); - return type; - } - - if (category === 'otherNonJsObjects') { - if (type === 'extra native bytes') return 'Extra native bytes'; - if (type === 'native') return sanitizeHeapSnapshotBreakdownLabel(`native: ${name}`, 'native: unknown'); - return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type); - } - - if (category === 'code') { - const lowerName = name.toLowerCase(); - if (lowerName.includes('bytecode')) return 'bytecode'; - if (lowerName.includes('builtin')) return 'builtins'; - if (lowerName.includes('regexp')) return 'regexp code'; - if (lowerName.includes('stub')) return 'stubs'; - return sanitizeHeapSnapshotBreakdownLabel(`code: ${name}`, 'code: unknown'); - } - - return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type); -} - -function collapseHeapSnapshotBreakdown(breakdowns: Record>) { - const collapsed = {} as Record>; - - for (const [category, children] of Object.entries(breakdowns)) { - const entries = Object.entries(children) - .filter(([, value]) => value > 0) - .toSorted((a, b) => b[1] - a[1]); - - const topEntries = entries.slice(0, HEAP_SNAPSHOT_BREAKDOWN_TOP_N); - const otherValue = entries - .slice(HEAP_SNAPSHOT_BREAKDOWN_TOP_N) - .reduce((sum, [, value]) => sum + value, 0); - - const categoryBreakdown = Object.fromEntries(topEntries); - if (otherValue > 0) categoryBreakdown.Other = otherValue; - if (Object.keys(categoryBreakdown).length > 0) collapsed[category] = categoryBreakdown; - } - - return collapsed; -} - -// Keep these buckets aligned with Chrome DevTools' heap snapshot Statistics view. -function analyzeHeapSnapshot(snapshot) { - const meta = snapshot?.snapshot?.meta; - const nodes = snapshot?.nodes; - const edges = snapshot?.edges; - const strings = snapshot?.strings; - if (meta == null || !Array.isArray(nodes) || !Array.isArray(edges) || !Array.isArray(strings)) { - throw new Error('Invalid heap snapshot format'); - } - - const nodeFields = meta.node_fields; - if (!Array.isArray(nodeFields)) throw new Error('Invalid heap snapshot node fields'); - const edgeFields = meta.edge_fields; - if (!Array.isArray(edgeFields)) throw new Error('Invalid heap snapshot edge fields'); - - const typeOffset = nodeFields.indexOf('type'); - const nameOffset = nodeFields.indexOf('name'); - const selfSizeOffset = nodeFields.indexOf('self_size'); - const edgeCountOffset = nodeFields.indexOf('edge_count'); - if (typeOffset < 0 || nameOffset < 0 || selfSizeOffset < 0 || edgeCountOffset < 0) { - throw new Error('Heap snapshot is missing required node fields'); - } - const edgeTypeOffset = edgeFields.indexOf('type'); - const edgeNameOffset = edgeFields.indexOf('name_or_index'); - const edgeToNodeOffset = edgeFields.indexOf('to_node'); - if (edgeTypeOffset < 0 || edgeNameOffset < 0 || edgeToNodeOffset < 0) { - throw new Error('Heap snapshot is missing required edge fields'); - } - - const nodeTypeNames = meta.node_types?.[typeOffset]; - if (!Array.isArray(nodeTypeNames)) throw new Error('Invalid heap snapshot node types'); - const edgeTypeNames = meta.edge_types?.[edgeTypeOffset]; - if (!Array.isArray(edgeTypeNames)) throw new Error('Invalid heap snapshot edge types'); - - function createEmptyHeapSnapshotCategoryMap() { - return Object.fromEntries(Object.keys(heapSnapshotCategory).map(category => [category, 0])) as Record; - } - - const nodeFieldCount = nodeFields.length; - const edgeFieldCount = edgeFields.length; - const nativeType = nodeTypeNames.indexOf('native'); - const codeType = nodeTypeNames.indexOf('code'); - const hiddenType = nodeTypeNames.indexOf('hidden'); - const stringTypes = new Set([ - nodeTypeNames.indexOf('string'), - nodeTypeNames.indexOf('concatenated string'), - nodeTypeNames.indexOf('sliced string'), - ]); - const internalEdgeType = edgeTypeNames.indexOf('internal'); - const extraNativeBytes = Number.isFinite(snapshot.snapshot.extra_native_bytes) ? snapshot.snapshot.extra_native_bytes : 0; - const categories = createEmptyHeapSnapshotCategoryMap(); - const nodeCounts = createEmptyHeapSnapshotCategoryMap(); - const breakdowns = Object.fromEntries( - (Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) - .filter(category => category !== 'total') - .map(category => [category, {}]), - ); - - function addValue(map: Record, key: string, value: number) { - map[key] = (map[key] ?? 0) + value; - } - - const edgeStartIndexes = new Map(); - const retainerCounts = new Map(); - let edgeIndex = 0; - for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) { - edgeStartIndexes.set(nodeIndex, edgeIndex); - const edgeCount = nodes[nodeIndex + edgeCountOffset] ?? 0; - for (let i = 0; i < edgeCount; i++, edgeIndex += edgeFieldCount) { - const toNodeIndex = edges[edgeIndex + edgeToNodeOffset]; - retainerCounts.set(toNodeIndex, (retainerCounts.get(toNodeIndex) ?? 0) + 1); - } - } - - const jsArrayElementNodeIndexes = new Set(); - - function addCategoryValue(category: keyof typeof heapSnapshotCategory, value: number, type: string, name: string, nodeIndex: number | null = null) { - if (value <= 0) return; - categories[category] += value; - addValue(breakdowns[category], classifyHeapSnapshotBreakdown(category, type, name), value); - if (nodeIndex != null) nodeCounts[category]++; - } - - function addJsArrayElementSize(nodeIndex: number) { - const beginEdgeIndex = edgeStartIndexes.get(nodeIndex) ?? 0; - const edgeCount = nodes[nodeIndex + edgeCountOffset] ?? 0; - for (let i = 0, currentEdgeIndex = beginEdgeIndex; i < edgeCount; i++, currentEdgeIndex += edgeFieldCount) { - const edgeType = edges[currentEdgeIndex + edgeTypeOffset]; - if (edgeType !== internalEdgeType) continue; - - const edgeName = strings[edges[currentEdgeIndex + edgeNameOffset]]; - if (edgeName !== 'elements') continue; - - const elementsNodeIndex = edges[currentEdgeIndex + edgeToNodeOffset]; - if ((retainerCounts.get(elementsNodeIndex) ?? 0) === 1) { - const elementsSize = nodes[elementsNodeIndex + selfSizeOffset] ?? 0; - addCategoryValue('jsArrays', elementsSize, 'array elements', 'Array elements', elementsNodeIndex); - jsArrayElementNodeIndexes.add(elementsNodeIndex); - } - break; - } - } - - if (extraNativeBytes > 0) { - addCategoryValue('otherNonJsObjects', extraNativeBytes, 'extra native bytes', 'extra native bytes'); - } - - for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) { - const typeId = nodes[nodeIndex + typeOffset]; - const type = nodeTypeNames[typeId] ?? 'unknown'; - const name = strings[nodes[nodeIndex + nameOffset]] ?? ''; - const selfSize = nodes[nodeIndex + selfSizeOffset] ?? 0; - categories.total += selfSize; - nodeCounts.total++; - - if (typeId === hiddenType) { - addCategoryValue('systemObjects', selfSize, type, name, nodeIndex); - continue; - } - - if (typeId === nativeType) { - if (name === 'system / JSArrayBufferData') { - addCategoryValue('typedArrays', selfSize, type, name, nodeIndex); - } else { - addCategoryValue('otherNonJsObjects', selfSize, type, name, nodeIndex); - } - continue; - } - - if (typeId === codeType) { - addCategoryValue('code', selfSize, type, name, nodeIndex); - continue; - } - - if (stringTypes.has(typeId)) { - addCategoryValue('strings', selfSize, type, name, nodeIndex); - continue; - } - - if (name === 'Array') { - addCategoryValue('jsArrays', selfSize, type, name, nodeIndex); - addJsArrayElementSize(nodeIndex); - continue; - } - } - - categories.total += extraNativeBytes; - - for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) { - if (jsArrayElementNodeIndexes.has(nodeIndex)) continue; - - const typeId = nodes[nodeIndex + typeOffset]; - if (typeId === hiddenType || typeId === nativeType || typeId === codeType || stringTypes.has(typeId)) continue; - - const name = strings[nodes[nodeIndex + nameOffset]] ?? ''; - if (name === 'Array') continue; - - const type = nodeTypeNames[typeId] ?? 'unknown'; - const selfSize = nodes[nodeIndex + selfSizeOffset] ?? 0; - addCategoryValue('otherJsObjects', selfSize, type, name, nodeIndex); - } - - return { - categories, - nodeCounts, - breakdowns: collapseHeapSnapshotBreakdown(breakdowns), - }; -} - async function getMemoryUsage(pid: number) { const path = `/proc/${pid}/status`; const status = await fs.readFile(path, 'utf-8'); @@ -417,7 +177,7 @@ async function getHeapSnapshotStatistics(serverProcess: ChildProcess): Promise { process.stderr.write(`Failed to delete heap snapshot ${writtenPath}: ${err.message}\n`); diff --git a/packages/frontend/test/e2e/basic.spec.ts b/packages/frontend/test/e2e/basic.spec.ts index ceae8c6c25..d432680e1d 100644 --- a/packages/frontend/test/e2e/basic.spec.ts +++ b/packages/frontend/test/e2e/basic.spec.ts @@ -10,7 +10,7 @@ import { // locator helper locateMkInput, locateMkSwitch, locateMkTextarea, // utils - registerUser, resetState, visitHome, + registerUser, resetState, visitHome, closeUserSetupDialog, postNote, // page utils waitApiResponse, signIn, } from './utils.js'; @@ -194,17 +194,11 @@ test.describe('After user setup', () => { await signIn(page, 'alice', 'alice1234'); // 表示に時間がかかるのでデフォルト秒数だとタイムアウトする - await page.locator('[data-testid="user-setup-dialog"] [data-testid="modal-window-close"]').click({ timeout: 30000 }); - await page.getByTestId('modal-dialog-ok').click(); + await closeUserSetupDialog(page); }); test('note', async ({ page }) => { - await page.getByTestId('open-post-form').waitFor({ state: 'visible' }); - await page.getByTestId('open-post-form').click(); - await page.getByTestId('post-form-text').fill('Hello, Misskey!'); - await page.getByTestId('post-form-submit').click(); - - await page.getByText('Hello, Misskey!').waitFor({ timeout: 15000 }); + await postNote(page, 'Hello, Misskey!'); }); test('open note form with hotkey', async ({ page }) => { diff --git a/packages/frontend/test/e2e/router.spec.ts b/packages/frontend/test/e2e/router.spec.ts index 83754d1fb9..41c057b510 100644 --- a/packages/frontend/test/e2e/router.spec.ts +++ b/packages/frontend/test/e2e/router.spec.ts @@ -10,7 +10,7 @@ import { // utils resetState, registerUser, // page utils - signIn, + signIn, closeUserSetupDialogIfVisible, } from './utils.js'; test.describe('Router transition', () => { @@ -26,10 +26,7 @@ test.describe('Router transition', () => { // 表示に時間がかかるのでデフォルト秒数だとタイムアウトする。少し待つ await page.waitForTimeout(1000); - if (await page.getByTestId('user-setup-dialog').isVisible()) { - await page.locator('[data-testid="user-setup-dialog"] [data-testid="modal-window-close"]').click(); - await page.getByTestId('modal-dialog-ok').click(); - } + await closeUserSetupDialogIfVisible(page); }); test.describe('Redirect', () => { diff --git a/packages/frontend/test/e2e/shared.ts b/packages/frontend/test/e2e/shared.ts new file mode 100644 index 0000000000..2bff5bf136 --- /dev/null +++ b/packages/frontend/test/e2e/shared.ts @@ -0,0 +1,135 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { Locator, Page } from 'playwright'; + +export const ADMIN_SETUP_PASSWORD = 'example_password_please_change_this_or_you_will_get_hacked'; +export const DEFAULT_INVITATION_CODE = 'test-invitation-code'; + +export interface RegisteredUser { + id: string; + token: string; +} + +export function assertOk(status: number, route: string): void { + if (status < 200 || status >= 300) { + throw new Error(`${route} failed: status=${status}`); + } +} + +export async function api(baseUrl: string, endpoint: string, body: Record) { + const response = await fetch(`${baseUrl}/api/${endpoint}`, { + method: 'POST', + body: JSON.stringify(body), + headers: { + 'Content-Type': 'application/json', + }, + }); + assertOk(response.status, `/api/${endpoint}`); + if (response.status === 204) return null; + return await response.json(); +} + +export async function resetState(baseUrl: string): Promise { + await api(baseUrl, 'reset-db', {}); +} + +export async function registerUser( + baseUrl: string, + username: string, + password: string, + isAdmin = false, +): Promise { + const route = isAdmin ? 'admin/accounts/create' : 'signup'; + const result = await api(baseUrl, route, { + username, + password, + ...(isAdmin ? { setupPassword: ADMIN_SETUP_PASSWORD } : {}), + }); + return result as RegisteredUser; +} + +export function locateMkInput(page: Page, testId: string): Locator { + return page.locator(`[data-testid="${testId}"] input`); +} + +export function locateMkTextarea(page: Page, testId: string): Locator { + return page.locator(`[data-testid="${testId}"] textarea`); +} + +export function locateMkSwitch(page: Page, testId: string): Locator { + return page.locator(`[data-testid="${testId}"] [data-testid="switch-toggle"]`); +} + +export async function visitHome(page: Page, baseUrl: string): Promise { + await page.goto(`${baseUrl}/`); + await page.locator('button').first().waitFor({ state: 'visible', timeout: 30_000 }); +} + +export async function waitApiResponse(page: Page, path: string, timeout = 30_000): Promise { + await page.waitForResponse((response) => { + return response.url().endsWith(path) && response.request().method() === 'POST'; + }, { timeout }); +} + +export async function signIn(page: Page, baseUrl: string, username: string, password: string): Promise { + await visitHome(page, baseUrl); + await page.getByTestId('signin').click(); + await page.getByTestId('signin-page-input').waitFor({ state: 'visible', timeout: 10_000 }); + await locateMkInput(page, 'signin-username').fill(username); + await page.keyboard.press('Enter'); + await page.getByTestId('signin-page-password').waitFor({ state: 'visible', timeout: 10_000 }); + await locateMkInput(page, 'signin-password').fill(password); + const signinResponse = waitApiResponse(page, '/api/signin-flow'); + await page.keyboard.press('Enter'); + await signinResponse; +} + +export async function acceptSignupRules(page: Page): Promise { + await page.getByTestId('signup-rules-continue').waitFor({ state: 'visible' }); + await locateMkSwitch(page, 'signup-rules-notes-agree').click(); + await page.getByTestId('modal-dialog-ok').click(); + await page.getByTestId('signup-rules-continue').click(); +} + +export async function signupThroughUi( + page: Page, + options: { + username: string; + password: string; + invitationCode?: string; + }, +): Promise { + await page.getByTestId('signup').click(); + await acceptSignupRules(page); + + await locateMkInput(page, 'signup-username').fill(options.username); + await locateMkInput(page, 'signup-password').fill(options.password); + await locateMkInput(page, 'signup-password-retype').fill(options.password); + await locateMkInput(page, 'signup-invitation-code').fill(options.invitationCode ?? DEFAULT_INVITATION_CODE); + + const signupResponse = waitApiResponse(page, '/api/signup'); + await page.getByTestId('signup-submit').click(); + await signupResponse; +} + +export async function closeUserSetupDialog(page: Page, timeout = 30_000): Promise { + await page.locator('[data-testid="user-setup-dialog"] [data-testid="modal-window-close"]').click({ timeout }); + await page.getByTestId('modal-dialog-ok').click(); +} + +export async function closeUserSetupDialogIfVisible(page: Page): Promise { + if (await page.getByTestId('user-setup-dialog').isVisible()) { + await closeUserSetupDialog(page); + } +} + +export async function postNote(page: Page, noteText: string, timeout = 15_000): Promise { + await page.getByTestId('open-post-form').waitFor({ state: 'visible' }); + await page.getByTestId('open-post-form').click(); + await page.getByTestId('post-form-text').fill(noteText); + await page.getByTestId('post-form-submit').click(); + await page.getByText(noteText).waitFor({ timeout }); +} diff --git a/packages/frontend/test/e2e/utils.ts b/packages/frontend/test/e2e/utils.ts index 4a21076464..67bcfd10d1 100644 --- a/packages/frontend/test/e2e/utils.ts +++ b/packages/frontend/test/e2e/utils.ts @@ -3,92 +3,50 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import type { Locator, Page } from 'playwright'; +import type { Page } from 'playwright'; +import { + registerUser as registerUserWithBaseUrl, + resetState as resetStateWithBaseUrl, + signIn as signInWithBaseUrl, + visitHome as visitHomeWithBaseUrl, +} from './shared.js'; +export type { RegisteredUser } from './shared.js'; +export { + ADMIN_SETUP_PASSWORD, + DEFAULT_INVITATION_CODE, + acceptSignupRules, + assertOk, + closeUserSetupDialog, + closeUserSetupDialogIfVisible, + locateMkInput, + locateMkSwitch, + locateMkTextarea, + postNote, + waitApiResponse, +} from './shared.js'; export const BASE_URL = 'http://localhost:61812'; -export const ADMIN_SETUP_PASSWORD = 'example_password_please_change_this_or_you_will_get_hacked'; - -export interface RegisteredUser { - id: string; - token: string; -} //#region Misc -export function assertOk(status: number, route: string): void { - if (status < 200 || status >= 300) { - throw new Error(`${route} failed: status=${status}`); - } -} - export async function resetState(): Promise { - const response = await fetch(`${BASE_URL}/api/reset-db`, { - method: 'POST', - body: '{}', - headers: { - 'Content-Type': 'application/json', - }, - }); - assertOk(response.status, '/api/reset-db'); + await resetStateWithBaseUrl(BASE_URL); } export async function registerUser( username: string, password: string, isAdmin = false, -): Promise { - const route = isAdmin ? '/api/admin/accounts/create' : '/api/signup'; - const response = await fetch(`${BASE_URL}${route}`, { - method: 'POST', - body: JSON.stringify({ - username, - password, - ...(isAdmin ? { setupPassword: ADMIN_SETUP_PASSWORD } : {}), - }), - headers: { - 'Content-Type': 'application/json', - }, - }); - assertOk(response.status, route); - return await response.json() as RegisteredUser; -} -//#endregion - -//#region Locator Helpers -export function locateMkInput(page: Page, testId: string): Locator { - return page.locator(`[data-testid="${testId}"] input`); -} - -export function locateMkTextarea(page: Page, testId: string): Locator { - return page.locator(`[data-testid="${testId}"] textarea`); -} - -export function locateMkSwitch(page: Page, testId: string): Locator { - return page.locator(`[data-testid="${testId}"] [data-testid="switch-toggle"]`); +): ReturnType { + return registerUserWithBaseUrl(BASE_URL, username, password, isAdmin); } //#endregion //#region Page Helpers export async function visitHome(page: Page): Promise { - await page.goto(`${BASE_URL}/`); - await page.locator('button').first().waitFor({ state: 'visible', timeout: 30_000 }); -} - -export async function waitApiResponse(page: Page, path: string): Promise { - await page.waitForResponse((response) => { - return response.url().endsWith(path) && response.request().method() === 'POST'; - }, { timeout: 30_000 }); + await visitHomeWithBaseUrl(page, BASE_URL); } export async function signIn(page: Page, username: string, password: string): Promise { - await visitHome(page); - await page.getByTestId('signin').click(); - await page.getByTestId('signin-page-input').waitFor({ state: 'visible', timeout: 10_000 }); - await locateMkInput(page, 'signin-username').fill(username); - await page.keyboard.press('Enter'); - await page.getByTestId('signin-page-password').waitFor({ state: 'visible', timeout: 10_000 }); - await locateMkInput(page, 'signin-password').fill(password); - const signinResponse = waitApiResponse(page, '/api/signin-flow'); - await page.keyboard.press('Enter'); - await signinResponse; + await signInWithBaseUrl(page, BASE_URL, username, password); } //#endregion From df063d9386b789f8be9fbc955a92043aaa15fdf6 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:20:34 +0900 Subject: [PATCH 065/168] enhance(dev): tweak get-backend-memory --- .../measure-backend-memory-comparison.mts | 28 +-- .github/scripts/memory-stability-util.mts | 75 ++++++++ .github/workflows/get-backend-memory.yml | 1 + .github/workflows/lint.yml | 2 + packages/backend/scripts/measure-memory.mts | 178 +++++------------- 5 files changed, 131 insertions(+), 153 deletions(-) create mode 100644 .github/scripts/memory-stability-util.mts diff --git a/.github/scripts/measure-backend-memory-comparison.mts b/.github/scripts/measure-backend-memory-comparison.mts index 2e7e67fe5a..ee95c68165 100644 --- a/.github/scripts/measure-backend-memory-comparison.mts +++ b/.github/scripts/measure-backend-memory-comparison.mts @@ -8,30 +8,19 @@ import { copyFile, 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 type { MemoryReportRaw } from '../../packages/backend/scripts/measure-memory.mts'; +import type { MemorySample } from '../../packages/backend/scripts/measure-memory.mts'; const phases = ['afterGc'] as const; export type MemoryReport = { timestamp: string; - sampleCount: any; + sampleCount: number; aggregation: string; - measurement: { - startupTimeoutMs: any; - memorySettleTimeMs: any; - ipcTimeoutMs: any; - requestCount: any; - heapSnapshot: { - enabled: any; - timeoutMs: any; - breakdownTopN: any; - }; - }; summary: Record; heapSnapshot?: heapSnapshotUtil.HeapSnapshotData; }>; - samples: (MemoryReportRaw['samples'][number] & { + samples: (MemorySample & { round: number; })[]; }; @@ -41,6 +30,8 @@ const [baseDirArg, headDirArg, baseOutputArg, headOutputArg] = process.argv.slic const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = util.readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', heapSnapshotUtil.defaultHeapSnapshotBreakdownTopN, 1); const HEAD_HEAP_SNAPSHOT_WORK_DIR = resolve('head-heap-snapshots'); const HEAD_HEAP_SNAPSHOT_OUTPUT_PATH = resolve('head-heap-snapshot.heapsnapshot'); +// Use the head checkout's measurement harness for both targets so only the built backend differs. +const MEASURE_MEMORY_SCRIPT = resolve(import.meta.dirname, '../../packages/backend/scripts/measure-memory.mts'); async function resetState(repoDir: string) { const require = createRequire(join(repoDir, 'packages/backend/package.json')); @@ -115,20 +106,17 @@ async function measureRepo(label: string, repoDir: string, round: number, option process.stderr.write(`[${label}] Measuring memory\n`); const measureEnv = { ...process.env, - MK_MEMORY_SAMPLE_COUNT: '1', + MK_MEMORY_BACKEND_DIR: resolve(repoDir, 'packages/backend'), } as NodeJS.ProcessEnv; if (round <= 0) measureEnv.MK_MEMORY_HEAP_SNAPSHOT = '0'; if (options.heapSnapshotSavePath != null) measureEnv.MK_MEMORY_HEAP_SNAPSHOT_SAVE_PATH = options.heapSnapshotSavePath; - const stdout = await util.run('node', ['packages/backend/scripts/measure-memory.mts'], { + const stdout = await util.run('node', [MEASURE_MEMORY_SCRIPT], { cwd: repoDir, env: measureEnv, }); - const report = JSON.parse(stdout) as MemoryReportRaw; - const sample = report.samples[0]; - - return sample; + return JSON.parse(stdout) as MemorySample; } function headHeapSnapshotPath(round: number) { diff --git a/.github/scripts/memory-stability-util.mts b/.github/scripts/memory-stability-util.mts new file mode 100644 index 0000000000..0a9a8d4f52 --- /dev/null +++ b/.github/scripts/memory-stability-util.mts @@ -0,0 +1,75 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { setTimeout } from 'node:timers/promises'; + +type MemoryStabilityTimer = { + now: () => number; + wait: (durationMs: number) => Promise; +}; + +const intervalMs = 2000; +const maxWaitMs = 10000; +const windowSize = 3; +const slopeThresholdKiBPerSecond = 256; +const stabilityMetrics = ['Pss', 'Private_Dirty'] as const; + +const defaultTimer: MemoryStabilityTimer = { + now: () => performance.now(), + wait: durationMs => setTimeout(durationMs), +}; + +function getMaxAbsoluteSlopes>(readings: { elapsedMs: number; memoryUsage: T }[]) { + const result = {} as Record; + + for (const metric of stabilityMetrics) { + let maxAbsoluteSlope = 0; + for (let i = 1; i < readings.length; i++) { + const previous = readings[i - 1]; + const current = readings[i]; + const durationSeconds = (current.elapsedMs - previous.elapsedMs) / 1000; + maxAbsoluteSlope = Math.max(maxAbsoluteSlope, Math.abs(current.memoryUsage[metric] - previous.memoryUsage[metric]) / durationSeconds); + } + result[metric] = maxAbsoluteSlope; + } + + return result; +} + +export async function measureMemoryUntilStable>( + readMemoryUsage: () => Promise, + timer: MemoryStabilityTimer = defaultTimer, +) { + const startedAt = timer.now(); + const readings: { elapsedMs: number; memoryUsage: T }[] = []; + let maxAbsoluteSlopesKiBPerSecond: Record | null = null; + + while (true) { + const memoryUsage = await readMemoryUsage(); + const elapsedMs = timer.now() - startedAt; + readings.push({ elapsedMs, memoryUsage }); + + let converged = false; + if (readings.length >= windowSize) { + const latestSlopes = getMaxAbsoluteSlopes(readings.slice(-windowSize)); + maxAbsoluteSlopesKiBPerSecond = latestSlopes; + converged = stabilityMetrics.every(metric => latestSlopes[metric] <= slopeThresholdKiBPerSecond); + } + + if (converged || elapsedMs >= maxWaitMs) { + return { + memoryUsage, + stability: { + converged, + readingCount: readings.length, + elapsedMs, + maxAbsoluteSlopesKiBPerSecond, + }, + }; + } + + await timer.wait(Math.min(intervalMs, maxWaitMs - elapsedMs)); + } +} diff --git a/.github/workflows/get-backend-memory.yml b/.github/workflows/get-backend-memory.yml index 400a81a7d9..0d0bec3307 100644 --- a/.github/workflows/get-backend-memory.yml +++ b/.github/workflows/get-backend-memory.yml @@ -12,6 +12,7 @@ on: - .github/scripts/utility.mts - .github/scripts/backend-memory-report.mts - .github/scripts/measure-backend-memory-comparison.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 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index d3c745aea6..c8e24fb19b 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -19,6 +19,7 @@ on: - 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 @@ -37,6 +38,7 @@ on: - 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 diff --git a/packages/backend/scripts/measure-memory.mts b/packages/backend/scripts/measure-memory.mts index eb7a9c037a..55aab61a84 100644 --- a/packages/backend/scripts/measure-memory.mts +++ b/packages/backend/scripts/measure-memory.mts @@ -4,16 +4,15 @@ */ import { ChildProcess, fork } from 'node:child_process'; -import { setTimeout } from 'node:timers/promises'; -import { fileURLToPath } from 'node:url'; -import { dirname, join } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; import { tmpdir } from 'node:os'; -//import * as http from 'node:http'; import * as fs from 'node:fs/promises'; import { analyzeHeapSnapshot, defaultHeapSnapshotBreakdownTopN, type HeapSnapshotData } from '../../../.github/scripts/heap-snapshot-util.mts'; +import { measureMemoryUntilStable } from '../../../.github/scripts/memory-stability-util.mts'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); +const backendDir = process.env.MK_MEMORY_BACKEND_DIR == null || process.env.MK_MEMORY_BACKEND_DIR === '' + ? join(import.meta.dirname, '..') + : resolve(process.env.MK_MEMORY_BACKEND_DIR); function readIntegerEnv(name, defaultValue, min) { const rawValue = process.env[name]; @@ -33,11 +32,8 @@ function readBooleanEnv(name, defaultValue) { throw new Error(`${name} must be one of: 1, 0, true, false`); } -const SAMPLE_COUNT = readIntegerEnv('MK_MEMORY_SAMPLE_COUNT', 3, 1); // Number of samples to measure const STARTUP_TIMEOUT = readIntegerEnv('MK_MEMORY_STARTUP_TIMEOUT_MS', 120000, 1); // Timeout for server startup -const MEMORY_SETTLE_TIME = readIntegerEnv('MK_MEMORY_SETTLE_TIME_MS', 10000, 0); // Wait after startup for memory to settle const IPC_TIMEOUT = readIntegerEnv('MK_MEMORY_IPC_TIMEOUT_MS', 30000, 1); // Timeout for IPC responses -const REQUEST_COUNT = readIntegerEnv('MK_MEMORY_REQUEST_COUNT', 10, 0); const HEAP_SNAPSHOT = readBooleanEnv('MK_MEMORY_HEAP_SNAPSHOT', false); const HEAP_SNAPSHOT_TIMEOUT = readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_TIMEOUT_MS', 120000, 1); const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', defaultHeapSnapshotBreakdownTopN, 1); @@ -187,17 +183,23 @@ async function getHeapSnapshotStatistics(serverProcess: ChildProcess): Promise getSmapsRollupMemoryUsage(pid), + ); + return { - ...await getMemoryUsage(pid), - ...await getSmapsRollupMemoryUsage(pid), - ...await getRuntimeMemoryUsage(serverProcess), + memoryUsage: { + ...await getMemoryUsage(pid), + ...stableSmapsRollup.memoryUsage, + ...await getRuntimeMemoryUsage(serverProcess), + }, + stability: stableSmapsRollup.stability, }; } async function measureMemory() { - // Start the Misskey backend server using fork to enable IPC - const serverProcess = fork(join(__dirname, '../built/entry.js'), [], { - cwd: join(__dirname, '..'), + const serverProcess = fork(join(backendDir, 'built/entry.js'), [], { + cwd: backendDir, env: { ...process.env, NODE_ENV: 'production', @@ -209,16 +211,13 @@ async function measureMemory() { execArgv: [...process.execArgv, '--expose-gc'], }); - let serverReady = false; + const serverReady = waitForMessage( + serverProcess, + (message): message is 'ok' => message === 'ok', + 'server startup', + STARTUP_TIMEOUT, + ); - // Listen for the 'ok' message from the server indicating it's ready - serverProcess.on('message', (message) => { - if (message === 'ok') { - serverReady = true; - } - }); - - // Handle server output serverProcess.stdout?.on('data', (data) => { process.stderr.write(`[server stdout] ${data}`); }); @@ -227,7 +226,6 @@ async function measureMemory() { process.stderr.write(`[server stderr] ${data}`); }); - // Handle server error serverProcess.on('error', (err) => { process.stderr.write(`[server error] ${err}\n`); }); @@ -245,141 +243,55 @@ async function measureMemory() { if (message === 'gc unavailable') { throw new Error('GC is unavailable. Start the process with --expose-gc to enable this feature.'); } - - await setTimeout(1000); } - //function createRequest() { - // return new Promise((resolve, reject) => { - // const req = http.request({ - // host: 'localhost', - // port: 61812, - // path: '/api/meta', - // method: 'POST', - // }, (res) => { - // res.on('data', () => { }); - // res.on('end', () => { - // resolve(); - // }); - // }); - // req.on('error', (err) => { - // reject(err); - // }); - // req.end(); - // }); - //} - - // Wait for server to be ready or timeout const startupStartTime = Date.now(); - while (!serverReady) { - if (Date.now() - startupStartTime > STARTUP_TIMEOUT) { - serverProcess.kill('SIGTERM'); - throw new Error('Server startup timeout'); - } - await setTimeout(100); + try { + await serverReady; + } catch (err) { + serverProcess.kill('SIGTERM'); + throw err; } const startupTime = Date.now() - startupStartTime; process.stderr.write(`Server started in ${startupTime}ms\n`); - // Wait for memory to settle - await setTimeout(MEMORY_SETTLE_TIME); - - //const beforeGc = await getAllMemoryUsage(serverProcess); - await triggerGc(); - const memoryUsageAfterGC = await getAllMemoryUsage(serverProcess); - - //// create some http requests to simulate load - //await Promise.all( - // Array.from({ length: REQUEST_COUNT }).map(() => createRequest()), - //); - - //await triggerGc(); - - //const afterRequest = await getAllMemoryUsage(serverProcess); + const afterGc = await getAllMemoryUsage(serverProcess); + process.stderr.write(`Memory ${afterGc.stability.converged ? 'stabilized' : 'did not stabilize'} after ${afterGc.stability.readingCount} readings over ${Math.round(afterGc.stability.elapsedMs)}ms\n`); const heapSnapshotAfterGc = await getHeapSnapshotStatistics(serverProcess); - // Stop the server - serverProcess.kill('SIGTERM'); - - // Wait for process to exit - let exited = false; - await new Promise((resolve) => { - serverProcess.on('exit', () => { - exited = true; - resolve(undefined); - }); - // Force kill after 10 seconds if not exited - setTimeout(10000).then(() => { - if (!exited) { - serverProcess.kill('SIGKILL'); - } - resolve(undefined); + const serverExited = new Promise(resolve => { + const timer = globalThis.setTimeout(() => { + serverProcess.kill('SIGKILL'); + resolve(); + }, 10000); + serverProcess.once('exit', () => { + globalThis.clearTimeout(timer); + resolve(); }); }); + serverProcess.kill('SIGTERM'); + await serverExited; - const result = { + return { timestamp: new Date().toISOString(), phases: { - //beforeGc, afterGc: { - memoryUsage: memoryUsageAfterGC, + memoryUsage: afterGc.memoryUsage, + memoryStability: afterGc.stability, heapSnapshot: heapSnapshotAfterGc, }, - //afterRequest, }, }; - - return result; } -export type MemoryReportRaw = { - timestamp: string; - sampleCount: number; - measurement: { - startupTimeoutMs: number; - memorySettleTimeMs: number; - ipcTimeoutMs: number; - requestCount: number; - heapSnapshot: { - enabled: boolean; - timeoutMs: number; - breakdownTopN: number; - }; - }; - samples: Awaited>[]; -}; +export type MemorySample = Awaited>; async function main() { - const results = []; - for (let i = 0; i < SAMPLE_COUNT; i++) { - process.stderr.write(`Starting sample ${i + 1}/${SAMPLE_COUNT}\n`); - const res = await measureMemory(); - results.push(res); - } - - const result: MemoryReportRaw = { - timestamp: new Date().toISOString(), - sampleCount: SAMPLE_COUNT, - measurement: { - startupTimeoutMs: STARTUP_TIMEOUT, - memorySettleTimeMs: MEMORY_SETTLE_TIME, - ipcTimeoutMs: IPC_TIMEOUT, - requestCount: REQUEST_COUNT, - heapSnapshot: { - enabled: HEAP_SNAPSHOT, - timeoutMs: HEAP_SNAPSHOT_TIMEOUT, - breakdownTopN: HEAP_SNAPSHOT_BREAKDOWN_TOP_N, - }, - }, - samples: results, - }; - - // Output as JSON to stdout - console.log(JSON.stringify(result, null, 2)); + console.log(JSON.stringify(await measureMemory(), null, 2)); } main().catch((err) => { From 22cdc24b04fa1ca8af78de5b6da9b41df410fe91 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:10:30 +0900 Subject: [PATCH 066/168] add test --- .../scripts/memory-stability-util.test.mts | 110 ++++++++++++++++++ .github/workflows/lint.yml | 1 + 2 files changed, 111 insertions(+) create mode 100644 .github/scripts/memory-stability-util.test.mts diff --git a/.github/scripts/memory-stability-util.test.mts b/.github/scripts/memory-stability-util.test.mts new file mode 100644 index 0000000000..70bbb3924b --- /dev/null +++ b/.github/scripts/memory-stability-util.test.mts @@ -0,0 +1,110 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { measureMemoryUntilStable } from './memory-stability-util.mts'; + +function createTimer() { + let elapsedMs = 0; + + return { + now: () => elapsedMs, + wait: async (durationMs: number) => { + elapsedMs += durationMs; + }, + }; +} + +async function measure(readings: Record[]) { + let readCount = 0; + const result = await measureMemoryUntilStable( + async () => readings[readCount++], + createTimer(), + ); + return { result, readCount }; +} + +test('adopts the latest reading once Pss and Private_Dirty slopes converge', async () => { + const readings = [ + { Pss: 1000, Private_Dirty: 500, HeapUsed: 300 }, + { Pss: 1100, Private_Dirty: 520, HeapUsed: 310 }, + { Pss: 1200, Private_Dirty: 540, HeapUsed: 320 }, + ]; + const { result, readCount } = await measure(readings); + + assert.equal(readCount, 3); + assert.deepEqual(result.memoryUsage, readings[2]); + assert.deepEqual(result.stability, { + converged: true, + readingCount: 3, + elapsedMs: 4000, + maxAbsoluteSlopesKiBPerSecond: { + Pss: 50, + Private_Dirty: 10, + }, + }); +}); + +test('uses only the latest readings when determining convergence', async () => { + const readings = [ + { Pss: 1000, Private_Dirty: 500 }, + { Pss: 2000, Private_Dirty: 1000 }, + { Pss: 3000, Private_Dirty: 1500 }, + { Pss: 3040, Private_Dirty: 1520 }, + { Pss: 3080, Private_Dirty: 1540 }, + ]; + const { result, readCount } = await measure(readings); + + assert.equal(readCount, 5); + assert.equal(result.stability.converged, true); + assert.deepEqual(result.stability.maxAbsoluteSlopesKiBPerSecond, { + Pss: 20, + Private_Dirty: 10, + }); +}); + +test('bounds the wait and reports the latest slopes when memory does not converge', async () => { + const readings = [ + { Pss: 1000, Private_Dirty: 500 }, + { Pss: 1600, Private_Dirty: 520 }, + { Pss: 2200, Private_Dirty: 540 }, + { Pss: 2800, Private_Dirty: 560 }, + { Pss: 3400, Private_Dirty: 580 }, + { Pss: 4000, Private_Dirty: 600 }, + ]; + const { result, readCount } = await measure(readings); + + assert.equal(readCount, 6); + assert.deepEqual(result.memoryUsage, readings[5]); + assert.deepEqual(result.stability, { + converged: false, + readingCount: 6, + elapsedMs: 10000, + maxAbsoluteSlopesKiBPerSecond: { + Pss: 300, + Private_Dirty: 10, + }, + }); +}); + +test('does not treat opposing adjacent slopes as convergence', async () => { + const readings = [ + { Pss: 1000, Private_Dirty: 500 }, + { Pss: 1600, Private_Dirty: 500 }, + { Pss: 1000, Private_Dirty: 500 }, + { Pss: 1600, Private_Dirty: 500 }, + { Pss: 1000, Private_Dirty: 500 }, + { Pss: 1600, Private_Dirty: 500 }, + ]; + const { result, readCount } = await measure(readings); + + assert.equal(readCount, 6); + assert.equal(result.stability.converged, false); + assert.deepEqual(result.stability.maxAbsoluteSlopesKiBPerSecond, { + Pss: 300, + Private_Dirty: 0, + }); +}); diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index c8e24fb19b..63f8684c33 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -151,3 +151,4 @@ jobs: 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 From 212fcaf254352e8e682758044e4e18f93fb207ad Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:44:49 +0900 Subject: [PATCH 067/168] enhance(dev): tweak get-backend-memory --- .github/scripts/backend-memory-report.mts | 46 ++++++++++----------- packages/backend/scripts/measure-memory.mts | 8 ++-- 2 files changed, 26 insertions(+), 28 deletions(-) diff --git a/.github/scripts/backend-memory-report.mts b/.github/scripts/backend-memory-report.mts index 9aae10d887..fc64cb6db5 100644 --- a/.github/scripts/backend-memory-report.mts +++ b/.github/scripts/backend-memory-report.mts @@ -47,8 +47,7 @@ const memoryReportPhases = [ const metrics = [ 'HeapUsed', 'Pss', - 'Private_Dirty', - 'VmRSS', + 'USS', 'External', ] as const; @@ -57,16 +56,27 @@ function formatMemoryMb(valueKiB: number | null | undefined) { return `${util.formatNumber(valueKiB / 1000)} MB`; } -function getMemoryValue(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) { - return report.summary[phase].memoryUsage[metric]; +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]) { - return sample.phases[phase].memoryUsage[metric]; + 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 = report.samples.map(sample => getMemoryValueFromSample(sample, phase, metric)); + const values = getSampleValues(report, phase, metric); if (values.length < 2) return null; const center = util.median(values); @@ -91,9 +101,9 @@ function renderMainTableForPhase(base: MemoryReport, head: MemoryReport, phase: 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 = summary == null ? '-' : `${formatDeltaMemory(summary.median)}
${util.formatDeltaPercent(percent, 0.1).replaceAll('\\%', '\\\\%')}`; + const deltaMedian = `${formatDeltaMemory(summary.median)}
${util.formatDeltaPercent(percent, 0.1).replaceAll('\\%', '\\\\%')}`; - lines.push(`| **${metric}** | ${formatMemoryMb(baseValue)}
± ${formatMemoryMb(baseSpread)} | ${formatMemoryMb(headValue)}
± ${formatMemoryMb(headSpread)} | ${deltaMedian} | ${summary?.mad == null ? '-' : formatMemoryMb(summary.mad)} | ${summary == null ? '-' : formatDeltaMemory(summary.min)} | ${summary == null ? '-' : formatDeltaMemory(summary.max)} |`); + lines.push(`| **${formatMetricName(metric)}** | ${formatMemoryMb(baseValue)}
± ${formatMemoryMb(baseSpread)} | ${formatMemoryMb(headValue)}
± ${formatMemoryMb(headSpread)} | ${deltaMedian} | ${formatMemoryMb(summary.mad)} | ${formatDeltaMemory(summary.min)} | ${formatDeltaMemory(summary.max)} |`); } return lines.join('\n'); @@ -384,27 +394,15 @@ if (jsFootprintSection != null) { lines.push(''); } -function getWarningMetric(base: MemoryReport, head: MemoryReport) { - for (const metric of ['Pss', 'Private_Dirty', 'VmRSS'] as const) { - if (getMemoryValue(base, 'afterGc', metric) != null && getMemoryValue(head, 'afterGc', metric) != null) { - return metric; - } - } - return null; -} - 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); - if (baseValue == null || headValue == null || baseValue <= 0) return null; - 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); - if (baseValue == null || headValue == null) return false; const delta = headValue - baseValue; if (delta <= 0) return false; @@ -417,10 +415,10 @@ function isBeyondSampleNoise(base: MemoryReport, head: MemoryReport, phase: type return delta > combinedSpread * 3; } -const warningMetric = getWarningMetric(base, head); -const warningDiffPercent = warningMetric == null ? null : getDiffPercent(base, head, 'afterGc', warningMetric); -if (warningMetric != null && warningDiffPercent != null && warningDiffPercent > 5 && isBeyondSampleNoise(base, head, 'afterGc', warningMetric)) { - lines.push(`⚠️ **Warning**: Memory usage (${warningMetric}) has increased by more than 5% and exceeds the observed sample noise. Please verify this is not an unintended change.`); +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(''); } diff --git a/packages/backend/scripts/measure-memory.mts b/packages/backend/scripts/measure-memory.mts index 55aab61a84..a95eb3075c 100644 --- a/packages/backend/scripts/measure-memory.mts +++ b/packages/backend/scripts/measure-memory.mts @@ -57,14 +57,14 @@ type HeapSnapshotErrorMessage = { }; type HeapSnapshotResponseMessage = HeapSnapshotMessage | HeapSnapshotErrorMessage; -function parseMemoryFile(content: string, keys: KS, path: string, required: boolean): Record { +function parseMemoryFile(content: string, keys: KS, path: string): Record { const result = {} as Record; for (const _key of keys) { const key = _key as KS[number]; const match = content.match(new RegExp(`${key}:\\s+(\\d+)\\s+kB`)); if (match) { result[key] = parseInt(match[1], 10); - } else if (required) { + } else { throw new Error(`Failed to parse ${key} from ${path}`); } } @@ -78,13 +78,13 @@ function bytesToKiB(value: number) { async function getMemoryUsage(pid: number) { const path = `/proc/${pid}/status`; const status = await fs.readFile(path, 'utf-8'); - return parseMemoryFile(status, procStatusKeys, path, true); + return parseMemoryFile(status, procStatusKeys, path); } async function getSmapsRollupMemoryUsage(pid: number) { const path = `/proc/${pid}/smaps_rollup`; const smapsRollup = await fs.readFile(path, 'utf-8'); - return parseMemoryFile(smapsRollup, smapsRollupKeys, path, false); + return parseMemoryFile(smapsRollup, smapsRollupKeys, path); } function isRecord(value: unknown): value is Record { From b5e470b6d4c3635b382b2a6fe6b327b5d4a9f420 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:59:41 +0900 Subject: [PATCH 068/168] enhance(dev): tweak get-backend-memory --- .github/scripts/backend-memory-report.mts | 5 +- .../measure-backend-memory-comparison.mts | 46 +++++++++++-------- .github/workflows/get-backend-memory.yml | 9 +++- .github/workflows/report-backend-memory.yml | 19 +++++--- 4 files changed, 51 insertions(+), 28 deletions(-) diff --git a/.github/scripts/backend-memory-report.mts b/.github/scripts/backend-memory-report.mts index fc64cb6db5..4a701655ec 100644 --- a/.github/scripts/backend-memory-report.mts +++ b/.github/scripts/backend-memory-report.mts @@ -384,8 +384,9 @@ if (heapSnapshotSection != null) { lines.push(''); } -const artifactUrl = process.env.MK_MEMORY_HEAP_SNAPSHOT_ARTIFACT_URL_HEAD!.trim(); -lines.push(`[Download representative V8 heap snapshot (head)](${artifactUrl})`); +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); diff --git a/.github/scripts/measure-backend-memory-comparison.mts b/.github/scripts/measure-backend-memory-comparison.mts index ee95c68165..a4e93b3ce4 100644 --- a/.github/scripts/measure-backend-memory-comparison.mts +++ b/.github/scripts/measure-backend-memory-comparison.mts @@ -28,8 +28,15 @@ export type MemoryReport = { const [baseDirArg, headDirArg, baseOutputArg, headOutputArg] = process.argv.slice(2); const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = util.readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', heapSnapshotUtil.defaultHeapSnapshotBreakdownTopN, 1); -const HEAD_HEAP_SNAPSHOT_WORK_DIR = resolve('head-heap-snapshots'); -const HEAD_HEAP_SNAPSHOT_OUTPUT_PATH = resolve('head-heap-snapshot.heapsnapshot'); +const heapSnapshotLabels = ['base', 'head'] as const; +const HEAP_SNAPSHOT_WORK_DIRS = { + base: resolve('base-heap-snapshots'), + head: resolve('head-heap-snapshots'), +}; +const HEAP_SNAPSHOT_OUTPUT_PATHS = { + base: resolve('base-heap-snapshot.heapsnapshot'), + head: resolve('head-heap-snapshot.heapsnapshot'), +}; // Use the head checkout's measurement harness for both targets so only the built backend differs. const MEASURE_MEMORY_SCRIPT = resolve(import.meta.dirname, '../../packages/backend/scripts/measure-memory.mts'); @@ -119,11 +126,11 @@ async function measureRepo(label: string, repoDir: string, round: number, option return JSON.parse(stdout) as MemorySample; } -function headHeapSnapshotPath(round: number) { - return join(HEAD_HEAP_SNAPSHOT_WORK_DIR, `round-${round}.heapsnapshot`); +function heapSnapshotPath(label: typeof heapSnapshotLabels[number], round: number) { + return join(HEAP_SNAPSHOT_WORK_DIRS[label], `round-${round}.heapsnapshot`); } -function selectRepresentativeHeadHeapSnapshotRound(samples: MemoryReport['samples'], summary: MemoryReport['summary']) { +function selectRepresentativeHeapSnapshotRound(samples: MemoryReport['samples'], summary: MemoryReport['summary']) { const medianTotal = summary.afterGc.heapSnapshot?.categories?.total; if (medianTotal == null || !Number.isFinite(medianTotal)) return null; @@ -144,13 +151,13 @@ function selectRepresentativeHeadHeapSnapshotRound(samples: MemoryReport['sample return selected?.round ?? null; } -async function saveRepresentativeHeadHeapSnapshot(samples: MemoryReport['samples'], summary: MemoryReport['summary']) { - const round = selectRepresentativeHeadHeapSnapshotRound(samples, summary); +async function saveRepresentativeHeapSnapshot(label: typeof heapSnapshotLabels[number], samples: MemoryReport['samples'], summary: MemoryReport['summary']) { + const round = selectRepresentativeHeapSnapshotRound(samples, summary); if (round == null) return; - await copyFile(headHeapSnapshotPath(round), HEAD_HEAP_SNAPSHOT_OUTPUT_PATH); - process.stderr.write(`Selected head heap snapshot round ${round} for artifact\n`); - await rm(HEAD_HEAP_SNAPSHOT_WORK_DIR, { recursive: true, force: true }); + await copyFile(heapSnapshotPath(label, round), HEAP_SNAPSHOT_OUTPUT_PATHS[label]); + process.stderr.write(`Selected ${label} heap snapshot round ${round} for artifact\n`); + await rm(HEAP_SNAPSHOT_WORK_DIRS[label], { recursive: true, force: true }); } async function main() { @@ -162,8 +169,10 @@ async function main() { const warmupRounds = util.readIntegerEnv('MK_MEMORY_COMPARE_WARMUP_ROUNDS', 1, 0); const startedAt = new Date().toISOString(); - await rm(HEAD_HEAP_SNAPSHOT_WORK_DIR, { recursive: true, force: true }); - await rm(HEAD_HEAP_SNAPSHOT_OUTPUT_PATH, { force: true }); + for (const label of heapSnapshotLabels) { + await rm(HEAP_SNAPSHOT_WORK_DIRS[label], { recursive: true, force: true }); + await rm(HEAP_SNAPSHOT_OUTPUT_PATHS[label], { force: true }); + } const reports = { base: { @@ -178,7 +187,7 @@ async function main() { for (let round = 1; round <= warmupRounds; round++) { process.stderr.write(`Starting warmup round ${round}/${warmupRounds}\n`); - for (const label of ['base', 'head'] as const) { + for (const label of heapSnapshotLabels) { await measureRepo(label, reports[label].dir, -round); } } @@ -188,10 +197,7 @@ async function main() { process.stderr.write(`Starting measurement round ${round}/${rounds}: ${order.join(' -> ')}\n`); for (const label of order) { - const shouldSaveHeadHeapSnapshot = label === 'head'; - const options = shouldSaveHeadHeapSnapshot - ? { heapSnapshotSavePath: headHeapSnapshotPath(round) } - : {}; + const options = { heapSnapshotSavePath: heapSnapshotPath(label, round) }; const sample = await measureRepo(label, reports[label].dir, round, options); reports[label].samples.push({ ...sample, @@ -204,9 +210,11 @@ async function main() { base: summarizeSamples(reports.base.samples), head: summarizeSamples(reports.head.samples), }; - await saveRepresentativeHeadHeapSnapshot(reports.head.samples, summaries.head); + for (const label of heapSnapshotLabels) { + await saveRepresentativeHeapSnapshot(label, reports[label].samples, summaries[label]); + } - for (const label of ['base', 'head'] as const) { + for (const label of heapSnapshotLabels) { const report = { timestamp: new Date().toISOString(), sampleCount: reports[label].samples.length, diff --git a/.github/workflows/get-backend-memory.yml b/.github/workflows/get-backend-memory.yml index 0d0bec3307..03f82789a3 100644 --- a/.github/workflows/get-backend-memory.yml +++ b/.github/workflows/get-backend-memory.yml @@ -97,11 +97,18 @@ jobs: 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 + - name: Upload 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 head heap snapshot uses: actions/upload-artifact@v7 with: - name: backend-memory-head-heap-snapshot path: head-heap-snapshot.heapsnapshot + archive: false if-no-files-found: error retention-days: 7 - name: Measure backend loaded JS footprint diff --git a/.github/workflows/report-backend-memory.yml b/.github/workflows/report-backend-memory.yml index 3343ec745e..a6c0b4afdf 100644 --- a/.github/workflows/report-backend-memory.yml +++ b/.github/workflows/report-backend-memory.yml @@ -33,8 +33,8 @@ jobs: - name: Load PR Number id: load-pr-num run: echo "pr-number=$(cat artifacts/pr_number)" >> "$GITHUB_OUTPUT" - - name: Find head heap snapshot artifact - id: find-heap-snapshot-artifact + - name: Find heap snapshot artifacts + id: find-heap-snapshot-artifacts uses: actions/github-script@v8 with: script: | @@ -45,14 +45,21 @@ jobs: repo, run_id, }); - const artifact = artifacts.find(artifact => artifact.name === 'backend-memory-head-heap-snapshot'); - if (artifact == null) return; - core.setOutput('url', `https://github.com/${owner}/${repo}/actions/runs/${run_id}/artifacts/${artifact.id}`); + const artifactNames = { + base: 'base-heap-snapshot.heapsnapshot', + head: 'head-heap-snapshot.heapsnapshot', + }; + for (const [label, artifactName] of Object.entries(artifactNames)) { + const artifact = artifacts.find(artifact => artifact.name === artifactName); + if (artifact == null) throw new Error(`Artifact not found: ${artifactName}`); + core.setOutput(`${label}-url`, `https://github.com/${owner}/${repo}/actions/runs/${run_id}/artifacts/${artifact.id}`); + } - id: build-comment name: Build memory comment env: - MK_MEMORY_HEAP_SNAPSHOT_ARTIFACT_URL_HEAD: ${{ steps.find-heap-snapshot-artifact.outputs.url }} + 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 - uses: thollander/actions-comment-pull-request@v3 with: From ac8e8c48921ad4ae1926ef977c660b72d7953049 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:35:36 +0900 Subject: [PATCH 069/168] chore(dev): tweak get-backend-memory --- .github/workflows/get-backend-memory.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/get-backend-memory.yml b/.github/workflows/get-backend-memory.yml index 03f82789a3..1f933444bd 100644 --- a/.github/workflows/get-backend-memory.yml +++ b/.github/workflows/get-backend-memory.yml @@ -93,7 +93,7 @@ jobs: run: pnpm build - name: Measure backend memory usage env: - MK_MEMORY_COMPARE_ROUNDS: 5 + 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 From 828a24f6dfc7965d691d9195431fde66a33a6517 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=8A=E3=81=95=E3=82=80=E3=81=AE=E3=81=B2=E3=81=A8?= <46447427+samunohito@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:39:28 +0900 Subject: [PATCH 070/168] =?UTF-8?q?enhance:=20=E3=83=97=E3=83=AD=E3=82=BB?= =?UTF-8?q?=E3=82=B9=E8=B5=B7=E5=8B=95=E7=9B=B4=E5=BE=8C=E3=81=AE=E3=83=AD?= =?UTF-8?q?=E3=82=B0=E3=81=8B=E3=82=89=E6=96=B0logger=E9=81=A9=E7=94=A8?= =?UTF-8?q?=EF=BC=8Blogger=E8=A8=AD=E5=AE=9A=E6=A9=9F=E6=A7=8B=E3=81=AE?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0=20(#17725)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * enhance: プロセスのライフサイクルに合わせてlogger初期化・終了+logger設定機構の追加 * fix --------- Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com> --- .config/docker_example.yml | 8 ++ .config/example.yml | 8 ++ CHANGELOG.md | 1 + packages/backend/src/boot/entry.ts | 5 +- packages/backend/src/boot/master.ts | 11 +- packages/backend/src/boot/shutdown-handler.ts | 100 +++++++++++++++ packages/backend/src/boot/worker.ts | 21 +++- packages/backend/src/config.ts | 5 + .../src/core/telemetry/telemetry-shutdown.ts | 67 +--------- .../src/logging/BootstrapConsoleBackend.ts | 49 ++++++++ packages/backend/src/logging/LogManager.ts | 116 +++++++++++++++++- .../backend/src/logging/logging-runtime.ts | 15 ++- packages/backend/src/logging/types.ts | 3 + .../test/unit/boot/shutdown-handler.ts | 113 +++++++++++++++++ .../unit/logging/BootstrapConsoleBackend.ts | 54 ++++++++ .../backend/test/unit/logging/LogManager.ts | 90 +++++++++++++- .../backend/test/unit/telemetry-shutdown.ts | 59 --------- 17 files changed, 590 insertions(+), 135 deletions(-) create mode 100644 packages/backend/src/boot/shutdown-handler.ts create mode 100644 packages/backend/src/logging/BootstrapConsoleBackend.ts create mode 100644 packages/backend/test/unit/boot/shutdown-handler.ts create mode 100644 packages/backend/test/unit/logging/BootstrapConsoleBackend.ts delete mode 100644 packages/backend/test/unit/telemetry-shutdown.ts diff --git a/.config/docker_example.yml b/.config/docker_example.yml index 2f4ea329d2..bc2549d5cf 100644 --- a/.config/docker_example.yml +++ b/.config/docker_example.yml @@ -241,6 +241,14 @@ proxyBypassHosts: # Log settings # logging: +# # Minimum level for all loggers. Defaults to info in production and debug otherwise. +# level: info +# # The longest matching logger domain takes precedence over the global level. +# domains: +# core.boot: info +# queue: error +# queue.deliver: debug +# db.sql: off # sql: # # Outputs query parameters during SQL execution to the log. # # default: false diff --git a/.config/example.yml b/.config/example.yml index 14808e2795..961417cb30 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -474,6 +474,14 @@ proxyBypassHosts: # Log settings # logging: +# # Minimum level for all loggers. Defaults to info in production and debug otherwise. +# level: info +# # The longest matching logger domain takes precedence over the global level. +# domains: +# core.boot: info +# queue: error +# queue.deliver: debug +# db.sql: off # sql: # # Outputs query parameters during SQL execution to the log. # # default: false diff --git a/CHANGELOG.md b/CHANGELOG.md index d2cb4e8e40..1fc991d0a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ - Enhance: Docker Image の Node.js を 26.4.0 に、Debian を trixie (v13) に更新 - Enhance: URLプレビューの結果を内部でキャッシュするように - Enhance: API内部エラーのログに構造化属性と正規化したエラー情報を付与し、認証情報を自動的に秘匿するように(従来形式の表示は維持) +- Enhance: ログ全体の既定levelとlogger domainごとの出力levelを設定できるように - Fix: `/stats` API のレスポンス型が正しくない問題を修正 - Fix: ハッシュタグに関連するデータを更新する際のエラーハンドリングを修正 - Fix: Sentry 使用環境下にて、Misskey が発行した SQL クエリが span に含まれない問題を修正 diff --git a/packages/backend/src/boot/entry.ts b/packages/backend/src/boot/entry.ts index a35bd41272..3c2d56c73c 100644 --- a/packages/backend/src/boot/entry.ts +++ b/packages/backend/src/boot/entry.ts @@ -13,8 +13,8 @@ import { writeHeapSnapshot } from 'node:v8'; import chalk from 'chalk'; import Xev from 'xev'; import Logger from '@/logger.js'; -import { isTelemetryShutdownInProgress } from '@/core/telemetry/telemetry-shutdown.js'; import { envOption } from '../env.js'; +import { isShutdownInProgress } from './shutdown-handler.js'; import { readyRef } from './ready.js'; import 'reflect-metadata'; @@ -42,7 +42,7 @@ cluster.on('online', worker => { // Listen for dying workers cluster.on('exit', worker => { - if (isTelemetryShutdownInProgress()) { + if (isShutdownInProgress()) { clusterLogger.info(`Process exited during shutdown: [${worker.id}]`); return; } @@ -68,6 +68,7 @@ process.on('uncaughtException', err => { // Dying away... process.on('exit', code => { + if (isShutdownInProgress()) return; logger.info(`The process is going to exit with code ${code}`); }); diff --git a/packages/backend/src/boot/master.ts b/packages/backend/src/boot/master.ts index 59e1351ea1..4f78e602d0 100644 --- a/packages/backend/src/boot/master.ts +++ b/packages/backend/src/boot/master.ts @@ -11,11 +11,12 @@ import chalkTemplate from 'chalk-template'; import Logger from '@/logger.js'; import { loadConfig } from '@/config.js'; import type { Config } from '@/config.js'; +import { configureLogging, shutdownLogging } from '@/logging/logging-runtime.js'; import { showMachineInfo } from '@/misc/show-machine-info.js'; import { envOption } from '@/env.js'; -import { initTelemetry } from '@/core/telemetry/telemetry-registry.js'; -import { installTelemetrySignalHandlers } from '@/core/telemetry/telemetry-shutdown.js'; +import { initTelemetry, shutdownTelemetry } from '@/core/telemetry/telemetry-registry.js'; import { initExtraThreadPool, jobQueue, server } from './common.js'; +import { installShutdownSignalHandlers } from './shutdown-handler.js'; const logger = new Logger('core', 'cyan'); const bootLogger = logger.createSubLogger('boot', 'magenta'); @@ -74,7 +75,10 @@ export async function masterMain() { bootLogger.error(e instanceof Error ? e : new Error(String(e)), null, true); process.exit(1); } - installTelemetrySignalHandlers(); + installShutdownSignalHandlers({ + shutdownTasks: [shutdownTelemetry, shutdownLogging], + onRegistered: message => bootLogger.info(message), + }); bootLogger.info( `mode: [disableClustering: ${envOption.disableClustering}, onlyServer: ${envOption.onlyServer}, onlyQueue: ${envOption.onlyQueue}]`, @@ -138,6 +142,7 @@ function loadConfigBoot(): Config { try { config = loadConfig(); + configureLogging(config.logging); } catch (exception) { if (typeof exception === 'string') { configLogger.error(exception); diff --git a/packages/backend/src/boot/shutdown-handler.ts b/packages/backend/src/boot/shutdown-handler.ts new file mode 100644 index 0000000000..a0d85d1df6 --- /dev/null +++ b/packages/backend/src/boot/shutdown-handler.ts @@ -0,0 +1,100 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +type ShutdownSignalProcess = { + once(event: 'SIGTERM' | 'SIGINT', listener: () => Promise): unknown; +}; + +const SHUTDOWN_TIMEOUT_MS = 10_000; + +export type ShutdownTask = () => Promise; + +export type ShutdownHandlerOptions = { + /** The process-like object that receives the signal handlers. */ + process?: ShutdownSignalProcess; + /** Shutdown tasks, executed in array order. */ + shutdownTasks: readonly ShutdownTask[]; + /** Process termination function. */ + exit?: (code: number) => void; + /** Optional boot logger hook used after signal handlers are registered. */ + onRegistered?: (message: string) => void; +}; + +let shuttingDown = false; + +/** + * Register the process-level shutdown signals. + * + * Boot owns signal coordination and receives shutdown tasks through callbacks + * so individual domains do not depend on each other. + * + * 注意: このプロジェクトでは app.enableShutdownHooks() が一切呼ばれていないため、 + * NestJSのOnApplicationShutdown経由のgraceful shutdown(GlobalModule.dispose()によるDB/Redis切断、 + * QueueProcessorService.stop()によるqueue drain、ServerService.dispose()によるfastify/WebSocket close)は + * SIGTERM/SIGINTを起点には発火しない。このhandlerはそれらを経由せず、登録された終了処理を実行して即exitする。 + * 将来enableShutdownHooks()を配線する場合は、この即exitとNestJS側のshutdown sequenceが競合しないよう順序を設計すること。 + */ +export function installShutdownSignalHandlers(options: ShutdownHandlerOptions): void { + // テストではprocess/exitを差し替え、本番では実processにSIGTERM/SIGINT handlerを登録する。 + const processLike = options.process ?? process; + const exit = options.exit ?? ((code: number) => process.exit(code)); + + const handleSignal = async () => { + // 同時に複数signalが来てもflushを二重実行せず、cluster refork抑止用の状態もここで立てる。 + if (shuttingDown) return; + shuttingDown = true; + + let timedOut = false; + let timeout: NodeJS.Timeout | undefined; + try { + // 処理時間上限つきのシャットダウンプロセス + await Promise.race([ + (async () => { + for (const shutdownTask of options.shutdownTasks) { + if (timedOut) return; + try { + await shutdownTask(); + } catch (error) { + // 1つの終了処理の失敗で後続タスクを妨げないよう、stderrへフォールバックする。 + try { + console.error('Shutdown task failed:', error); + } catch { + // stderrの出力自体が失敗しても、残りの終了処理とexitは継続する。 + } + } + } + })(), + new Promise(resolve => { + timeout = setTimeout(() => { + timedOut = true; + try { + console.error(`Shutdown tasks timed out after ${SHUTDOWN_TIMEOUT_MS}ms.`); + } catch { + // stderrの出力自体が失敗してもexitは継続する。 + } + resolve(); + }, SHUTDOWN_TIMEOUT_MS); + }), + ]); + } finally { + if (timeout != null) clearTimeout(timeout); + } + + // 既存挙動と同じく、終了処理後はプロセスを終了する。 + exit(0); + }; + + // onceにして、同じsignalでhandlerが再入しないようにする。 + processLike.once('SIGTERM', handleSignal); + processLike.once('SIGINT', handleSignal); + + // app.enableShutdownHooks()未配線の現状、SIGTERM/SIGINT時には登録済み終了処理のみを行う。 + options.onRegistered?.('Registered SIGTERM/SIGINT shutdown handler (this process does not perform NestJS graceful shutdown on these signals).'); +} + +export function isShutdownInProgress(): boolean { + // masterのcluster exit handlerが、意図したshutdown中のworker終了を再forkしないために参照する。 + return shuttingDown; +} diff --git a/packages/backend/src/boot/worker.ts b/packages/backend/src/boot/worker.ts index 32bcbe8fb4..ad15296440 100644 --- a/packages/backend/src/boot/worker.ts +++ b/packages/backend/src/boot/worker.ts @@ -7,9 +7,11 @@ import cluster from 'node:cluster'; import Logger from '@/logger.js'; import { envOption } from '@/env.js'; import { loadConfig } from '@/config.js'; -import { initTelemetry } from '@/core/telemetry/telemetry-registry.js'; -import { installTelemetrySignalHandlers } from '@/core/telemetry/telemetry-shutdown.js'; +import type { Config } from '@/config.js'; +import { configureLogging, shutdownLogging } from '@/logging/logging-runtime.js'; +import { initTelemetry, shutdownTelemetry } from '@/core/telemetry/telemetry-registry.js'; import { initExtraThreadPool, jobQueue, server } from './common.js'; +import { installShutdownSignalHandlers } from './shutdown-handler.js'; const logger = new Logger('core', 'cyan'); const bootLogger = logger.createSubLogger('boot', 'magenta'); @@ -18,7 +20,15 @@ const bootLogger = logger.createSubLogger('boot', 'magenta'); * Init worker process */ export async function workerMain() { - const config = loadConfig(); + let config: Config; + try { + config = loadConfig(); + configureLogging(config.logging); + } catch (e) { + bootLogger.error(e instanceof Error ? e : new Error(String(e)), null, true); + process.exit(1); + return; + } initExtraThreadPool(config); @@ -28,7 +38,10 @@ export async function workerMain() { bootLogger.error(e instanceof Error ? e : new Error(String(e)), null, true); process.exit(1); } - installTelemetrySignalHandlers(); + installShutdownSignalHandlers({ + shutdownTasks: [shutdownTelemetry, shutdownLogging], + onRegistered: message => bootLogger.info(message), + }); if (envOption.onlyServer) { await server(); diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index de67daa31b..ab46416b35 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -10,6 +10,7 @@ import { type FastifyServerOptions } from 'fastify'; import type * as Sentry from '@sentry/node'; import type * as SentryVue from '@sentry/vue'; import type { RedisOptions } from 'ioredis'; +import type { LogLevelSetting } from './logging/types.js'; type RedisOptionsSource = Partial & { host: string; @@ -132,6 +133,8 @@ type Source = { pidFile: string; logging?: { + level?: LogLevelSetting; + domains?: Record | null; sql?: { disableQueryTruncation?: boolean, enableQueryParamLogging?: boolean, @@ -194,6 +197,8 @@ export type Config = { deliverJobMaxAttempts: number | undefined; inboxJobMaxAttempts: number | undefined; logging?: { + level?: LogLevelSetting; + domains?: Record | null; sql?: { disableQueryTruncation?: boolean, enableQueryParamLogging?: boolean, diff --git a/packages/backend/src/core/telemetry/telemetry-shutdown.ts b/packages/backend/src/core/telemetry/telemetry-shutdown.ts index 07d950f70d..1ee1b42649 100644 --- a/packages/backend/src/core/telemetry/telemetry-shutdown.ts +++ b/packages/backend/src/core/telemetry/telemetry-shutdown.ts @@ -3,64 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import Logger from '@/logger.js'; -import { shutdownTelemetry as defaultShutdownTelemetry } from './telemetry-registry.js'; - -const logger = new Logger('telemetry', 'green'); - -type SignalProcess = { - once(event: 'SIGTERM' | 'SIGINT', listener: () => Promise): unknown; -}; - -type InstallTelemetrySignalHandlersOptions = { - process?: SignalProcess; - shutdownTelemetry?: () => Promise; - exit?: (code: number) => void; -}; - -let shuttingDown = false; - -export function installTelemetrySignalHandlers(options: InstallTelemetrySignalHandlersOptions = {}): void { - // テストではprocess/exitを差し替え、本番では実processにSIGTERM/SIGINT handlerを登録する。 - // - // 注意: このプロジェクトでは app.enableShutdownHooks() が一切呼ばれていないため、 - // NestJSのOnApplicationShutdown経由のgraceful shutdown(GlobalModule.dispose()によるDB/Redis切断、 - // QueueProcessorService.stop()によるqueue drain、ServerService.dispose()によるfastify/WebSocket close)は - // 本PR以前から実質的に無効(SIGTERM/SIGINTを起点に発火することが無い)だった。 - // このhandlerはリポジトリで初めて有効になる本番SIGTERM/SIGINT handlerであり、telemetryのflushのみを行い - // 上記のgraceful shutdown処理を経由せずに即exitする。将来enableShutdownHooks()を配線する場合は、 - // この即exitとNestJS側のshutdown sequenceが競合しないよう順序を設計すること。 - const processLike = options.process ?? process; - const shutdownTelemetry = options.shutdownTelemetry ?? defaultShutdownTelemetry; - const exit = options.exit ?? ((code: number) => process.exit(code)); - - const handleSignal = async () => { - // 同時に複数signalが来てもflushを二重実行せず、cluster refork抑止用の状態もここで立てる。 - if (shuttingDown) return; - shuttingDown = true; - - try { - // 終了直前にBatchSpanProcessor/Sentryのbufferをflushする。 - // (DB/Redis/queue/HTTPサーバーのgraceful closeはここでは行わない。上記の注意を参照。) - await shutdownTelemetry(); - } catch { - // telemetry flushの失敗でプロセス終了が止まらないよう、ここでは握り潰す。 - } finally { - // 既存挙動と同じく、telemetry flush後はプロセスを終了する。 - exit(0); - } - }; - - // onceにして、同じsignalでhandlerが再入しないようにする。 - processLike.once('SIGTERM', handleSignal); - processLike.once('SIGINT', handleSignal); - - // app.enableShutdownHooks()未配線の現状、SIGTERM/SIGINT時の処理はtelemetry flushのみであることを - // 起動時に明示し、DB/Redis/queue/HTTPサーバーのgraceful closeが行われない点を運用者が把握できるようにする。 - logger.info('Registered SIGTERM/SIGINT handler for telemetry flush (this process does not perform NestJS graceful shutdown on these signals).'); -} - -export function isTelemetryShutdownInProgress(): boolean { - // masterのcluster exit handlerが、意図したshutdown中のworker終了を再forkしないために参照する。 - return shuttingDown; -} +/** + * Telemetry-specific shutdown is implemented by telemetry-registry.ts. + * Signal coordination belongs to boot/shutdown-handler.ts so telemetry and + * logging remain independent domains. + */ +export { shutdownTelemetry } from './telemetry-registry.js'; diff --git a/packages/backend/src/logging/BootstrapConsoleBackend.ts b/packages/backend/src/logging/BootstrapConsoleBackend.ts new file mode 100644 index 0000000000..b6b033c8b6 --- /dev/null +++ b/packages/backend/src/logging/BootstrapConsoleBackend.ts @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { LogBackend } from './LogBackend.js'; +import type { LogRecord } from './types.js'; + +/** 設定読込前の最小出力処理が外部から受け取る依存関係です。 */ +export type BootstrapConsoleBackendDependencies = { + readonly output: (...args: unknown[]) => void; +}; + +const defaultDependencies: BootstrapConsoleBackendDependencies = { + output: (...args) => console.log(...args), +}; + +/** + * 設定読込前でも利用できる、依存の少ないコンソール出力です。 + * 設定ファイルの読み込み失敗を報告するため、Pretty backendやTelemetryには依存しません。 + */ +export class BootstrapConsoleBackend implements LogBackend { + private readonly dependencies: BootstrapConsoleBackendDependencies; + + constructor(dependencies: Partial = {}) { + this.dependencies = { + ...defaultDependencies, + ...dependencies, + }; + } + + public write(record: LogRecord): void { + const worker = record.isPrimary ? '*' : record.workerId ?? '?'; + const line = `${record.timestamp} ${record.level.toUpperCase()} ${worker}\t[${record.loggerName}]\t${record.message}`; + const args: unknown[] = [line]; + + if (record.compatibility?.data != null) { + args.push(record.compatibility.data); + } else if (record.eventName != null || record.attributes != null || record.error != null) { + args.push({ + ...(record.eventName != null ? { eventName: record.eventName } : {}), + ...(record.attributes != null ? { attributes: record.attributes } : {}), + ...(record.error != null ? { error: record.error } : {}), + }); + } + + this.dependencies.output(...args); + } +} diff --git a/packages/backend/src/logging/LogManager.ts b/packages/backend/src/logging/LogManager.ts index 6fef23d35f..565bc5e0b4 100644 --- a/packages/backend/src/logging/LogManager.ts +++ b/packages/backend/src/logging/LogManager.ts @@ -13,7 +13,7 @@ import { type LogNormalizationProfile, } from './LogNormalizer.js'; import type { LogBackend } from './LogBackend.js'; -import type { LogRecord, LogRecordInput } from './types.js'; +import type { LogLevel, LogLevelSetting, LogRecord, LogRecordInput } from './types.js'; /** ログを出力したプロセスを識別するための情報です。 */ export type LogProcessInfo = { @@ -39,6 +39,60 @@ export type LogManagerOptions = { readonly normalizationProfile?: LogNormalizationProfile; }; +/** 起動時に適用するログ出力設定です。 */ +export type LogManagerConfiguration = { + readonly level?: LogLevelSetting; + readonly domains?: Readonly> | null; +}; + +const logLevelOrder: Readonly> = { + debug: 0, + info: 1, + warn: 2, + error: 3, + fatal: 4, +}; + +const validLogLevels = new Set(['debug', 'info', 'warn', 'error', 'fatal', 'off']); + +function validateLogLevel(value: unknown, path: string): LogLevelSetting | undefined { + if (typeof value === 'undefined') return undefined; + if (typeof value !== 'string' || !validLogLevels.has(value as LogLevelSetting)) { + throw new Error(`${path} must be one of debug, info, warn, error, fatal, or off`); + } + return value as LogLevelSetting; +} + +function validateDomainName(domain: string): void { + if (domain.length === 0 || domain.trim() !== domain || domain.split('.').some(segment => segment.length === 0)) { + throw new Error(`logging.domains contains an invalid domain name: ${JSON.stringify(domain)}`); + } +} + +function resolveConfiguration(configuration: LogManagerConfiguration | undefined): { + readonly level: LogLevelSetting | undefined; + readonly domains: readonly (readonly [string, LogLevelSetting])[]; +} { + if (configuration == null) return { level: undefined, domains: [] }; + + const level = validateLogLevel(configuration.level, 'logging.level'); + if (configuration.domains == null) return { level, domains: [] }; + if (typeof configuration.domains !== 'object' || configuration.domains === null || Array.isArray(configuration.domains)) { + throw new Error('logging.domains must be an object'); + } + + const domains = Object.entries(configuration.domains).map(([domain, value]) => { + validateDomainName(domain); + const level = validateLogLevel(value, `logging.domains.${domain}`); + if (typeof level === 'undefined') { + throw new Error(`logging.domains.${domain} must be configured`); + } + return [domain, level] as const; + }).sort((left, right) => right[0].length - left[0].length); + + return { level, domains }; +} + const defaultDependencies: LogManagerDependencies = { now: () => new Date(), getProcessInfo: () => ({ @@ -59,6 +113,9 @@ export class LogManager { private backend: LogBackend; private readonly dependencies: LogManagerDependencies; private normalizationProfile: LogNormalizationProfile; + private configuredLevel: LogLevelSetting | undefined; + private configuredDomains: readonly (readonly [string, LogLevelSetting])[]; + private shutdownPromise: Promise | undefined; /** * 出力先と実行環境から値を取得する処理を受け取ります。 @@ -75,6 +132,8 @@ export class LogManager { ...dependencies, }; this.normalizationProfile = options.normalizationProfile ?? 'standard'; + this.configuredLevel = undefined; + this.configuredDomains = []; } /** @@ -85,11 +144,60 @@ export class LogManager { this.backend = backend; } + /** 起動時の既定levelとdomain別levelを適用します。 */ + public configure(configuration?: LogManagerConfiguration): void { + const resolved = resolveConfiguration(configuration); + this.configuredLevel = resolved.level; + this.configuredDomains = resolved.domains; + } + /** 正規化方式を切り替え、既に作成済みのLoggerにも反映します。 */ public setNormalizationProfile(profile: LogNormalizationProfile): void { this.normalizationProfile = profile; } + /** backendに残っているログをflushしてから終了処理を行います。 */ + public shutdown(): Promise { + if (this.shutdownPromise != null) return this.shutdownPromise; + + this.shutdownPromise = (async () => { + try { + await this.backend.flush?.(); + } finally { + await this.backend.close?.(); + } + })(); + + return this.shutdownPromise; + } + + private getDefaultLevel(): LogLevel { + if (this.dependencies.isVerbose()) return 'debug'; + return this.dependencies.getNodeEnv() === 'production' ? 'info' : 'debug'; + } + + private getThreshold(loggerName: string): LogLevelSetting { + let threshold: LogLevelSetting | undefined; + for (const [domain, level] of this.configuredDomains) { + if (loggerName === domain || loggerName.startsWith(`${domain}.`)) { + threshold = level; + break; + } + } + + threshold ??= this.configuredLevel ?? this.getDefaultLevel(); + + // verboseは障害調査用の緊急モードとして、明示されたoff以外をdebugまで下げる。 + // offは意図的な無効化なので、verboseでも再有効化しない。 + return threshold === 'off' || !this.dependencies.isVerbose() ? threshold : 'debug'; + } + + private shouldWrite(input: LogRecordInput, loggerName: string): boolean { + const threshold = this.getThreshold(loggerName); + if (threshold === 'off') return false; + return logLevelOrder[input.level] >= logLevelOrder[threshold]; + } + /** * 出力条件を確認し、共通情報を付加して出力先へ渡します。 */ @@ -97,8 +205,8 @@ export class LogManager { // `quiet`は他の条件より優先し、ログに付随する情報の取得も行いません。 if (this.dependencies.isQuiet()) return; - // 本番環境のデバッグログは、明示的に`verbose`が指定された場合だけ出力します。 - if (input.level === 'debug' && this.dependencies.getNodeEnv() === 'production' && !this.dependencies.isVerbose()) return; + const loggerName = input.context.map(segment => segment.name).join('.'); + if (!this.shouldWrite(input, loggerName)) return; const processInfo = this.dependencies.getProcessInfo(); // 呼び出し側の配列を共有せず、親から末端までの順序を固定します。 @@ -116,7 +224,7 @@ export class LogManager { ...inputWithoutStructuredValues, context, timestamp: this.dependencies.now().toISOString(), - loggerName: context.map(segment => segment.name).join('.'), + loggerName, processId: processInfo.processId, isPrimary: processInfo.isPrimary, workerId: processInfo.workerId, diff --git a/packages/backend/src/logging/logging-runtime.ts b/packages/backend/src/logging/logging-runtime.ts index 79890ad496..e4f97f56e6 100644 --- a/packages/backend/src/logging/logging-runtime.ts +++ b/packages/backend/src/logging/logging-runtime.ts @@ -4,10 +4,23 @@ */ import { LogManager } from './LogManager.js'; +import { BootstrapConsoleBackend } from './BootstrapConsoleBackend.js'; import { PrettyConsoleBackend } from './PrettyConsoleBackend.js'; +import type { LogManagerConfiguration } from './LogManager.js'; /** * プロセス内のすべてのLoggerが共有するLogManagerです。 * Logger作成後も同じLogManagerを参照するため、出力先の切り替えを一括で反映できます。 */ -export const logManager = new LogManager(new PrettyConsoleBackend()); +export const logManager = new LogManager(new BootstrapConsoleBackend()); + +/** 設定読込後のlogging設定とPretty backendを適用します。 */ +export function configureLogging(configuration?: LogManagerConfiguration): void { + logManager.configure(configuration); + logManager.setBackend(new PrettyConsoleBackend()); +} + +/** プロセス終了前に現在のlogging backendをflushして閉じます。 */ +export function shutdownLogging(): Promise { + return logManager.shutdown(); +} diff --git a/packages/backend/src/logging/types.ts b/packages/backend/src/logging/types.ts index aa3b63edec..1993cd1809 100644 --- a/packages/backend/src/logging/types.ts +++ b/packages/backend/src/logging/types.ts @@ -8,6 +8,9 @@ import type { Keyword } from 'color-convert'; /** ログの重要度を表します。 */ export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal'; +/** 設定で指定できるログの閾値です。`off`はログイベントのlevelには使用しません。 */ +export type LogLevelSetting = LogLevel | 'off'; + /** 正規化後にログ属性として扱えるJSONの値です。 */ export type LogAttributeValue = | string diff --git a/packages/backend/test/unit/boot/shutdown-handler.ts b/packages/backend/test/unit/boot/shutdown-handler.ts new file mode 100644 index 0000000000..399820f08c --- /dev/null +++ b/packages/backend/test/unit/boot/shutdown-handler.ts @@ -0,0 +1,113 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, test, vi } from 'vitest'; + +describe('shutdown-handler', () => { + test('runs shutdown tasks once and exits on SIGTERM or SIGINT', async () => { + vi.resetModules(); + const { installShutdownSignalHandlers, isShutdownInProgress } = await import('@/boot/shutdown-handler.js'); + const handlers = new Map Promise>(); + const processLike = { + once: vi.fn((event: string, handler: () => Promise) => { + handlers.set(event, handler); + return processLike; + }), + }; + const calls: string[] = []; + const shutdownTelemetry = vi.fn(async () => { + calls.push('telemetry'); + }); + const shutdownLogging = vi.fn(async () => { + calls.push('logging'); + }); + const exit = vi.fn(); + const onRegistered = vi.fn(); + + installShutdownSignalHandlers({ process: processLike, shutdownTasks: [shutdownTelemetry, shutdownLogging], exit, onRegistered }); + + expect(processLike.once).toHaveBeenCalledWith('SIGTERM', expect.any(Function)); + expect(processLike.once).toHaveBeenCalledWith('SIGINT', expect.any(Function)); + expect(onRegistered).toHaveBeenCalledOnce(); + expect(isShutdownInProgress()).toBe(false); + + await handlers.get('SIGTERM')!(); + await handlers.get('SIGINT')!(); + + expect(isShutdownInProgress()).toBe(true); + expect(calls).toEqual(['telemetry', 'logging']); + expect(shutdownTelemetry).toHaveBeenCalledTimes(1); + expect(shutdownLogging).toHaveBeenCalledTimes(1); + expect(exit).toHaveBeenCalledTimes(1); + expect(exit).toHaveBeenCalledWith(0); + }); + + test('continues with later shutdown tasks when an earlier task rejects', async () => { + vi.resetModules(); + const { installShutdownSignalHandlers } = await import('@/boot/shutdown-handler.js'); + const handlers = new Map Promise>(); + const processLike = { + once: vi.fn((event: string, handler: () => Promise) => { + handlers.set(event, handler); + return processLike; + }), + }; + const exit = vi.fn(); + const shutdownLogging = vi.fn().mockResolvedValue(undefined); + const shutdownError = new Error('flush failed'); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + + try { + installShutdownSignalHandlers({ + process: processLike, + shutdownTasks: [vi.fn().mockRejectedValue(shutdownError), shutdownLogging], + exit, + }); + + await handlers.get('SIGINT')!(); + + expect(exit).toHaveBeenCalledWith(0); + expect(shutdownLogging).toHaveBeenCalledOnce(); + expect(consoleError).toHaveBeenCalledWith('Shutdown task failed:', shutdownError); + } finally { + consoleError.mockRestore(); + } + }); + + test('exits after the shutdown deadline when a task remains pending', async () => { + vi.resetModules(); + vi.useFakeTimers(); + const { installShutdownSignalHandlers } = await import('@/boot/shutdown-handler.js'); + const handlers = new Map Promise>(); + const processLike = { + once: vi.fn((event: string, handler: () => Promise) => { + handlers.set(event, handler); + return processLike; + }), + }; + const exit = vi.fn(); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + + try { + installShutdownSignalHandlers({ + process: processLike, + shutdownTasks: [() => new Promise(() => {})], + exit, + }); + + const signalPromise = handlers.get('SIGTERM')!(); + expect(exit).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(10_000); + await signalPromise; + + expect(consoleError).toHaveBeenCalledWith('Shutdown tasks timed out after 10000ms.'); + expect(exit).toHaveBeenCalledWith(0); + } finally { + consoleError.mockRestore(); + vi.useRealTimers(); + } + }); +}); diff --git a/packages/backend/test/unit/logging/BootstrapConsoleBackend.ts b/packages/backend/test/unit/logging/BootstrapConsoleBackend.ts new file mode 100644 index 0000000000..43545a0693 --- /dev/null +++ b/packages/backend/test/unit/logging/BootstrapConsoleBackend.ts @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, test, vi } from 'vitest'; +import type { LogRecord } from '@/logging/types.js'; +import { BootstrapConsoleBackend } from '@/logging/BootstrapConsoleBackend.js'; + +function createRecord(overrides: Partial = {}): LogRecord { + return { + level: 'error', + message: 'configuration failed', + context: [{ name: 'core' }, { name: 'boot' }], + timestamp: '2025-01-02T03:04:05.678Z', + loggerName: 'core.boot', + processId: 1234, + isPrimary: true, + workerId: null, + ...overrides, + }; +} + +describe('BootstrapConsoleBackend', () => { + test('writes a minimal line with legacy data', () => { + const output = vi.fn(); + const backend = new BootstrapConsoleBackend({ output }); + const data = { detail: 'failed' }; + + backend.write(createRecord({ compatibility: { data } })); + + expect(output).toHaveBeenCalledWith('2025-01-02T03:04:05.678Z ERROR *\t[core.boot]\tconfiguration failed', data); + }); + + test('writes structured details without depending on Pretty formatting', () => { + const output = vi.fn(); + const backend = new BootstrapConsoleBackend({ output }); + + backend.write(createRecord({ + eventName: 'config.load.failed', + attributes: { path: '.config/default.yml' }, + error: { type: 'Error', message: 'not found' }, + })); + + expect(output).toHaveBeenCalledWith( + '2025-01-02T03:04:05.678Z ERROR *\t[core.boot]\tconfiguration failed', + { + eventName: 'config.load.failed', + attributes: { path: '.config/default.yml' }, + error: { type: 'Error', message: 'not found' }, + }, + ); + }); +}); diff --git a/packages/backend/test/unit/logging/LogManager.ts b/packages/backend/test/unit/logging/LogManager.ts index 9329059033..eac853afdb 100644 --- a/packages/backend/test/unit/logging/LogManager.ts +++ b/packages/backend/test/unit/logging/LogManager.ts @@ -4,9 +4,9 @@ */ import { describe, expect, test, vi } from 'vitest'; -import { LogManager } from '@/logging/LogManager.js'; import type { LogBackend } from '@/logging/LogBackend.js'; import type { LogRecordInput } from '@/logging/types.js'; +import { LogManager } from '@/logging/LogManager.js'; /** テストで使う最小構成のログ入力を作成します。 */ function createInput(level: LogRecordInput['level'] = 'info'): LogRecordInput { @@ -28,6 +28,10 @@ function createManager(options: { isPrimary?: boolean; workerId?: number | null; normalizationProfile?: 'standard' | 'detailed'; + configuration?: { + level?: 'debug' | 'info' | 'warn' | 'error' | 'fatal' | 'off'; + domains?: Record; + }; } = {}) { const write = vi.fn(); const manager = new LogManager({ write }, { @@ -43,6 +47,7 @@ function createManager(options: { }, { normalizationProfile: options.normalizationProfile, }); + if (options.configuration) manager.configure(options.configuration); return { manager, write }; } @@ -115,6 +120,76 @@ describe('LogManager', () => { expect(write).toHaveBeenCalledOnce(); }); + test('applies the configured global level', () => { + const { manager, write } = createManager({ configuration: { level: 'warn' } }); + + manager.write(createInput('info')); + manager.write(createInput('warn')); + + expect(write).toHaveBeenCalledOnce(); + expect(write.mock.calls[0][0].level).toBe('warn'); + }); + + test('uses the longest matching domain and supports child re-enablement', () => { + const { manager, write } = createManager({ + configuration: { + level: 'info', + domains: { + queue: 'off', + 'queue.deliver': 'debug', + }, + }, + }); + + manager.write({ ...createInput('info'), context: [{ name: 'queue' }, { name: 'inbox' }] }); + manager.write({ ...createInput('debug'), context: [{ name: 'queue' }, { name: 'deliver' }] }); + manager.write({ ...createInput('debug'), context: [{ name: 'queueing' }] }); + + expect(write).toHaveBeenCalledOnce(); + expect(write.mock.calls[0][0].loggerName).toBe('queue.deliver'); + }); + + test('lowers configured levels to debug in verbose mode', () => { + const { manager, write } = createManager({ + nodeEnv: 'production', + verbose: true, + configuration: { level: 'info' }, + }); + + manager.write(createInput('debug')); + + expect(write).toHaveBeenCalledOnce(); + }); + + test('keeps explicit off levels disabled in verbose mode', () => { + const { manager, write } = createManager({ + verbose: true, + configuration: { + level: 'off', + domains: { + queue: 'off', + 'queue.deliver': 'warn', + }, + }, + }); + + manager.write({ ...createInput('fatal'), context: [{ name: 'system' }] }); + manager.write({ ...createInput('debug'), context: [{ name: 'queue' }] }); + manager.write({ ...createInput('debug'), context: [{ name: 'queue' }, { name: 'deliver' }] }); + + expect(write).toHaveBeenCalledOnce(); + expect(write.mock.calls[0][0].loggerName).toBe('queue.deliver'); + }); + + test('rejects invalid logging configuration', () => { + const { manager } = createManager(); + + expect(() => manager.configure({ domains: null })).not.toThrow(); + expect(() => manager.configure({ level: 'notice' as never })).toThrow('logging.level'); + expect(() => manager.configure({ domains: { queue: 'notice' as never } })).toThrow('logging.domains.queue'); + expect(() => manager.configure({ domains: { 'queue.': 'info' } })).toThrow('invalid domain name'); + }); + test('uses a replaced backend for subsequent records', () => { const { manager, write } = createManager(); const replacementWrite = vi.fn(); @@ -126,6 +201,19 @@ describe('LogManager', () => { expect(replacementWrite).toHaveBeenCalledOnce(); }); + test('flushes and closes the backend once during shutdown', async () => { + const write = vi.fn(); + const flush = vi.fn>().mockResolvedValue(undefined); + const close = vi.fn>().mockResolvedValue(undefined); + const manager = new LogManager({ write, flush, close }); + + await Promise.all([manager.shutdown(), manager.shutdown()]); + + expect(flush).toHaveBeenCalledOnce(); + expect(close).toHaveBeenCalledOnce(); + expect(flush.mock.invocationCallOrder[0]).toBeLessThan(close.mock.invocationCallOrder[0]); + }); + test('normalizes structured attributes and errors before writing', () => { const { manager, write } = createManager(); const error = new TypeError('broken'); diff --git a/packages/backend/test/unit/telemetry-shutdown.ts b/packages/backend/test/unit/telemetry-shutdown.ts deleted file mode 100644 index 11c47c4181..0000000000 --- a/packages/backend/test/unit/telemetry-shutdown.ts +++ /dev/null @@ -1,59 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { describe, expect, test, vi } from 'vitest'; - -describe('telemetry-shutdown', () => { - test('flushes telemetry once and exits on SIGTERM or SIGINT', async () => { - vi.resetModules(); - const { installTelemetrySignalHandlers, isTelemetryShutdownInProgress } = await import('@/core/telemetry/telemetry-shutdown.js'); - const handlers = new Map Promise>(); - const processLike = { - once: vi.fn((event: string, handler: () => Promise) => { - handlers.set(event, handler); - return processLike; - }), - }; - const shutdownTelemetry = vi.fn().mockResolvedValue(undefined); - const exit = vi.fn(); - - installTelemetrySignalHandlers({ process: processLike, shutdownTelemetry, exit }); - - expect(processLike.once).toHaveBeenCalledWith('SIGTERM', expect.any(Function)); - expect(processLike.once).toHaveBeenCalledWith('SIGINT', expect.any(Function)); - expect(isTelemetryShutdownInProgress()).toBe(false); - - await handlers.get('SIGTERM')!(); - await handlers.get('SIGINT')!(); - - expect(isTelemetryShutdownInProgress()).toBe(true); - expect(shutdownTelemetry).toHaveBeenCalledTimes(1); - expect(exit).toHaveBeenCalledTimes(1); - expect(exit).toHaveBeenCalledWith(0); - }); - - test('exits even when telemetry shutdown rejects', async () => { - vi.resetModules(); - const { installTelemetrySignalHandlers } = await import('@/core/telemetry/telemetry-shutdown.js'); - const handlers = new Map Promise>(); - const processLike = { - once: vi.fn((event: string, handler: () => Promise) => { - handlers.set(event, handler); - return processLike; - }), - }; - const exit = vi.fn(); - - installTelemetrySignalHandlers({ - process: processLike, - shutdownTelemetry: vi.fn().mockRejectedValue(new Error('flush failed')), - exit, - }); - - await handlers.get('SIGINT')!(); - - expect(exit).toHaveBeenCalledWith(0); - }); -}); From 875554003e7c4b039ce6ed1a58186e4a1089bb6e Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:34:48 +0900 Subject: [PATCH 071/168] feat(dev): report frontend browser diagnostics --- .github/scripts/chrome.mts | 69 +++++++++++++ .github/scripts/frontend-browser-report.mts | 14 ++- .../scripts/frontend-browser-report.test.mts | 98 +++++++++++++++++++ .../measure-frontend-browser-comparison.mts | 4 +- .github/workflows/lint.yml | 1 + 5 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 .github/scripts/frontend-browser-report.test.mts diff --git a/.github/scripts/chrome.mts b/.github/scripts/chrome.mts index 5d07ac6efe..2be10bd261 100644 --- a/.github/scripts/chrome.mts +++ b/.github/scripts/chrome.mts @@ -7,6 +7,7 @@ import { createRequire } from 'node:module'; import { writeFile } from 'node:fs/promises'; import type { Browser, BrowserContext, CDPSession, Page } from 'playwright'; import type { HeapSnapshotData } from './heap-snapshot-util.mts'; +import * as util from './utility.mts'; export type NetworkRequest = { requestId: string; @@ -89,11 +90,65 @@ export type TabMemory = { totalBytes: number; }; +export type BrowserConsoleMetrics = { + log: number; + warn: number; + error: number; + info: number; +}; + +export type BrowserDiagnostics = { + pageErrorCount: number; + console: BrowserConsoleMetrics; +}; + +export function createBrowserDiagnostics(): BrowserDiagnostics { + return { + pageErrorCount: 0, + console: { + log: 0, + warn: 0, + error: 0, + info: 0, + }, + }; +} + +export function recordPageError(diagnostics: BrowserDiagnostics) { + diagnostics.pageErrorCount += 1; +} + +function isBrowserConsoleMetricKey(value: string): value is keyof BrowserConsoleMetrics { + return value === 'log' || value === 'warn' || value === 'error' || value === 'info'; +} + +export function recordConsoleMessageType(diagnostics: BrowserDiagnostics, messageType: string) { + const metricKey = messageType === 'warning' ? 'warn' : messageType; + if (!isBrowserConsoleMetricKey(metricKey)) return; + diagnostics.console[metricKey] += 1; +} + +export function summarizeBrowserDiagnostics(samples: BrowserDiagnostics[]): BrowserDiagnostics { + if (samples.length === 0) throw new Error('No browser diagnostic samples'); + const median = (select: (sample: BrowserDiagnostics) => number) => util.median(samples.map(select)); + + return { + pageErrorCount: median(sample => sample.pageErrorCount), + console: { + log: median(sample => sample.console.log), + warn: median(sample => sample.console.warn), + 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 +204,7 @@ type PlaywrightBrowserOptions = { export class HeadlessChromeController { public networkRequests: NetworkRequest[] = []; public webSocketConnections: WebSocketConnection[] = []; + private readonly diagnostics = createBrowserDiagnostics(); private readonly browser: Browser; private readonly context: BrowserContext; public readonly page: Page; @@ -168,6 +224,12 @@ export class HeadlessChromeController { this.cdp = cdp; this.page.setDefaultTimeout(options.scenarioTimeoutMs); this.page.setDefaultNavigationTimeout(options.scenarioTimeoutMs); + this.page.on('pageerror', () => { + recordPageError(this.diagnostics); + }); + this.page.on('console', message => { + recordConsoleMessageType(this.diagnostics, message.type()); + }); } static async create(label: string, options: PlaywrightBrowserOptions): Promise { @@ -376,6 +438,13 @@ export class HeadlessChromeController { ]) as T; } + public collectDiagnostics(): BrowserDiagnostics { + return { + pageErrorCount: this.diagnostics.pageErrorCount, + console: { ...this.diagnostics.console }, + }; + } + public async collectPerformance(): Promise { const cdpMetricsResult = await this.cdp.send<{ metrics: { name: string; value: number }[] }>('Performance.getMetrics'); const cdpMetrics = Object.fromEntries(cdpMetricsResult.metrics.map(metric => [metric.name, metric.value])); diff --git a/.github/scripts/frontend-browser-report.mts b/.github/scripts/frontend-browser-report.mts index 755b29bc88..829041f1d0 100644 --- a/.github/scripts/frontend-browser-report.mts +++ b/.github/scripts/frontend-browser-report.mts @@ -8,13 +8,14 @@ import { pathToFileURL } from 'node:url'; import * as util from './utility.mts'; import * as heapSnapshotUtil from './heap-snapshot-util.mts'; import type { HeapSnapshotData, HeapSnapshotReport } from './heap-snapshot-util.mts'; -import type { NetworkRequest } from './chrome.mts'; +import type { BrowserDiagnostics, NetworkRequest } from './chrome.mts'; export type BrowserMeasurement = { label: string; timestamp: string; url: string; scenario: string; + diagnostics: BrowserDiagnostics; durationMs: number; network: { requestCount: number; @@ -171,8 +172,19 @@ function getMetric(report: BrowserMeasurement, key: string) { return report.performance.cdpMetrics[key]; } +export function renderBrowserDiagnosticsRows(base: BrowserMetricsReport, head: BrowserMetricsReport, all = false) { + return [ + 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.warn, sample => sample.diagnostics.console.warn, 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), + ].filter(row => row != null); +} + function renderSummaryTable(base: BrowserMetricsReport, head: BrowserMetricsReport, all = false) { const rows = [ + ...renderBrowserDiagnosticsRows(base, head, all), //metricRow('Scenario duration', base, head, summary => summary.durationMs, sample => sample.durationMs, formatMs), metricRow('Requests', base, head, summary => summary.network.requestCount, sample => sample.network.requestCount, util.formatNumber, 1, !all), //metricRow('Failed requests', base, head, summary => summary.network.failedRequestCount, sample => sample.network.failedRequestCount, util.formatNumber), diff --git a/.github/scripts/frontend-browser-report.test.mts b/.github/scripts/frontend-browser-report.test.mts new file mode 100644 index 0000000000..4d4f130504 --- /dev/null +++ b/.github/scripts/frontend-browser-report.test.mts @@ -0,0 +1,98 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createBrowserDiagnostics, + recordConsoleMessageType, + recordPageError, + summarizeBrowserDiagnostics, + type BrowserDiagnostics, +} from './chrome.mts'; +import { + renderBrowserDiagnosticsRows, + type BrowserMeasurement, + type BrowserMeasurementSample, + type BrowserMetricsReport, +} from './frontend-browser-report.mts'; + +function diagnostics(pageErrorCount: number, log: number, warn: number, error: number, info: number): BrowserDiagnostics { + return { + pageErrorCount, + console: { log, warn, error, info }, + }; +} + +function browserReport(label: 'base' | 'head', values: BrowserDiagnostics[]): BrowserMetricsReport { + const samples = values.map((value, index) => ({ + round: index + 1, + diagnostics: value, + } as BrowserMeasurementSample)); + + return { + label, + timestamp: '2026-07-16T00:00:00.000Z', + url: 'http://127.0.0.1:61812', + scenario: 'test', + sampleCount: samples.length, + aggregation: 'median', + summary: { + diagnostics: summarizeBrowserDiagnostics(values), + } as BrowserMeasurement, + samples, + }; +} + +test('records page errors and only the requested console message types', () => { + const result = createBrowserDiagnostics(); + recordPageError(result); + recordPageError(result); + for (const type of ['log', 'warning', 'error', 'info', 'debug', 'trace']) { + recordConsoleMessageType(result, type); + } + + assert.deepEqual(result, diagnostics(2, 1, 1, 1, 1)); +}); + +test('summarizes browser diagnostics with a median for every counter', () => { + const result = summarizeBrowserDiagnostics([ + diagnostics(0, 10, 20, 30, 40), + diagnostics(4, 14, 24, 34, 44), + diagnostics(2, 12, 22, 32, 42), + diagnostics(3, 13, 23, 33, 43), + diagnostics(1, 11, 21, 31, 41), + ]); + + assert.deepEqual(result, diagnostics(2, 12, 22, 32, 42)); +}); + +test('renders each changed browser diagnostics metric as a separate row', () => { + const base = browserReport('base', Array.from({ length: 5 }, () => diagnostics(0, 0, 0, 0, 0))); + const head = browserReport('head', Array.from({ length: 5 }, () => diagnostics(1, 1, 1, 1, 1))); + const rows = renderBrowserDiagnosticsRows(base, head).join('\n'); + + for (const label of ['Page errors', 'Console log', 'Console warnings', 'Console errors', 'Console info']) { + assert.match(rows, new RegExp(`\\*\\*${label}\\*\\*`)); + } +}); + +test('renders browser diagnostics deltas from paired rounds instead of summary medians', () => { + const base = browserReport('base', [0, 9, 10, 100, 101].map(value => diagnostics(value, 0, 0, 0, 0))); + const head = browserReport('head', [10, 100, 101, 0, 9].map(value => diagnostics(value, 0, 0, 0, 0))); + const rows = renderBrowserDiagnosticsRows(base, head); + + assert.equal(rows.length, 1); + assert.match(rows[0], /\*\*Page errors\*\*/); + assert.match(rows[0], /\\text\{\+10\}/); +}); + +test('omits browser diagnostics rows whose paired median does not change', () => { + const values = Array.from({ length: 5 }, () => diagnostics(2, 3, 4, 5, 6)); + const base = browserReport('base', values); + const head = browserReport('head', values); + + assert.deepEqual(renderBrowserDiagnosticsRows(base, head), []); +}); diff --git a/.github/scripts/measure-frontend-browser-comparison.mts b/.github/scripts/measure-frontend-browser-comparison.mts index 0825133b1d..3e357c3396 100644 --- a/.github/scripts/measure-frontend-browser-comparison.mts +++ b/.github/scripts/measure-frontend-browser-comparison.mts @@ -7,7 +7,7 @@ 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'; @@ -173,6 +173,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 +211,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, diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 63f8684c33..c1a3aeda6e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -152,3 +152,4 @@ jobs: 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 + - run: node --test .github/scripts/frontend-browser-report.test.mts From 93bbc89c7adfba9d580c433dd07b19ab0235838b Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:58:24 +0900 Subject: [PATCH 072/168] chore(dev): tweak frontend browser diagnostics --- .github/scripts/chrome.mts | 58 ++++------- .github/scripts/frontend-browser-report.mts | 18 ++-- .../scripts/frontend-browser-report.test.mts | 98 ------------------- .github/workflows/lint.yml | 1 - 4 files changed, 24 insertions(+), 151 deletions(-) delete mode 100644 .github/scripts/frontend-browser-report.test.mts diff --git a/.github/scripts/chrome.mts b/.github/scripts/chrome.mts index 2be10bd261..b40c82a263 100644 --- a/.github/scripts/chrome.mts +++ b/.github/scripts/chrome.mts @@ -90,53 +90,19 @@ export type TabMemory = { totalBytes: number; }; -export type BrowserConsoleMetrics = { - log: number; - warn: number; - error: number; - info: number; -}; - export type BrowserDiagnostics = { pageErrorCount: number; - console: BrowserConsoleMetrics; + console: Record<'log' | 'warning' | 'error' | 'info', number>; }; -export function createBrowserDiagnostics(): BrowserDiagnostics { - return { - pageErrorCount: 0, - console: { - log: 0, - warn: 0, - error: 0, - info: 0, - }, - }; -} - -export function recordPageError(diagnostics: BrowserDiagnostics) { - diagnostics.pageErrorCount += 1; -} - -function isBrowserConsoleMetricKey(value: string): value is keyof BrowserConsoleMetrics { - return value === 'log' || value === 'warn' || value === 'error' || value === 'info'; -} - -export function recordConsoleMessageType(diagnostics: BrowserDiagnostics, messageType: string) { - const metricKey = messageType === 'warning' ? 'warn' : messageType; - if (!isBrowserConsoleMetricKey(metricKey)) return; - diagnostics.console[metricKey] += 1; -} - export function summarizeBrowserDiagnostics(samples: BrowserDiagnostics[]): BrowserDiagnostics { - if (samples.length === 0) throw new Error('No browser diagnostic samples'); const median = (select: (sample: BrowserDiagnostics) => number) => util.median(samples.map(select)); return { pageErrorCount: median(sample => sample.pageErrorCount), console: { log: median(sample => sample.console.log), - warn: median(sample => sample.console.warn), + warning: median(sample => sample.console.warning), error: median(sample => sample.console.error), info: median(sample => sample.console.info), }, @@ -204,7 +170,10 @@ type PlaywrightBrowserOptions = { export class HeadlessChromeController { public networkRequests: NetworkRequest[] = []; public webSocketConnections: WebSocketConnection[] = []; - private readonly diagnostics = createBrowserDiagnostics(); + private readonly diagnostics = { + pageErrorCount: 0, + console: {} as Record, + }; private readonly browser: Browser; private readonly context: BrowserContext; public readonly page: Page; @@ -225,10 +194,14 @@ export class HeadlessChromeController { this.page.setDefaultTimeout(options.scenarioTimeoutMs); this.page.setDefaultNavigationTimeout(options.scenarioTimeoutMs); this.page.on('pageerror', () => { - recordPageError(this.diagnostics); + this.diagnostics.pageErrorCount++; }); this.page.on('console', message => { - recordConsoleMessageType(this.diagnostics, message.type()); + if (this.diagnostics.console[message.type()] == null) { + this.diagnostics.console[message.type()] = 1; + } else { + this.diagnostics.console[message.type()]++; + } }); } @@ -441,7 +414,12 @@ export class HeadlessChromeController { public collectDiagnostics(): BrowserDiagnostics { return { pageErrorCount: this.diagnostics.pageErrorCount, - console: { ...this.diagnostics.console }, + 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, + }, }; } diff --git a/.github/scripts/frontend-browser-report.mts b/.github/scripts/frontend-browser-report.mts index 829041f1d0..cfb37ccf3b 100644 --- a/.github/scripts/frontend-browser-report.mts +++ b/.github/scripts/frontend-browser-report.mts @@ -172,19 +172,8 @@ function getMetric(report: BrowserMeasurement, key: string) { return report.performance.cdpMetrics[key]; } -export function renderBrowserDiagnosticsRows(base: BrowserMetricsReport, head: BrowserMetricsReport, all = false) { - return [ - 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.warn, sample => sample.diagnostics.console.warn, 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), - ].filter(row => row != null); -} - function renderSummaryTable(base: BrowserMetricsReport, head: BrowserMetricsReport, all = false) { const rows = [ - ...renderBrowserDiagnosticsRows(base, head, all), //metricRow('Scenario duration', base, head, summary => summary.durationMs, sample => sample.durationMs, formatMs), metricRow('Requests', base, head, summary => summary.network.requestCount, sample => sample.network.requestCount, util.formatNumber, 1, !all), //metricRow('Failed requests', base, head, summary => summary.network.failedRequestCount, sample => sample.network.failedRequestCount, util.formatNumber), @@ -215,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.warn, sample => sample.diagnostics.console.warn, 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 [ diff --git a/.github/scripts/frontend-browser-report.test.mts b/.github/scripts/frontend-browser-report.test.mts deleted file mode 100644 index 4d4f130504..0000000000 --- a/.github/scripts/frontend-browser-report.test.mts +++ /dev/null @@ -1,98 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { - createBrowserDiagnostics, - recordConsoleMessageType, - recordPageError, - summarizeBrowserDiagnostics, - type BrowserDiagnostics, -} from './chrome.mts'; -import { - renderBrowserDiagnosticsRows, - type BrowserMeasurement, - type BrowserMeasurementSample, - type BrowserMetricsReport, -} from './frontend-browser-report.mts'; - -function diagnostics(pageErrorCount: number, log: number, warn: number, error: number, info: number): BrowserDiagnostics { - return { - pageErrorCount, - console: { log, warn, error, info }, - }; -} - -function browserReport(label: 'base' | 'head', values: BrowserDiagnostics[]): BrowserMetricsReport { - const samples = values.map((value, index) => ({ - round: index + 1, - diagnostics: value, - } as BrowserMeasurementSample)); - - return { - label, - timestamp: '2026-07-16T00:00:00.000Z', - url: 'http://127.0.0.1:61812', - scenario: 'test', - sampleCount: samples.length, - aggregation: 'median', - summary: { - diagnostics: summarizeBrowserDiagnostics(values), - } as BrowserMeasurement, - samples, - }; -} - -test('records page errors and only the requested console message types', () => { - const result = createBrowserDiagnostics(); - recordPageError(result); - recordPageError(result); - for (const type of ['log', 'warning', 'error', 'info', 'debug', 'trace']) { - recordConsoleMessageType(result, type); - } - - assert.deepEqual(result, diagnostics(2, 1, 1, 1, 1)); -}); - -test('summarizes browser diagnostics with a median for every counter', () => { - const result = summarizeBrowserDiagnostics([ - diagnostics(0, 10, 20, 30, 40), - diagnostics(4, 14, 24, 34, 44), - diagnostics(2, 12, 22, 32, 42), - diagnostics(3, 13, 23, 33, 43), - diagnostics(1, 11, 21, 31, 41), - ]); - - assert.deepEqual(result, diagnostics(2, 12, 22, 32, 42)); -}); - -test('renders each changed browser diagnostics metric as a separate row', () => { - const base = browserReport('base', Array.from({ length: 5 }, () => diagnostics(0, 0, 0, 0, 0))); - const head = browserReport('head', Array.from({ length: 5 }, () => diagnostics(1, 1, 1, 1, 1))); - const rows = renderBrowserDiagnosticsRows(base, head).join('\n'); - - for (const label of ['Page errors', 'Console log', 'Console warnings', 'Console errors', 'Console info']) { - assert.match(rows, new RegExp(`\\*\\*${label}\\*\\*`)); - } -}); - -test('renders browser diagnostics deltas from paired rounds instead of summary medians', () => { - const base = browserReport('base', [0, 9, 10, 100, 101].map(value => diagnostics(value, 0, 0, 0, 0))); - const head = browserReport('head', [10, 100, 101, 0, 9].map(value => diagnostics(value, 0, 0, 0, 0))); - const rows = renderBrowserDiagnosticsRows(base, head); - - assert.equal(rows.length, 1); - assert.match(rows[0], /\*\*Page errors\*\*/); - assert.match(rows[0], /\\text\{\+10\}/); -}); - -test('omits browser diagnostics rows whose paired median does not change', () => { - const values = Array.from({ length: 5 }, () => diagnostics(2, 3, 4, 5, 6)); - const base = browserReport('base', values); - const head = browserReport('head', values); - - assert.deepEqual(renderBrowserDiagnosticsRows(base, head), []); -}); diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index c1a3aeda6e..63f8684c33 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -152,4 +152,3 @@ jobs: 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 - - run: node --test .github/scripts/frontend-browser-report.test.mts From 12be1fe5b5002d195e4817c888ee052cbc5a1ea7 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Thu, 16 Jul 2026 20:23:01 +0900 Subject: [PATCH 073/168] [skip ci] chore(dev): tweak backend-memory-report --- .github/scripts/backend-memory-report.mts | 277 ++-------------------- 1 file changed, 16 insertions(+), 261 deletions(-) diff --git a/.github/scripts/backend-memory-report.mts b/.github/scripts/backend-memory-report.mts index 4a701655ec..85630f87b2 100644 --- a/.github/scripts/backend-memory-report.mts +++ b/.github/scripts/backend-memory-report.mts @@ -8,34 +8,7 @@ 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 [baseFile, headFile, outputFile] = process.argv.slice(2); const memoryReportPhases = [ { @@ -44,7 +17,7 @@ const memoryReportPhases = [ }, ] as const; -const metrics = [ +const memoryMetrics = [ 'HeapUsed', 'Pss', 'USS', @@ -56,26 +29,26 @@ function formatMemoryMb(valueKiB: number | null | undefined) { return `${util.formatNumber(valueKiB / 1000)} MB`; } -function formatMetricName(metric: typeof metrics[number]) { +function formatMemoryMetricName(metric: typeof memoryMetrics[number]) { return metric === 'Pss' ? 'PSS' : metric; } -function getMemoryValueFromSample(sample: MemoryReport['samples'][number], phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) { +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 metrics[number]) { +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 metrics[number]) { +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 metrics[number]) { +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; @@ -93,7 +66,7 @@ function renderMainTableForPhase(base: MemoryReport, head: MemoryReport, phase: return util.formatColoredDelta(deltaKiB, v => formatMemoryMb(v), 100); // 0.1 MB threshold } - for (const metric of metrics) { + for (const metric of memoryMetrics) { const baseValue = getMemoryValue(base, phase, metric); const headValue = getMemoryValue(head, phase, metric); @@ -103,29 +76,12 @@ function renderMainTableForPhase(base: MemoryReport, head: MemoryReport, phase: const percent = summary.median * 100 / baseValue; const deltaMedian = `${formatDeltaMemory(summary.median)}
${util.formatDeltaPercent(percent, 0.1).replaceAll('\\%', '\\\\%')}`; - lines.push(`| **${formatMetricName(metric)}** | ${formatMemoryMb(baseValue)}
± ${formatMemoryMb(baseSpread)} | ${formatMemoryMb(headValue)}
± ${formatMemoryMb(headSpread)} | ${deltaMedian} | ${formatMemoryMb(summary.mad)} | ${formatDeltaMemory(summary.min)} | ${formatDeltaMemory(summary.max)} |`); + lines.push(`| **${formatMemoryMetricName(metric)}** | ${formatMemoryMb(baseValue)}
± ${formatMemoryMb(baseSpread)} | ${formatMemoryMb(headValue)}
± ${formatMemoryMb(headSpread)} | ${deltaMedian} | ${formatMemoryMb(summary.mad)} | ${formatDeltaMemory(summary.min)} | ${formatDeltaMemory(summary.max)} |`); } return lines.join('\n'); } -/* -function 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!, @@ -165,204 +121,11 @@ function renderHeapSnapshotSection(base: MemoryReport, head: MemoryReport) { return lines.join('\n'); } -function getJsFootprintValue(report: RuntimeLoadedJsFootprintReport, phase: 'afterRequest', key: keyof RuntimeLoadedJsFootprintReport['phases'][typeof phase]['totals']) { - const value = report.phases[phase].totals[key]; - return Number.isFinite(value) ? value : null; -} - -function renderJsFootprintMetricTable(base: RuntimeLoadedJsFootprintReport, head: RuntimeLoadedJsFootprintReport) { - const metricRows = [ - ['Loaded JS modules', 'loadedJsModules', util.formatNumber], - ['Loaded JS source', 'loadedJsSourceBytes', util.formatBytes], - //['Loaded JS gzip estimate', 'loadedJsGzipBytes', util.formatBytes], - //['AST nodes', 'astNodeCount', util.formatNumber], - //['Functions', 'functionCount', util.formatNumber], - //['Classes', 'classCount', util.formatNumber], - //['String literals', 'stringLiteralBytes', util.formatBytes], - ['External packages loaded', 'externalPackageCount', util.formatNumber], - ['Native addon packages', 'nativeAddonPackageCount', util.formatNumber], - ] as const; - - const lines = [ - '| Metric | Base | Head | Δ | Δ (%) |', - '| --- | ---: | ---: | ---: | ---: |', - ]; - - for (const [title, key, formatter] of metricRows) { - const baseValue = getJsFootprintValue(base, 'afterRequest', key); - const headValue = getJsFootprintValue(head, 'afterRequest', key); - if (baseValue == null || headValue == null) continue; - - lines.push(`| **${title}** | ${formatter(baseValue)} | ${formatter(headValue)} | ${util.formatColoredDelta(headValue - baseValue, v => formatter(v))} | ${util.calcAndFormatDeltaPercent(baseValue, headValue).replaceAll('\\%', '\\\\%')} |`); - } - - return lines.join('\n'); -} - -/* -function renderJsFootprintPhaseTable(base, head) { - const lines = [ - '| Phase | Base modules | Head modules | Δ modules | Base source | Head source | Δ source |', - '| --- | ---: | ---: | ---: | ---: | ---: | ---: |', - ]; - - for (const [phase, title] of [['startup', 'Startup'], ['afterRequest', 'After warmup requests']]) { - const baseModules = getJsFootprintValue(base, phase, 'loadedJsModules'); - const headModules = getJsFootprintValue(head, phase, 'loadedJsModules'); - const baseSource = getJsFootprintValue(base, phase, 'loadedJsSourceBytes'); - const headSource = getJsFootprintValue(head, phase, 'loadedJsSourceBytes'); - if (baseModules == null || headModules == null || baseSource == null || headSource == null) continue; - - lines.push(`| ${title} | ${util.formatNumber(baseModules)} | ${util.formatNumber(headModules)} | ${formatPlainDelta(baseModules, headModules)} | ${util.formatBytes(baseSource)} | ${util.formatBytes(headSource)} | ${formatPlainDelta(baseSource, headSource, util.formatBytes)} |`); - } - - return lines.join('\n'); -} -*/ - -function packageMap(report: RuntimeLoadedJsFootprintReport) { - const map = new Map(); - for (const packageSummary of report.phases.afterRequest.packages) { - if (packageSummary?.category !== 'external' || typeof packageSummary.name !== 'string') continue; - map.set(packageSummary.name, packageSummary); - } - return map; -} - -function packageDisplayName(packageSummary: { name: string; version?: string | null }) { - if (packageSummary.version == null) return packageSummary.name; - return `${packageSummary.name} ${packageSummary.version}`; -} - -function renderNewExternalPackages(base: RuntimeLoadedJsFootprintReport, head: RuntimeLoadedJsFootprintReport) { - const basePackages = packageMap(base); - const headPackages = packageMap(head); - const newPackages = [...headPackages.values()] - .filter(packageSummary => !basePackages.has(packageSummary.name)) - .toSorted((a, b) => b.sourceBytes - a.sourceBytes) - .slice(0, 10); - - if (newPackages.length === 0) return null; - - const lines = [ - '#### Newly Loaded External Packages', - '', - '| Package | Loaded JS | Modules | Notes |', - '| --- | ---: | ---: | --- |', - ]; - - for (const packageSummary of newPackages) { - lines.push(`| ${packageDisplayName(packageSummary)} | ${util.formatBytes(packageSummary.sourceBytes)} | ${util.formatNumber(packageSummary.modules)} | ${packageSummary.nativeAddon ? 'native addon' : ''} |`); - } - - return lines.join('\n'); -} - -function renderLargestPackageIncreases(base: RuntimeLoadedJsFootprintReport, head: RuntimeLoadedJsFootprintReport) { - const basePackages = packageMap(base); - const headPackages = packageMap(head); - const increases = [...headPackages.values()] - .map(headPackage => { - const basePackage = basePackages.get(headPackage.name); - const baseSourceBytes = basePackage?.sourceBytes ?? 0; - const baseModules = basePackage?.modules ?? 0; - return { - ...headPackage, - baseSourceBytes, - baseModules, - sourceDiff: headPackage.sourceBytes - baseSourceBytes, - moduleDiff: headPackage.modules - baseModules, - }; - }) - .filter(packageSummary => packageSummary.sourceDiff > 0) - .toSorted((a, b) => b.sourceDiff - a.sourceDiff) - .slice(0, 10); - - if (increases.length === 0) return null; - - const lines = [ - '#### Largest Package Increases', - '', - '| Package | Base | Head | Δ | Modules Δ |', - '| --- | ---: | ---: | ---: | ---: |', - ]; - - for (const packageSummary of increases) { - lines.push(`| ${packageDisplayName(packageSummary)} | ${util.formatBytes(packageSummary.baseSourceBytes)} | ${util.formatBytes(packageSummary.sourceBytes)} | ${util.formatColoredDelta(packageSummary.sourceBytes - packageSummary.baseSourceBytes, v => util.formatBytes(v))} | ${util.formatColoredDelta(packageSummary.modules - packageSummary.baseModules, v => util.formatNumber(v))} |`); - } - - return lines.join('\n'); -} - -function renderNewLoadedModules(base: RuntimeLoadedJsFootprintReport, head: RuntimeLoadedJsFootprintReport) { - function moduleMap(report: RuntimeLoadedJsFootprintReport) { - const map = new Map(); - for (const moduleSummary of report.phases.afterRequest.modules) { - if (typeof moduleSummary.path !== 'string') continue; - map.set(moduleSummary.path, moduleSummary); - } - return map; - } - - const baseModules = moduleMap(base); - const headModules = moduleMap(head); - const newModules = [...headModules.values()] - .filter(moduleSummary => !baseModules.has(moduleSummary.path)) - .toSorted((a, b) => b.sourceBytes - a.sourceBytes) - .slice(0, 10); - - if (newModules.length === 0) return null; - - const lines = [ - '#### Largest Newly Loaded Modules', - '', - '| Module | Package | Loaded JS |', - '| --- | --- | ---: |', - ]; - - for (const moduleSummary of newModules) { - lines.push(`| \`${moduleSummary.path}\` | ${moduleSummary.package} | ${util.formatBytes(moduleSummary.sourceBytes)} |`); - } - - return lines.join('\n'); -} - -function renderJsFootprintSection(base: RuntimeLoadedJsFootprintReport, head: RuntimeLoadedJsFootprintReport) { - const lines = [ - '### Runtime Loaded JS Footprint', - '', - '
Click to show', - '', - renderJsFootprintMetricTable(base, head), - '', - //'#### Load Phase Breakdown', - //'', - //renderJsFootprintPhaseTable(base, head), - //'', - ]; - - for (const block of [ - renderNewExternalPackages(base, head), - renderLargestPackageIncreases(base, head), - renderNewLoadedModules(base, head), - ]) { - if (block == null) continue; - lines.push(block); - lines.push(''); - } - - lines.push('
'); - lines.push(''); - - return lines.join('\n'); -} - const base = JSON.parse(await readFile(baseFile, 'utf8')) as MemoryReport; const head = JSON.parse(await readFile(headFile, 'utf8')) as MemoryReport; -const baseJsFootprint = JSON.parse(await readFile(baseJsFootprintFile, 'utf8')) as RuntimeLoadedJsFootprintReport; -const headJsFootprint = JSON.parse(await readFile(headJsFootprintFile, 'utf8')) as RuntimeLoadedJsFootprintReport; + const lines = [ - '## ⚙️ Backend Memory Usage Report', + '## ⚙️ Backend Diagnostics Report', '', ]; @@ -373,7 +136,7 @@ const lines = [ //} for (const phase of memoryReportPhases) { - lines.push(`### ${phase.title}`); + lines.push(`### Memory: ${phase.title}`); lines.push(renderMainTableForPhase(base, head, phase.key)); lines.push(''); } @@ -386,22 +149,16 @@ if (heapSnapshotSection != null) { 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(`You can download the representative 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]) { +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 metrics[number]) { +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); @@ -419,10 +176,8 @@ function isBeyondSampleNoise(base: MemoryReport, head: MemoryReport, phase: type 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(`⚠️ **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(''); } -//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`); From 1a04d4c2d91b09324938ea9956d6d26353179e7c Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Thu, 16 Jul 2026 20:36:55 +0900 Subject: [PATCH 074/168] refacotr(gh): rename some files --- ...ry-report.mts => backend-diagnostics.render-md.mts} | 0 .github/scripts/frontend-browser-report.mts | 2 +- ...kend-memory.yml => backend-diagnostics.inspect.yml} | 10 +++++----- ...ckend-memory.yml => backend-diagnostics.report.yml} | 6 +++--- 4 files changed, 9 insertions(+), 9 deletions(-) rename .github/scripts/{backend-memory-report.mts => backend-diagnostics.render-md.mts} (100%) rename .github/workflows/{get-backend-memory.yml => backend-diagnostics.inspect.yml} (93%) rename .github/workflows/{report-backend-memory.yml => backend-diagnostics.report.yml} (90%) diff --git a/.github/scripts/backend-memory-report.mts b/.github/scripts/backend-diagnostics.render-md.mts similarity index 100% rename from .github/scripts/backend-memory-report.mts rename to .github/scripts/backend-diagnostics.render-md.mts diff --git a/.github/scripts/frontend-browser-report.mts b/.github/scripts/frontend-browser-report.mts index cfb37ccf3b..1ecfbe14db 100644 --- a/.github/scripts/frontend-browser-report.mts +++ b/.github/scripts/frontend-browser-report.mts @@ -206,7 +206,7 @@ function renderSummaryTable(base: BrowserMetricsReport, head: BrowserMetricsRepo metricRow('WebSocket received', base, head, summary => summary.network.webSocketReceivedBytes, sample => sample.network.webSocketReceivedBytes, 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.warn, sample => sample.diagnostics.console.warn, 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), diff --git a/.github/workflows/get-backend-memory.yml b/.github/workflows/backend-diagnostics.inspect.yml similarity index 93% rename from .github/workflows/get-backend-memory.yml rename to .github/workflows/backend-diagnostics.inspect.yml index 1f933444bd..65412bafa8 100644 --- a/.github/workflows/get-backend-memory.yml +++ b/.github/workflows/backend-diagnostics.inspect.yml @@ -1,5 +1,5 @@ -# this name is used in report-backend-memory.yml so be careful when change name -name: Get backend memory usage +# this name is used in backend-diagnostics.report.yml so be careful when change name +name: Backend diagnostics (inspect) on: pull_request: @@ -10,14 +10,14 @@ on: - packages/backend/** - packages/misskey-js/** - .github/scripts/utility.mts - - .github/scripts/backend-memory-report.mts + - .github/scripts/backend-diagnostics.render-md.mts - .github/scripts/measure-backend-memory-comparison.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.report.yml jobs: get-memory-usage: diff --git a/.github/workflows/report-backend-memory.yml b/.github/workflows/backend-diagnostics.report.yml similarity index 90% rename from .github/workflows/report-backend-memory.yml rename to .github/workflows/backend-diagnostics.report.yml index a6c0b4afdf..f9fded83d7 100644 --- a/.github/workflows/report-backend-memory.yml +++ b/.github/workflows/backend-diagnostics.report.yml @@ -1,10 +1,10 @@ -name: Report backend memory +name: Backend diagnostics (report) on: workflow_run: types: [completed] workflows: - - Get backend memory usage # get-backend-memory.yml + - Backend diagnostics (inspect) # backend-diagnostics.inspect.yml jobs: compare-memory: @@ -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 ./artifacts/js-footprint-base.json ./artifacts/js-footprint-head.json - uses: thollander/actions-comment-pull-request@v3 with: pr-number: ${{ steps.load-pr-num.outputs.pr-number }} From 77c2f54fa54ab0e4a76fc307b1fc00552664c262 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Thu, 16 Jul 2026 20:42:58 +0900 Subject: [PATCH 075/168] refacotr(gh): refactor --- ...on.mts => backend-diagnostics.inspect.mts} | 0 .../scripts/backend-diagnostics.render-md.mts | 2 +- .../scripts/backend-js-footprint-loader.mjs | 46 -- .../scripts/backend-js-footprint-require.cjs | 46 -- .github/scripts/backend-js-footprint.mjs | 473 ------------------ .../workflows/backend-diagnostics.inspect.yml | 13 +- .../workflows/backend-diagnostics.report.yml | 2 +- 7 files changed, 4 insertions(+), 578 deletions(-) rename .github/scripts/{measure-backend-memory-comparison.mts => backend-diagnostics.inspect.mts} (100%) delete mode 100644 .github/scripts/backend-js-footprint-loader.mjs delete mode 100644 .github/scripts/backend-js-footprint-require.cjs delete mode 100644 .github/scripts/backend-js-footprint.mjs diff --git a/.github/scripts/measure-backend-memory-comparison.mts b/.github/scripts/backend-diagnostics.inspect.mts similarity index 100% rename from .github/scripts/measure-backend-memory-comparison.mts rename to .github/scripts/backend-diagnostics.inspect.mts diff --git a/.github/scripts/backend-diagnostics.render-md.mts b/.github/scripts/backend-diagnostics.render-md.mts index 85630f87b2..e73c22a722 100644 --- a/.github/scripts/backend-diagnostics.render-md.mts +++ b/.github/scripts/backend-diagnostics.render-md.mts @@ -6,7 +6,7 @@ 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'; +import type { MemoryReport } from './backend-diagnostics.inspect.mts'; const [baseFile, headFile, outputFile] = process.argv.slice(2); diff --git a/.github/scripts/backend-js-footprint-loader.mjs b/.github/scripts/backend-js-footprint-loader.mjs deleted file mode 100644 index cd2c0af2e6..0000000000 --- a/.github/scripts/backend-js-footprint-loader.mjs +++ /dev/null @@ -1,46 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { appendFileSync, statSync } from 'node:fs'; -import { extname } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const traceFile = process.env.MK_BACKEND_JS_FOOTPRINT_TRACE; -const jsExtensions = new Set(['.js', '.mjs', '.cjs']); - -function recordLoadedFile(kind, url, format) { - if (traceFile == null || !url.startsWith('file:')) return; - - let filePath; - try { - filePath = fileURLToPath(url); - } catch { - return; - } - - const extension = extname(filePath); - if (!jsExtensions.has(extension)) return; - - let size = null; - try { - size = statSync(filePath).size; - } catch { - return; - } - - appendFileSync(traceFile, `${JSON.stringify({ - kind, - format, - path: filePath, - size, - timestamp: Date.now(), - })}\n`); -} - -export async function load(url, context, nextLoad) { - const result = await nextLoad(url, context); - recordLoadedFile('esm', url, result.format ?? context.format ?? null); - return result; -} diff --git a/.github/scripts/backend-js-footprint-require.cjs b/.github/scripts/backend-js-footprint-require.cjs deleted file mode 100644 index 1adab8bc05..0000000000 --- a/.github/scripts/backend-js-footprint-require.cjs +++ /dev/null @@ -1,46 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -'use strict'; - -const { appendFileSync, statSync } = require('node:fs'); -const Module = require('node:module'); -const { extname } = require('node:path'); - -const traceFile = process.env.MK_BACKEND_JS_FOOTPRINT_TRACE; -const jsExtensions = new Set(['.js', '.mjs', '.cjs']); - -function recordLoadedFile(kind, filePath, request) { - if (traceFile == null || typeof filePath !== 'string') return; - - const extension = extname(filePath); - if (!jsExtensions.has(extension) && extension !== '.node') return; - - let size = null; - try { - size = statSync(filePath).size; - } catch { - return; - } - - appendFileSync(traceFile, `${JSON.stringify({ - kind, - format: extension === '.node' ? 'native' : 'commonjs', - path: filePath, - request, - size, - timestamp: Date.now(), - })}\n`); -} - -const originalLoad = Module._load; -const originalResolveFilename = Module._resolveFilename; - -Module._load = function load(request, parent, isMain) { - const resolved = originalResolveFilename.call(this, request, parent, isMain); - const result = originalLoad.apply(this, arguments); - recordLoadedFile('cjs', resolved, request); - return result; -}; diff --git a/.github/scripts/backend-js-footprint.mjs b/.github/scripts/backend-js-footprint.mjs deleted file mode 100644 index 9c98357ad6..0000000000 --- a/.github/scripts/backend-js-footprint.mjs +++ /dev/null @@ -1,473 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { fork, spawn } from 'node:child_process'; -import { createRequire } from 'node:module'; -import { cpus, tmpdir } from 'node:os'; -import { dirname, extname, join, relative, resolve, sep } from 'node:path'; -import { setTimeout } from 'node:timers/promises'; -import { fileURLToPath, pathToFileURL } from 'node:url'; -import { gzipSync } from 'node:zlib'; -import * as fs from 'node:fs/promises'; -import * as fsSync from 'node:fs'; -import * as http from 'node:http'; -import * as util from './utility.mts'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -const [repoDirArg, outputFileArg] = process.argv.slice(2); - -const STARTUP_TIMEOUT = util.readIntegerEnv('MK_JS_FOOTPRINT_STARTUP_TIMEOUT_MS', 120000, 1); -const SETTLE_TIME = util.readIntegerEnv('MK_JS_FOOTPRINT_SETTLE_TIME_MS', 10000, 0); -const REQUEST_COUNT = util.readIntegerEnv('MK_JS_FOOTPRINT_REQUEST_COUNT', 10, 0); - -const repoDir = resolve(repoDirArg); -const outputFile = resolve(outputFileArg); -const backendDir = join(repoDir, 'packages/backend'); -const backendBuiltDir = join(backendDir, 'built'); -const traceFile = join(tmpdir(), `misskey-backend-js-footprint-${process.pid}-${Date.now()}.jsonl`); -const require = createRequire(join(repoDir, 'package.json')); -const ts = require('typescript'); -const jsExtensions = new Set(['.js', '.mjs', '.cjs']); -const fileMetricCache = new Map(); -const packageInfoCache = new Map(); -const nativePackageNames = new Set(); - -function isInside(parent, child) { - const rel = relative(parent, child); - return rel === '' || (!rel.startsWith('..') && !rel.includes(`..${sep}`)); -} - -function bytesToKiB(value) { - return Math.round(value / 1024); -} - -async function resetState() { - const backendRequire = createRequire(join(backendDir, 'package.json')); - const pg = backendRequire('pg'); - const Redis = backendRequire('ioredis'); - - const postgres = new pg.Client({ - host: '127.0.0.1', - port: 54312, - database: 'postgres', - user: 'postgres', - }); - - await postgres.connect(); - try { - await postgres.query('DROP DATABASE IF EXISTS "test-misskey" WITH (FORCE)'); - await postgres.query('CREATE DATABASE "test-misskey"'); - } finally { - await postgres.end(); - } - - const redis = new Redis({ host: '127.0.0.1', port: 56312 }); - try { - await redis.flushall(); - } finally { - redis.disconnect(); - } -} - -function createRequest() { - return new Promise((resolvePromise, reject) => { - const req = http.request({ - host: 'localhost', - port: 61812, - path: '/api/meta', - method: 'POST', - }, res => { - res.on('data', () => { }); - res.on('end', () => resolvePromise()); - }); - req.on('error', reject); - req.end(); - }); -} - -async function waitForServerReady(serverProcess) { - let serverReady = false; - serverProcess.on('message', message => { - if (message === 'ok') serverReady = true; - }); - - const startupStartTime = Date.now(); - while (!serverReady) { - if (Date.now() - startupStartTime > STARTUP_TIMEOUT) { - serverProcess.kill('SIGTERM'); - throw new Error('Server startup timeout'); - } - await setTimeout(100); - } -} - -async function stopServer(serverProcess) { - serverProcess.kill('SIGTERM'); - - let exited = false; - await new Promise(resolvePromise => { - serverProcess.on('exit', () => { - exited = true; - resolvePromise(undefined); - }); - - setTimeout(10000).then(() => { - if (!exited) serverProcess.kill('SIGKILL'); - resolvePromise(undefined); - }); - }); -} - -function getPackageNameFromPath(filePath) { - const normalized = util.normalizePath(filePath); - const marker = '/node_modules/'; - const index = normalized.lastIndexOf(marker); - if (index === -1) return null; - - const rest = normalized.slice(index + marker.length).split('/'); - if (rest[0] === '.pnpm') { - const nestedNodeModulesIndex = rest.indexOf('node_modules'); - if (nestedNodeModulesIndex === -1) return null; - const packageParts = rest.slice(nestedNodeModulesIndex + 1); - if (packageParts.length === 0) return null; - return packageParts[0].startsWith('@') ? packageParts.slice(0, 2).join('/') : packageParts[0]; - } - - return rest[0]?.startsWith('@') ? rest.slice(0, 2).join('/') : rest[0] ?? null; -} - -function findPackageDir(filePath, packageName) { - const normalizedPackageName = packageName.split('/').join(sep); - let current = dirname(filePath); - - while (current !== dirname(current)) { - if (current.endsWith(`${sep}${normalizedPackageName}`) && fsSync.existsSync(join(current, 'package.json'))) { - return current; - } - - const parent = dirname(current); - if (parent === current) break; - current = parent; - } - - return null; -} - -function readPackageInfo(filePath) { - const externalPackageName = getPackageNameFromPath(filePath); - if (externalPackageName != null) { - const packageDir = findPackageDir(filePath, externalPackageName); - const cacheKey = packageDir ?? externalPackageName; - if (packageInfoCache.has(cacheKey)) return packageInfoCache.get(cacheKey); - - let version = null; - if (packageDir != null) { - try { - const packageJson = JSON.parse(fsSync.readFileSync(join(packageDir, 'package.json'), 'utf8')); - version = typeof packageJson.version === 'string' ? packageJson.version : null; - } catch { } - } - - const info = { - category: 'external', - name: externalPackageName, - version, - dir: packageDir, - }; - packageInfoCache.set(cacheKey, info); - return info; - } - - if (isInside(backendBuiltDir, filePath)) { - return { - category: 'internal', - name: 'backend', - version: null, - dir: backendDir, - }; - } - - return { - category: 'internal', - name: 'workspace', - version: null, - dir: repoDir, - }; -} - -function analyzeSource(filePath, source) { - const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS); - const metrics = { - astNodeCount: 0, - functionCount: 0, - classCount: 0, - stringLiteralBytes: 0, - }; - - function visit(node) { - metrics.astNodeCount += 1; - - if ( - ts.isFunctionDeclaration(node) || - ts.isFunctionExpression(node) || - ts.isArrowFunction(node) || - ts.isMethodDeclaration(node) || - ts.isConstructorDeclaration(node) || - ts.isGetAccessorDeclaration(node) || - ts.isSetAccessorDeclaration(node) - ) { - metrics.functionCount += 1; - } else if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) { - metrics.classCount += 1; - } else if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) { - metrics.stringLiteralBytes += Buffer.byteLength(node.text); - } - - ts.forEachChild(node, visit); - } - - visit(sourceFile); - return metrics; -} - -function readFileMetrics(filePath) { - if (fileMetricCache.has(filePath)) return fileMetricCache.get(filePath); - - const source = fsSync.readFileSync(filePath); - const sourceText = source.toString('utf8'); - const astMetrics = analyzeSource(filePath, sourceText); - const packageInfo = readPackageInfo(filePath); - const metric = { - path: filePath, - displayPath: util.normalizePath(relative(repoDir, filePath)), - sourceBytes: source.byteLength, - gzipBytes: gzipSync(source).byteLength, - ...astMetrics, - package: packageInfo, - }; - - fileMetricCache.set(filePath, metric); - return metric; -} - -async function readTraceRecords() { - let content = ''; - try { - content = await fs.readFile(traceFile, 'utf8'); - } catch (err) { - if (err.code === 'ENOENT') return []; - throw err; - } - - const records = []; - for (const line of content.split('\n')) { - if (line.trim() === '') continue; - try { - records.push(JSON.parse(line)); - } catch { } - } - return records; -} - -function emptyTotals() { - return { - loadedJsModules: 0, - loadedJsSourceBytes: 0, - loadedJsGzipBytes: 0, - astNodeCount: 0, - functionCount: 0, - classCount: 0, - stringLiteralBytes: 0, - externalPackageCount: 0, - nativeAddonPackageCount: 0, - }; -} - -function addFileMetrics(target, metric) { - target.loadedJsModules += 1; - target.loadedJsSourceBytes += metric.sourceBytes; - target.loadedJsGzipBytes += metric.gzipBytes; - target.astNodeCount += metric.astNodeCount; - target.functionCount += metric.functionCount; - target.classCount += metric.classCount; - target.stringLiteralBytes += metric.stringLiteralBytes; -} - -function summarizeRecords(records, phase) { - const jsPaths = new Set(); - const nativePaths = new Set(); - - for (const record of records) { - if (typeof record.path !== 'string') continue; - - const extension = extname(record.path); - if (jsExtensions.has(extension)) { - jsPaths.add(resolve(record.path)); - } else if (extension === '.node') { - nativePaths.add(resolve(record.path)); - } - } - - for (const nativePath of nativePaths) { - const packageInfo = readPackageInfo(nativePath); - if (packageInfo.category === 'external') nativePackageNames.add(packageInfo.name); - } - - const totals = emptyTotals(); - const packages = new Map(); - const modules = []; - - for (const filePath of [...jsPaths].toSorted()) { - let metric; - try { - metric = readFileMetrics(filePath); - } catch (err) { - process.stderr.write(`Failed to analyze ${filePath}: ${err.message}\n`); - continue; - } - - addFileMetrics(totals, metric); - - const packageKey = metric.package.name; - if (!packages.has(packageKey)) { - packages.set(packageKey, { - name: metric.package.name, - version: metric.package.version, - category: metric.package.category, - sourceBytes: 0, - gzipBytes: 0, - modules: 0, - astNodeCount: 0, - functionCount: 0, - classCount: 0, - stringLiteralBytes: 0, - nativeAddon: false, - }); - } - - const packageSummary = packages.get(packageKey); - packageSummary.sourceBytes += metric.sourceBytes; - packageSummary.gzipBytes += metric.gzipBytes; - packageSummary.modules += 1; - packageSummary.astNodeCount += metric.astNodeCount; - packageSummary.functionCount += metric.functionCount; - packageSummary.classCount += metric.classCount; - packageSummary.stringLiteralBytes += metric.stringLiteralBytes; - - modules.push({ - path: metric.displayPath, - package: metric.package.name, - category: metric.package.category, - sourceBytes: metric.sourceBytes, - gzipBytes: metric.gzipBytes, - astNodeCount: metric.astNodeCount, - functionCount: metric.functionCount, - classCount: metric.classCount, - stringLiteralBytes: metric.stringLiteralBytes, - }); - } - - for (const packageName of nativePackageNames) { - const packageSummary = packages.get(packageName); - if (packageSummary != null) packageSummary.nativeAddon = true; - } - - const externalPackages = [...packages.values()].filter(packageSummary => packageSummary.category === 'external'); - totals.externalPackageCount = externalPackages.length; - totals.nativeAddonPackageCount = externalPackages.filter(packageSummary => packageSummary.nativeAddon).length; - - return { - totals: { - ...totals, - loadedJsSourceKiB: bytesToKiB(totals.loadedJsSourceBytes), - loadedJsGzipKiB: bytesToKiB(totals.loadedJsGzipBytes), - stringLiteralKiB: bytesToKiB(totals.stringLiteralBytes), - }, - packages: [...packages.values()].toSorted((a, b) => b.sourceBytes - a.sourceBytes), - modules: modules.toSorted((a, b) => b.sourceBytes - a.sourceBytes), - }; -} - -async function measureFootprint() { - await fs.writeFile(traceFile, ''); - - process.stderr.write('Resetting database and Redis\n'); - await resetState(); - - process.stderr.write('Running migrations\n'); - await util.run('pnpm', ['--filter', 'backend', 'migrate'], { - cwd: repoDir, - env: process.env, - logStdout: true, - }); - - const serverProcess = fork(join(backendBuiltDir, 'entry.js'), [], { - cwd: backendDir, - env: { - ...process.env, - NODE_ENV: 'production', - MK_DISABLE_CLUSTERING: '1', - MK_ONLY_SERVER: '1', - MK_NO_DAEMONS: '1', - MK_BACKEND_JS_FOOTPRINT_TRACE: traceFile, - }, - stdio: ['pipe', 'pipe', 'pipe', 'ipc'], - execArgv: [ - '--require', - join(__dirname, 'backend-js-footprint-require.cjs'), - '--experimental-loader', - pathToFileURL(join(__dirname, 'backend-js-footprint-loader.mjs')).href, - ], - }); - - serverProcess.stdout?.on('data', data => { - process.stderr.write(`[server stdout] ${data}`); - }); - - serverProcess.stderr?.on('data', data => { - process.stderr.write(`[server stderr] ${data}`); - }); - - serverProcess.on('error', err => { - process.stderr.write(`[server error] ${err}\n`); - }); - - try { - await waitForServerReady(serverProcess); - await setTimeout(SETTLE_TIME); - - //const startup = summarizeRecords(await readTraceRecords(), 'startup'); - - await Promise.all( - Array.from({ length: REQUEST_COUNT }).map(() => createRequest()), - ); - await setTimeout(1000); - - const afterRequest = summarizeRecords(await readTraceRecords(), 'afterRequest'); - - return { - timestamp: new Date().toISOString(), - measurement: { - strategy: 'runtime-loader-trace', - startupTimeoutMs: STARTUP_TIMEOUT, - settleTimeMs: SETTLE_TIME, - requestCount: REQUEST_COUNT, - cpus: cpus().length, - }, - phases: { - //startup, - afterRequest, - }, - }; - } finally { - await stopServer(serverProcess); - await fs.rm(traceFile, { force: true }); - } -} - -const result = await measureFootprint(); -await fs.writeFile(outputFile, `${JSON.stringify(result, null, 2)}\n`); diff --git a/.github/workflows/backend-diagnostics.inspect.yml b/.github/workflows/backend-diagnostics.inspect.yml index 65412bafa8..1474c78f0c 100644 --- a/.github/workflows/backend-diagnostics.inspect.yml +++ b/.github/workflows/backend-diagnostics.inspect.yml @@ -11,11 +11,8 @@ on: - packages/misskey-js/** - .github/scripts/utility.mts - .github/scripts/backend-diagnostics.render-md.mts - - .github/scripts/measure-backend-memory-comparison.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/backend-diagnostics.inspect.yml - .github/workflows/backend-diagnostics.report.yml @@ -96,7 +93,7 @@ jobs: MK_MEMORY_COMPARE_ROUNDS: 10 MK_MEMORY_COMPARE_WARMUP_ROUNDS: 1 MK_MEMORY_HEAP_SNAPSHOT: 1 - run: node head/.github/scripts/measure-backend-memory-comparison.mts base head memory-base.json memory-head.json + run: node head/.github/scripts/backend-diagnostics.inspect.mts base head memory-base.json memory-head.json - name: Upload base heap snapshot uses: actions/upload-artifact@v7 with: @@ -111,10 +108,6 @@ jobs: archive: false if-no-files-found: error retention-days: 7 - - name: Measure backend loaded JS footprint - run: | - node head/.github/scripts/backend-js-footprint.mjs base js-footprint-base.json - node head/.github/scripts/backend-js-footprint.mjs head js-footprint-head.json - name: Upload Artifact uses: actions/upload-artifact@v7 with: @@ -122,8 +115,6 @@ jobs: path: | memory-base.json memory-head.json - js-footprint-base.json - js-footprint-head.json save-pr-number: runs-on: ubuntu-latest diff --git a/.github/workflows/backend-diagnostics.report.yml b/.github/workflows/backend-diagnostics.report.yml index f9fded83d7..f358216a62 100644 --- a/.github/workflows/backend-diagnostics.report.yml +++ b/.github/workflows/backend-diagnostics.report.yml @@ -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-diagnostics.render-md.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 }} From ab92f984729d5a0dbb4f641939e6d4d36ef07ba9 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:02:45 +0900 Subject: [PATCH 076/168] refacotr(gh): rename some files --- ... frontend-browser-diagnostics.inspect.mts} | 0 ...er-diagnostics.render-additional-html.mts} | 5 +- ...rontend-browser-diagnostics.render-md.mts} | 9 ++-- ... frontend-browser-diagnostics.inspect.yml} | 52 +++++++++---------- ...> frontend-browser-diagnostics.report.yml} | 18 +++---- 5 files changed, 39 insertions(+), 45 deletions(-) rename .github/scripts/{measure-frontend-browser-comparison.mts => frontend-browser-diagnostics.inspect.mts} (100%) rename .github/scripts/{frontend-browser-detailed-html.mts => frontend-browser-diagnostics.render-additional-html.mts} (98%) rename .github/scripts/{frontend-browser-report.mts => frontend-browser-diagnostics.render-md.mts} (98%) rename .github/workflows/{frontend-browser-metrics-report.yml => frontend-browser-diagnostics.inspect.yml} (69%) rename .github/workflows/{frontend-browser-metrics-report-comment.yml => frontend-browser-diagnostics.report.yml} (62%) diff --git a/.github/scripts/measure-frontend-browser-comparison.mts b/.github/scripts/frontend-browser-diagnostics.inspect.mts similarity index 100% rename from .github/scripts/measure-frontend-browser-comparison.mts rename to .github/scripts/frontend-browser-diagnostics.inspect.mts diff --git a/.github/scripts/frontend-browser-detailed-html.mts b/.github/scripts/frontend-browser-diagnostics.render-additional-html.mts similarity index 98% rename from .github/scripts/frontend-browser-detailed-html.mts rename to .github/scripts/frontend-browser-diagnostics.render-additional-html.mts index 34240eefc6..86f70e7ebd 100644 --- a/.github/scripts/frontend-browser-detailed-html.mts +++ b/.github/scripts/frontend-browser-diagnostics.render-additional-html.mts @@ -6,7 +6,7 @@ import { readFile, writeFile } from 'node:fs/promises'; import { pathToFileURL } from 'node:url'; import * as util from './utility.mts'; -import type { BrowserMeasurementSample, BrowserMetricsReport } from './frontend-browser-report.mts'; +import type { BrowserMeasurementSample, BrowserMetricsReport } from './frontend-browser-diagnostics.render-md.mts'; import type { NetworkRequest } from './chrome.mts'; type DiffDirection = 'added' | 'removed'; @@ -434,9 +434,6 @@ function renderHtml(base: BrowserMetricsReport, head: BrowserMetricsReport) { async function main() { const [baseFile, headFile, outputFile] = process.argv.slice(2); - if (baseFile == null || headFile == null || outputFile == null) { - throw new Error('Usage: node frontend-browser-detailed-html.mts '); - } const base = JSON.parse(await readFile(baseFile, 'utf8')) as BrowserMetricsReport; const head = JSON.parse(await readFile(headFile, 'utf8')) as BrowserMetricsReport; diff --git a/.github/scripts/frontend-browser-report.mts b/.github/scripts/frontend-browser-diagnostics.render-md.mts similarity index 98% rename from .github/scripts/frontend-browser-report.mts rename to .github/scripts/frontend-browser-diagnostics.render-md.mts index 1ecfbe14db..b67a790102 100644 --- a/.github/scripts/frontend-browser-report.mts +++ b/.github/scripts/frontend-browser-diagnostics.render-md.mts @@ -324,12 +324,12 @@ export function renderFrontendBrowserReport(base: BrowserMetricsReport, head: Br : `${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), '', + 'Only metrics showing significant changes are displayed.', + '', //`> 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.`, //'', detailedHtmlUrl == null || detailedHtmlUrl === '' ? null : `[View details](${detailedHtmlUrl})`, @@ -367,9 +367,6 @@ export function renderFrontendBrowserReport(base: BrowserMetricsReport, head: Br async function main() { const [baseFile, headFile, outputFile] = process.argv.slice(2); - if (baseFile == null || headFile == null || outputFile == null) { - throw new Error('Usage: node frontend-browser-report.mts '); - } const base = JSON.parse(await readFile(baseFile, 'utf8')) as BrowserMetricsReport; const head = JSON.parse(await readFile(headFile, 'utf8')) as BrowserMetricsReport; diff --git a/.github/workflows/frontend-browser-metrics-report.yml b/.github/workflows/frontend-browser-diagnostics.inspect.yml similarity index 69% rename from .github/workflows/frontend-browser-metrics-report.yml rename to .github/workflows/frontend-browser-diagnostics.inspect.yml index 863c7bb155..444c54040b 100644 --- a/.github/workflows/frontend-browser-metrics-report.yml +++ b/.github/workflows/frontend-browser-diagnostics.inspect.yml @@ -1,4 +1,4 @@ -name: frontend-browser-metrics-report +name: Frontend browser diagnostics (inspect) on: pull_request: @@ -23,20 +23,20 @@ on: - .node-version - .github/misskey/test.yml - .github/scripts/utility.mts - - .github/scripts/frontend-browser-detailed-html.mts - - .github/scripts/frontend-browser-report.mts - .github/scripts/heap-snapshot-util.mts - - .github/scripts/measure-frontend-browser-comparison.mts - .github/scripts/chrome.mts - - .github/workflows/frontend-browser-metrics-report.yml - - .github/workflows/frontend-browser-metrics-report-comment.yml + - .github/scripts/frontend-browser-diagnostics.render-additional-html.mts + - .github/scripts/frontend-browser-diagnostics.render-md.mts + - .github/scripts/frontend-browser-diagnostics.inspect.mts + - .github/workflows/frontend-browser-diagnostics.inspect.yml + - .github/workflows/frontend-browser-diagnostics.report.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: @@ -125,33 +125,33 @@ 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" "$REPORT_DIR/head-heap-snapshot.heapsnapshot" - 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: ${{ runner.temp }}/frontend-browser-diagnostics/head-heap-snapshot.heapsnapshot 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 @@ -165,10 +165,10 @@ jobs: 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 +177,24 @@ jobs: - name: Check browser metrics report shell: bash run: | - REPORT_DIR="$RUNNER_TEMP/frontend-browser-metrics-report" - test -s "$REPORT_DIR/frontend-browser-metrics-report.md" + REPORT_DIR="$RUNNER_TEMP/frontend-browser-diagnostics" + test -s "$REPORT_DIR/frontend-browser-diagnostics.md" test -s "$REPORT_DIR/frontend-browser-detailed-html.html" test -s "$REPORT_DIR/pr-number.txt" test -s "$REPORT_DIR/head-sha.txt" - cat "$REPORT_DIR/frontend-browser-metrics-report.md" >> "$GITHUB_STEP_SUMMARY" + cat "$REPORT_DIR/frontend-browser-diagnostics.md" >> "$GITHUB_STEP_SUMMARY" - name: Upload browser metrics report uses: actions/upload-artifact@v7 with: - name: frontend-browser-metrics-report + name: frontend-browser-diagnostics path: | - ${{ runner.temp }}/frontend-browser-metrics-report/before-browser.json - ${{ runner.temp }}/frontend-browser-metrics-report/after-browser.json - ${{ runner.temp }}/frontend-browser-metrics-report/frontend-browser-metrics-report.md - ${{ runner.temp }}/frontend-browser-metrics-report/pr-number.txt - ${{ runner.temp }}/frontend-browser-metrics-report/base-sha.txt - ${{ runner.temp }}/frontend-browser-metrics-report/head-sha.txt - ${{ runner.temp }}/frontend-browser-metrics-report/pr-url.txt + ${{ runner.temp }}/frontend-browser-diagnostics/before-browser.json + ${{ runner.temp }}/frontend-browser-diagnostics/after-browser.json + ${{ runner.temp }}/frontend-browser-diagnostics/frontend-browser-diagnostics.md + ${{ runner.temp }}/frontend-browser-diagnostics/pr-number.txt + ${{ runner.temp }}/frontend-browser-diagnostics/base-sha.txt + ${{ runner.temp }}/frontend-browser-diagnostics/head-sha.txt + ${{ runner.temp }}/frontend-browser-diagnostics/pr-url.txt if-no-files-found: error retention-days: 7 diff --git a/.github/workflows/frontend-browser-metrics-report-comment.yml b/.github/workflows/frontend-browser-diagnostics.report.yml similarity index 62% rename from .github/workflows/frontend-browser-metrics-report-comment.yml rename to .github/workflows/frontend-browser-diagnostics.report.yml index 0312092a5b..7edc9f36c6 100644 --- a/.github/workflows/frontend-browser-metrics-report-comment.yml +++ b/.github/workflows/frontend-browser-diagnostics.report.yml @@ -1,9 +1,9 @@ -name: frontend-browser-metrics-report-comment +name: Frontend browser diagnostics (report) 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 From 64f98faca93e45ddbe137ca366b623d6a802b6e1 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:11:41 +0900 Subject: [PATCH 077/168] refacotr(gh): refactor --- .github/scripts/backend-diagnostics.inspect.mts | 6 +++--- .github/scripts/frontend-browser-diagnostics.inspect.mts | 6 +++--- .../scripts/frontend-browser-diagnostics.render-md.mts | 9 ++------- 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/.github/scripts/backend-diagnostics.inspect.mts b/.github/scripts/backend-diagnostics.inspect.mts index a4e93b3ce4..5bf0f3ee99 100644 --- a/.github/scripts/backend-diagnostics.inspect.mts +++ b/.github/scripts/backend-diagnostics.inspect.mts @@ -99,7 +99,7 @@ function summarizeSamples(samples: MemoryReport['samples']) { return summary; } -async function measureRepo(label: string, repoDir: string, round: number, options: { heapSnapshotSavePath?: string } = {}) { +async function genSample(label: string, repoDir: string, round: number, options: { heapSnapshotSavePath?: string } = {}) { process.stderr.write(`[${label}] Resetting database and Redis\n`); await resetState(repoDir); @@ -188,7 +188,7 @@ async function main() { for (let round = 1; round <= warmupRounds; round++) { process.stderr.write(`Starting warmup round ${round}/${warmupRounds}\n`); for (const label of heapSnapshotLabels) { - await measureRepo(label, reports[label].dir, -round); + await genSample(label, reports[label].dir, -round); } } @@ -198,7 +198,7 @@ async function main() { for (const label of order) { const options = { heapSnapshotSavePath: heapSnapshotPath(label, round) }; - const sample = await measureRepo(label, reports[label].dir, round, options); + const sample = await genSample(label, reports[label].dir, round, options); reports[label].samples.push({ ...sample, round, diff --git a/.github/scripts/frontend-browser-diagnostics.inspect.mts b/.github/scripts/frontend-browser-diagnostics.inspect.mts index 3e357c3396..545149df24 100644 --- a/.github/scripts/frontend-browser-diagnostics.inspect.mts +++ b/.github/scripts/frontend-browser-diagnostics.inspect.mts @@ -234,7 +234,7 @@ async function saveRepresentativeHeadHeapSnapshot(report: BrowserMetricsReport, await rm(headHeapSnapshotWorkDir, { 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, heapSnapshotSavePath?: string) { let server: ReturnType | null = null; try { @@ -269,8 +269,8 @@ async function measureRepo(label: 'base' | 'head', repoDir: string, outputPath: } 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), resolve(headHeapSnapshotOutputArg)); } await main(); diff --git a/.github/scripts/frontend-browser-diagnostics.render-md.mts b/.github/scripts/frontend-browser-diagnostics.render-md.mts index b67a790102..c18ef63f46 100644 --- a/.github/scripts/frontend-browser-diagnostics.render-md.mts +++ b/.github/scripts/frontend-browser-diagnostics.render-md.mts @@ -313,15 +313,12 @@ function toHeapSnapshotReport(report: BrowserMetricsReport): HeapSnapshotReport }; } -export function renderFrontendBrowserReport(base: BrowserMetricsReport, head: BrowserMetricsReport, options: { +function renderMd(base: BrowserMetricsReport, head: BrowserMetricsReport, options: { 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 Diagnostics Report', @@ -330,8 +327,6 @@ export function renderFrontendBrowserReport(base: BrowserMetricsReport, head: Br '', 'Only metrics showing significant changes are displayed.', '', - //`> 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.`, - //'', detailedHtmlUrl == null || detailedHtmlUrl === '' ? null : `[View details](${detailedHtmlUrl})`, detailedHtmlUrl == null || detailedHtmlUrl === '' ? null : '', '
', @@ -370,7 +365,7 @@ async function main() { 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, { + await writeFile(outputFile, renderMd(base, head, { headHeapSnapshotUrl: process.env.FRONTEND_BROWSER_HEAD_HEAP_SNAPSHOT_ARTIFACT_URL, detailedHtmlUrl: process.env.FRONTEND_BROWSER_DETAILED_HTML_ARTIFACT_URL, })); From fd83c9cb641e5bf82468b3f33fffda99d4562216 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:25:32 +0900 Subject: [PATCH 078/168] enhance(gh): tweak frontend-browser-diagnostics --- .../frontend-browser-diagnostics.inspect.mts | 35 ++++++++++--------- ...frontend-browser-diagnostics.render-md.mts | 11 +++--- .../frontend-browser-diagnostics.inspect.yml | 16 +++++++-- 3 files changed, 37 insertions(+), 25 deletions(-) diff --git a/.github/scripts/frontend-browser-diagnostics.inspect.mts b/.github/scripts/frontend-browser-diagnostics.inspect.mts index 545149df24..ec0e08857d 100644 --- a/.github/scripts/frontend-browser-diagnostics.inspect.mts +++ b/.github/scripts/frontend-browser-diagnostics.inspect.mts @@ -11,12 +11,15 @@ import { HeadlessChromeController, summarizeBrowserDiagnostics, summarizeNetwork 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, baseHeapSnapshotOutputArg, headHeapSnapshotOutputArg] = 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; type BrowserMeasurementSample = BrowserMeasurement & { round: number; @@ -223,28 +226,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, outputPath: string) { 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), outputPath); + process.stderr.write(`[${label}] Selected round ${representative.round} heap snapshot for artifact\n`); + await rm(heapSnapshotWorkDirs[label], { recursive: true, force: true }); } -async function genReport(label: 'base' | 'head', repoDir: string, outputPath: string, heapSnapshotSavePath?: string) { +async function genReport(label: 'base' | 'head', repoDir: string, outputPath: string, heapSnapshotSavePath: string) { let server: ReturnType | null = null; try { server = util.startServer(label, repoDir); 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++) { @@ -252,7 +253,7 @@ async function genReport(label: 'base' | 'head', repoDir: string, outputPath: st samples.push(await measureSample( label, round, - label === 'head' && heapSnapshotSavePath != null ? headHeapSnapshotPath(round) : undefined, + heapSnapshotPath(label, round), )); } @@ -260,8 +261,8 @@ async function genReport(label: 'base' | 'head', repoDir: string, outputPath: st 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); + if (heapSnapshotSavePath != null) { + await saveRepresentativeHeapSnapshot(label, report, heapSnapshotSavePath); } } finally { if (server != null) await util.stopServer(server); @@ -269,7 +270,7 @@ async function genReport(label: 'base' | 'head', repoDir: string, outputPath: st } async function main() { - await genReport('base', resolve(baseDirArg), resolve(baseOutputArg)); + await genReport('base', resolve(baseDirArg), resolve(baseOutputArg), resolve(baseHeapSnapshotOutputArg)); await genReport('head', resolve(headDirArg), resolve(headOutputArg), resolve(headHeapSnapshotOutputArg)); } diff --git a/.github/scripts/frontend-browser-diagnostics.render-md.mts b/.github/scripts/frontend-browser-diagnostics.render-md.mts index c18ef63f46..f0890e65ef 100644 --- a/.github/scripts/frontend-browser-diagnostics.render-md.mts +++ b/.github/scripts/frontend-browser-diagnostics.render-md.mts @@ -314,10 +314,10 @@ function toHeapSnapshotReport(report: BrowserMetricsReport): HeapSnapshotReport } function renderMd(base: BrowserMetricsReport, head: BrowserMetricsReport, options: { - headHeapSnapshotUrl?: string; + baseHeapSnapshotUrl: string; + headHeapSnapshotUrl: string; detailedHtmlUrl?: string; -} = {}) { - const headHeapSnapshotUrl = options.headHeapSnapshotUrl; +}) { const detailedHtmlUrl = options.detailedHtmlUrl; const heapSnapshotTable = heapSnapshotUtil.renderHeapSnapshotTable(toHeapSnapshotReport(base), toHeapSnapshotReport(head)); const lines = [ @@ -343,7 +343,7 @@ function renderMd(base: BrowserMetricsReport, head: BrowserMetricsReport, option '', heapSnapshotUtil.renderHeapSnapshotSankey(toHeapSnapshotReport(head), 'Head'), '', - `[Download representative head heap snapshot](${headHeapSnapshotUrl})`, + `Download representative heap snapshot: [base](${options.baseHeapSnapshotUrl}) / [head](${options.headHeapSnapshotUrl})`, '
', '', ]; @@ -366,7 +366,8 @@ async function main() { const base = JSON.parse(await readFile(baseFile, 'utf8')) as BrowserMetricsReport; const head = JSON.parse(await readFile(headFile, 'utf8')) as BrowserMetricsReport; await writeFile(outputFile, renderMd(base, head, { - headHeapSnapshotUrl: process.env.FRONTEND_BROWSER_HEAD_HEAP_SNAPSHOT_ARTIFACT_URL, + 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, })); } diff --git a/.github/workflows/frontend-browser-diagnostics.inspect.yml b/.github/workflows/frontend-browser-diagnostics.inspect.yml index 444c54040b..76af11bec6 100644 --- a/.github/workflows/frontend-browser-diagnostics.inspect.yml +++ b/.github/workflows/frontend-browser-diagnostics.inspect.yml @@ -127,14 +127,23 @@ jobs: run: | REPORT_DIR="$RUNNER_TEMP/frontend-browser-diagnostics" mkdir -p "$REPORT_DIR" - node after/.github/scripts/frontend-browser-diagnostics.inspect.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" "$REPORT_DIR/base-heap-snapshot.heapsnapshot" "$REPORT_DIR/head-heap-snapshot.heapsnapshot" + + - 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-diagnostics/head-heap-snapshot.heapsnapshot + path: head-heap-snapshot.heapsnapshot + archive: false if-no-files-found: error retention-days: 7 @@ -162,6 +171,7 @@ 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: | From 153875e1d96a472e05277dbba77c654d21d022f0 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:28:05 +0900 Subject: [PATCH 079/168] [skip ci] refacotr(gh): add note --- .../frontend-browser-diagnostics.render-additional-html.mts | 1 + .github/scripts/frontend-browser-diagnostics.render-md.mts | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/scripts/frontend-browser-diagnostics.render-additional-html.mts b/.github/scripts/frontend-browser-diagnostics.render-additional-html.mts index 86f70e7ebd..568f4257e2 100644 --- a/.github/scripts/frontend-browser-diagnostics.render-additional-html.mts +++ b/.github/scripts/frontend-browser-diagnostics.render-additional-html.mts @@ -440,6 +440,7 @@ async function main() { await writeFile(outputFile, renderHtml(base, head)); } +// 直接実行されたときだけ呼ぶための判定(テストなどでこのファイル内の関数をimportするだけのとき用) if (process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href) { await main(); } diff --git a/.github/scripts/frontend-browser-diagnostics.render-md.mts b/.github/scripts/frontend-browser-diagnostics.render-md.mts index f0890e65ef..76ff29b911 100644 --- a/.github/scripts/frontend-browser-diagnostics.render-md.mts +++ b/.github/scripts/frontend-browser-diagnostics.render-md.mts @@ -372,6 +372,7 @@ async function main() { })); } +// 直接実行されたときだけ呼ぶための判定(テストなどでこのファイル内の関数をimportするだけのとき用) if (process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href) { await main(); } From 8aff44ae2e23f34fabc627dfed4658c03df21bc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=8B=E3=81=A3=E3=81=93=E3=81=8B=E3=82=8A?= <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:37:07 +0900 Subject: [PATCH 080/168] =?UTF-8?q?refactor(misskey-js):=20=E3=83=A2?= =?UTF-8?q?=E3=83=87=E3=83=AC=E3=83=BC=E3=82=B7=E3=83=A7=E3=83=B3=E3=83=AD?= =?UTF-8?q?=E3=82=B0=E3=81=AE=E5=9E=8B=E3=82=92=E6=89=8B=E5=8B=95=E3=81=A7?= =?UTF-8?q?=E6=9B=B8=E3=81=8B=E3=81=9A=E3=81=AB=E3=81=99=E3=82=80=E3=82=88?= =?UTF-8?q?=E3=81=86=E3=81=AB=20(#17729)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(misskey-js): モデレーションログの型を手動で書かずにすむように * fix lint --- packages/misskey-js/etc/misskey-js.api.md | 167 +--------------------- packages/misskey-js/src/entities.ts | 164 +-------------------- 2 files changed, 12 insertions(+), 319 deletions(-) diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index a2effe339a..634ee1f02c 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -2884,6 +2884,8 @@ type MiauthGenTokenRequest = operations['miauth___gen-token']['requestBody']['co // @public (undocumented) type MiauthGenTokenResponse = operations['miauth___gen-token']['responses']['200']['content']['application/json']; +// Warning: (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts +// // @public (undocumented) type ModerationLog = { id: ID; @@ -2891,165 +2893,11 @@ type ModerationLog = { userId: User['id']; user: UserDetailedNotMe; } & ({ - type: 'updateServerSettings'; - info: ModerationLogPayloads['updateServerSettings']; -} | { - type: 'suspend'; - info: ModerationLogPayloads['suspend']; -} | { - type: 'unsuspend'; - info: ModerationLogPayloads['unsuspend']; -} | { - type: 'updateUserNote'; - info: ModerationLogPayloads['updateUserNote']; -} | { - type: 'addCustomEmoji'; - info: ModerationLogPayloads['addCustomEmoji']; -} | { - type: 'updateCustomEmoji'; - info: ModerationLogPayloads['updateCustomEmoji']; -} | { - type: 'deleteCustomEmoji'; - info: ModerationLogPayloads['deleteCustomEmoji']; -} | { - type: 'assignRole'; - info: ModerationLogPayloads['assignRole']; -} | { - type: 'unassignRole'; - info: ModerationLogPayloads['unassignRole']; -} | { - type: 'createRole'; - info: ModerationLogPayloads['createRole']; -} | { - type: 'updateRole'; - info: ModerationLogPayloads['updateRole']; -} | { - type: 'deleteRole'; - info: ModerationLogPayloads['deleteRole']; -} | { - type: 'clearQueue'; - info: ModerationLogPayloads['clearQueue']; -} | { - type: 'promoteQueue'; - info: ModerationLogPayloads['promoteQueue']; -} | { - type: 'deleteDriveFile'; - info: ModerationLogPayloads['deleteDriveFile']; -} | { - type: 'deleteNote'; - info: ModerationLogPayloads['deleteNote']; -} | { - type: 'createGlobalAnnouncement'; - info: ModerationLogPayloads['createGlobalAnnouncement']; -} | { - type: 'createUserAnnouncement'; - info: ModerationLogPayloads['createUserAnnouncement']; -} | { - type: 'updateGlobalAnnouncement'; - info: ModerationLogPayloads['updateGlobalAnnouncement']; -} | { - type: 'updateUserAnnouncement'; - info: ModerationLogPayloads['updateUserAnnouncement']; -} | { - type: 'deleteGlobalAnnouncement'; - info: ModerationLogPayloads['deleteGlobalAnnouncement']; -} | { - type: 'deleteUserAnnouncement'; - info: ModerationLogPayloads['deleteUserAnnouncement']; -} | { - type: 'resetPassword'; - info: ModerationLogPayloads['resetPassword']; -} | { - type: 'suspendRemoteInstance'; - info: ModerationLogPayloads['suspendRemoteInstance']; -} | { - type: 'unsuspendRemoteInstance'; - info: ModerationLogPayloads['unsuspendRemoteInstance']; -} | { - type: 'updateRemoteInstanceNote'; - info: ModerationLogPayloads['updateRemoteInstanceNote']; -} | { - type: 'markSensitiveDriveFile'; - info: ModerationLogPayloads['markSensitiveDriveFile']; -} | { - type: 'unmarkSensitiveDriveFile'; - info: ModerationLogPayloads['unmarkSensitiveDriveFile']; -} | { - type: 'createInvitation'; - info: ModerationLogPayloads['createInvitation']; -} | { - type: 'createAd'; - info: ModerationLogPayloads['createAd']; -} | { - type: 'updateAd'; - info: ModerationLogPayloads['updateAd']; -} | { - type: 'deleteAd'; - info: ModerationLogPayloads['deleteAd']; -} | { - type: 'createAvatarDecoration'; - info: ModerationLogPayloads['createAvatarDecoration']; -} | { - type: 'updateAvatarDecoration'; - info: ModerationLogPayloads['updateAvatarDecoration']; -} | { - type: 'deleteAvatarDecoration'; - info: ModerationLogPayloads['deleteAvatarDecoration']; -} | { - type: 'resolveAbuseReport'; - info: ModerationLogPayloads['resolveAbuseReport']; -} | { - type: 'forwardAbuseReport'; - info: ModerationLogPayloads['forwardAbuseReport']; -} | { - type: 'updateAbuseReportNote'; - info: ModerationLogPayloads['updateAbuseReportNote']; -} | { - type: 'unsetMfa'; - info: ModerationLogPayloads['unsetMfa']; -} | { - type: 'unsetUserAvatar'; - info: ModerationLogPayloads['unsetUserAvatar']; -} | { - type: 'unsetUserBanner'; - info: ModerationLogPayloads['unsetUserBanner']; -} | { - type: 'createSystemWebhook'; - info: ModerationLogPayloads['createSystemWebhook']; -} | { - type: 'updateSystemWebhook'; - info: ModerationLogPayloads['updateSystemWebhook']; -} | { - type: 'deleteSystemWebhook'; - info: ModerationLogPayloads['deleteSystemWebhook']; -} | { - type: 'createAbuseReportNotificationRecipient'; - info: ModerationLogPayloads['createAbuseReportNotificationRecipient']; -} | { - type: 'updateAbuseReportNotificationRecipient'; - info: ModerationLogPayloads['updateAbuseReportNotificationRecipient']; -} | { - type: 'deleteAbuseReportNotificationRecipient'; - info: ModerationLogPayloads['deleteAbuseReportNotificationRecipient']; -} | { - type: 'deleteAccount'; - info: ModerationLogPayloads['deleteAccount']; -} | { - type: 'deletePage'; - info: ModerationLogPayloads['deletePage']; -} | { - type: 'deleteFlash'; - info: ModerationLogPayloads['deleteFlash']; -} | { - type: 'deleteGalleryPost'; - info: ModerationLogPayloads['deleteGalleryPost']; -} | { - type: 'deleteChatRoom'; - info: ModerationLogPayloads['deleteChatRoom']; -} | { - type: 'updateProxyAccountDescription'; - info: ModerationLogPayloads['updateProxyAccountDescription']; -}); + [K in keyof ModerationLogPayloads]: { + type: K; + info: ModerationLogPayloads[K]; + }; +}[keyof ModerationLogPayloads]); // @public (undocumented) export const moderationLogTypes: readonly ["updateServerSettings", "suspend", "unsuspend", "updateUserNote", "addCustomEmoji", "updateCustomEmoji", "deleteCustomEmoji", "assignRole", "unassignRole", "createRole", "updateRole", "deleteRole", "clearQueue", "promoteQueue", "deleteDriveFile", "deleteNote", "createGlobalAnnouncement", "createUserAnnouncement", "updateGlobalAnnouncement", "updateUserAnnouncement", "deleteGlobalAnnouncement", "deleteUserAnnouncement", "resetPassword", "suspendRemoteInstance", "unsuspendRemoteInstance", "updateRemoteInstanceNote", "markSensitiveDriveFile", "unmarkSensitiveDriveFile", "resolveAbuseReport", "forwardAbuseReport", "updateAbuseReportNote", "createInvitation", "createAd", "updateAd", "deleteAd", "createAvatarDecoration", "updateAvatarDecoration", "deleteAvatarDecoration", "unsetMfa", "unsetUserAvatar", "unsetUserBanner", "createSystemWebhook", "updateSystemWebhook", "deleteSystemWebhook", "createAbuseReportNotificationRecipient", "updateAbuseReportNotificationRecipient", "deleteAbuseReportNotificationRecipient", "deleteAccount", "deletePage", "deleteFlash", "deleteGalleryPost", "deleteChatRoom", "updateProxyAccountDescription"]; @@ -3922,7 +3770,6 @@ type VerifyEmailRequest = operations['verify-email']['requestBody']['content'][' // Warnings were encountered during analysis: // -// src/entities.ts:60:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts // src/streaming.ts:57:3 - (ae-forgotten-export) The symbol "ReconnectingWebSocket" needs to be exported by the entry point index.d.ts // src/streaming.types.ts:226:4 - (ae-forgotten-export) The symbol "ReversiUpdateKey" needs to be exported by the entry point index.d.ts // src/streaming.types.ts:241:4 - (ae-forgotten-export) The symbol "ReversiUpdateSettings" needs to be exported by the entry point index.d.ts diff --git a/packages/misskey-js/src/entities.ts b/packages/misskey-js/src/entities.ts index 77cec5fe43..4fabe4f096 100644 --- a/packages/misskey-js/src/entities.ts +++ b/packages/misskey-js/src/entities.ts @@ -56,165 +56,11 @@ export type ModerationLog = { userId: User['id']; user: UserDetailedNotMe; } & ({ - type: 'updateServerSettings'; - info: ModerationLogPayloads['updateServerSettings']; -} | { - type: 'suspend'; - info: ModerationLogPayloads['suspend']; -} | { - type: 'unsuspend'; - info: ModerationLogPayloads['unsuspend']; -} | { - type: 'updateUserNote'; - info: ModerationLogPayloads['updateUserNote']; -} | { - type: 'addCustomEmoji'; - info: ModerationLogPayloads['addCustomEmoji']; -} | { - type: 'updateCustomEmoji'; - info: ModerationLogPayloads['updateCustomEmoji']; -} | { - type: 'deleteCustomEmoji'; - info: ModerationLogPayloads['deleteCustomEmoji']; -} | { - type: 'assignRole'; - info: ModerationLogPayloads['assignRole']; -} | { - type: 'unassignRole'; - info: ModerationLogPayloads['unassignRole']; -} | { - type: 'createRole'; - info: ModerationLogPayloads['createRole']; -} | { - type: 'updateRole'; - info: ModerationLogPayloads['updateRole']; -} | { - type: 'deleteRole'; - info: ModerationLogPayloads['deleteRole']; -} | { - type: 'clearQueue'; - info: ModerationLogPayloads['clearQueue']; -} | { - type: 'promoteQueue'; - info: ModerationLogPayloads['promoteQueue']; -} | { - type: 'deleteDriveFile'; - info: ModerationLogPayloads['deleteDriveFile']; -} | { - type: 'deleteNote'; - info: ModerationLogPayloads['deleteNote']; -} | { - type: 'createGlobalAnnouncement'; - info: ModerationLogPayloads['createGlobalAnnouncement']; -} | { - type: 'createUserAnnouncement'; - info: ModerationLogPayloads['createUserAnnouncement']; -} | { - type: 'updateGlobalAnnouncement'; - info: ModerationLogPayloads['updateGlobalAnnouncement']; -} | { - type: 'updateUserAnnouncement'; - info: ModerationLogPayloads['updateUserAnnouncement']; -} | { - type: 'deleteGlobalAnnouncement'; - info: ModerationLogPayloads['deleteGlobalAnnouncement']; -} | { - type: 'deleteUserAnnouncement'; - info: ModerationLogPayloads['deleteUserAnnouncement']; -} | { - type: 'resetPassword'; - info: ModerationLogPayloads['resetPassword']; -} | { - type: 'suspendRemoteInstance'; - info: ModerationLogPayloads['suspendRemoteInstance']; -} | { - type: 'unsuspendRemoteInstance'; - info: ModerationLogPayloads['unsuspendRemoteInstance']; -} | { - type: 'updateRemoteInstanceNote'; - info: ModerationLogPayloads['updateRemoteInstanceNote']; -} | { - type: 'markSensitiveDriveFile'; - info: ModerationLogPayloads['markSensitiveDriveFile']; -} | { - type: 'unmarkSensitiveDriveFile'; - info: ModerationLogPayloads['unmarkSensitiveDriveFile']; -} | { - type: 'createInvitation'; - info: ModerationLogPayloads['createInvitation']; -} | { - type: 'createAd'; - info: ModerationLogPayloads['createAd']; -} | { - type: 'updateAd'; - info: ModerationLogPayloads['updateAd']; -} | { - type: 'deleteAd'; - info: ModerationLogPayloads['deleteAd']; -} | { - type: 'createAvatarDecoration'; - info: ModerationLogPayloads['createAvatarDecoration']; -} | { - type: 'updateAvatarDecoration'; - info: ModerationLogPayloads['updateAvatarDecoration']; -} | { - type: 'deleteAvatarDecoration'; - info: ModerationLogPayloads['deleteAvatarDecoration']; -} | { - type: 'resolveAbuseReport'; - info: ModerationLogPayloads['resolveAbuseReport']; -} | { - type: 'forwardAbuseReport'; - info: ModerationLogPayloads['forwardAbuseReport']; -} | { - type: 'updateAbuseReportNote'; - info: ModerationLogPayloads['updateAbuseReportNote']; -} | { - type: 'unsetMfa'; - info: ModerationLogPayloads['unsetMfa']; -} | { - type: 'unsetUserAvatar'; - info: ModerationLogPayloads['unsetUserAvatar']; -} | { - type: 'unsetUserBanner'; - info: ModerationLogPayloads['unsetUserBanner']; -} | { - type: 'createSystemWebhook'; - info: ModerationLogPayloads['createSystemWebhook']; -} | { - type: 'updateSystemWebhook'; - info: ModerationLogPayloads['updateSystemWebhook']; -} | { - type: 'deleteSystemWebhook'; - info: ModerationLogPayloads['deleteSystemWebhook']; -} | { - type: 'createAbuseReportNotificationRecipient'; - info: ModerationLogPayloads['createAbuseReportNotificationRecipient']; -} | { - type: 'updateAbuseReportNotificationRecipient'; - info: ModerationLogPayloads['updateAbuseReportNotificationRecipient']; -} | { - type: 'deleteAbuseReportNotificationRecipient'; - info: ModerationLogPayloads['deleteAbuseReportNotificationRecipient']; -} | { - type: 'deleteAccount'; - info: ModerationLogPayloads['deleteAccount']; -} | { - type: 'deletePage'; - info: ModerationLogPayloads['deletePage']; -} | { - type: 'deleteFlash'; - info: ModerationLogPayloads['deleteFlash']; -} | { - type: 'deleteGalleryPost'; - info: ModerationLogPayloads['deleteGalleryPost']; -} | { - type: 'deleteChatRoom'; - info: ModerationLogPayloads['deleteChatRoom']; -} | { - type: 'updateProxyAccountDescription'; - info: ModerationLogPayloads['updateProxyAccountDescription']; -}); + [K in keyof ModerationLogPayloads]: { + type: K; + info: ModerationLogPayloads[K]; + }; +}[keyof ModerationLogPayloads]); export type ServerStats = { cpu: number; From 2874a6d5ba903fb752ca7c1dad98c81964aff95e Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:44:55 +0900 Subject: [PATCH 081/168] refacotr(gh): refactor --- ...frontend-bundle-diagnostics.render-md.mts} | 2 +- .github/scripts/frontend-js-size.test.mts | 252 -------------- ...> frontend-bundle-diagnostics.inspect.yml} | 16 +- .../frontend-bundle-diagnostics.report.yml | 179 ++++++++++ .../frontend-bundle-report-comment.yml | 318 ------------------ .github/workflows/lint.yml | 16 - 6 files changed, 188 insertions(+), 595 deletions(-) rename .github/scripts/{frontend-js-size.mts => frontend-bundle-diagnostics.render-md.mts} (99%) delete mode 100644 .github/scripts/frontend-js-size.test.mts rename .github/workflows/{frontend-bundle-report.yml => frontend-bundle-diagnostics.inspect.yml} (89%) create mode 100644 .github/workflows/frontend-bundle-diagnostics.report.yml delete mode 100644 .github/workflows/frontend-bundle-report-comment.yml diff --git a/.github/scripts/frontend-js-size.mts b/.github/scripts/frontend-bundle-diagnostics.render-md.mts similarity index 99% rename from .github/scripts/frontend-js-size.mts rename to .github/scripts/frontend-bundle-diagnostics.render-md.mts index db134bab79..7ec0cfc61d 100644 --- a/.github/scripts/frontend-js-size.mts +++ b/.github/scripts/frontend-bundle-diagnostics.render-md.mts @@ -7,7 +7,7 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; import * as util from './utility.mts'; -const marker = ''; +const marker = ''; const locale = process.env.FRONTEND_JS_SIZE_LOCALE ?? 'ja-JP'; diff --git a/.github/scripts/frontend-js-size.test.mts b/.github/scripts/frontend-js-size.test.mts deleted file mode 100644 index d3b3db9af6..0000000000 --- a/.github/scripts/frontend-js-size.test.mts +++ /dev/null @@ -1,252 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import assert from 'node:assert/strict'; -import { execFile } from 'node:child_process'; -import { promises as fs } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import test, { type TestContext } from 'node:test'; -import * as util from './utility.mts'; - -type Manifest = Record; - -const repoDir = path.resolve(import.meta.dirname, '../..'); -const reportScript = path.join(repoDir, '.github/scripts/frontend-js-size.mts'); - -async function writeBuild(repo: string, manifest: Manifest, sizes: Record) { - const outDir = path.join(repo, 'built/_frontend_vite_/'); - await fs.mkdir(path.join(outDir, 'ja-JP'), { recursive: true }); - await fs.writeFile(path.join(outDir, 'manifest.json'), JSON.stringify(manifest)); - for (const [file, size] of Object.entries(sizes)) { - const localizedFile = file.replace(/^scripts\//, ''); - const outputFile = path.join(outDir, 'ja-JP', localizedFile); - await fs.mkdir(path.dirname(outputFile), { recursive: true }); - await fs.writeFile(outputFile, Buffer.alloc(size)); - } -} - -async function runReport(t: TestContext, before: { manifest: Manifest; sizes: Record }, after: { manifest: Manifest; sizes: Record }, expectFailure = false) { - const root = await fs.mkdtemp(path.join(os.tmpdir(), 'frontend-js-size-')); - t.after(() => fs.rm(root, { recursive: true, force: true })); - const beforeDir = path.join(root, 'before'); - const afterDir = path.join(root, 'after'); - const beforeStats = path.join(root, 'before-stats.json'); - const afterStats = path.join(root, 'after-stats.json'); - const reportFile = path.join(root, 'report.md'); - await writeBuild(beforeDir, before.manifest, before.sizes); - await writeBuild(afterDir, after.manifest, after.sizes); - await fs.writeFile(beforeStats, '{}'); - await fs.writeFile(afterStats, '{}'); - const args = [reportScript, beforeDir, afterDir, beforeStats, afterStats, reportFile]; - if (expectFailure) { - return new Promise((resolve, reject) => { - execFile(process.execPath, args, (error, _stdout, stderr) => { - if (error == null) { - reject(new Error('Expected frontend report script to fail')); - } else { - resolve(stderr); - } - }); - }); - } - await util.run(process.execPath, args); - return fs.readFile(reportFile, 'utf8'); -} - -function fixture(suffix: string, generatedName: string, sizes: { entry: number; generatedA: number; generatedB: number; vue: number; i18n: number }) { - const files = { - entry: `scripts/entry-${suffix}.js`, - generatedA: `scripts/generated-a-${suffix}.js`, - generatedB: `scripts/generated-b-${suffix}.js`, - vue: `scripts/vue-${suffix}.js`, - i18n: `scripts/i18n-${suffix}.js`, - }; - return { - manifest: { - 'src/_boot_.ts': { file: files.entry, src: 'src/_boot_.ts', name: 'entry', isEntry: true, imports: ['_generatedA', '_generatedB', '_vue', '_i18n'] }, - _generatedA: { file: files.generatedA, name: generatedName }, - _generatedB: { file: files.generatedB, name: generatedName }, - _vue: { file: files.vue, name: 'vue' }, - _i18n: { file: files.i18n, name: 'i18n' }, - }, - sizes: Object.fromEntries(Object.entries(files).map(([key, file]) => [file, sizes[key as keyof typeof sizes]])), - }; -} - -test('groups generated chunks while preserving full and startup totals', async t => { - const report = await runReport( - t, - fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }), - fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 55, i18n: 50 }), - ); - - assert.match(report, /\| \(total\) \| 220 B \| 285 B \|/); - assert.equal(report.match(/\| \(other generated chunks\) \| 30 B \| 70 B \|/g)?.length, 2); - assert.doesNotMatch(report, /generated chunks are grouped/); - assert.doesNotMatch(report, /`(?:dist|esm)`<\/summary>/); - assert.match(report, /`src\/_boot_\.ts`<\/summary>/); - assert.match(report, /`vue`<\/summary>/); -}); - -test('groups small deltas at the bottom while preserving all change counts', async t => { - const before = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }); - before.manifest._removedSmall = { file: 'scripts/removed-small-before.js', src: 'src/removed-small.ts' }; - before.manifest['src/_boot_.ts'].imports?.push('_removedSmall'); - before.sizes['scripts/removed-small-before.js'] = 5; - - const after = fixture('after', 'esm', { entry: 106, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }); - after.manifest._addedSmall = { file: 'scripts/added-small-after.js', src: 'src/added-small.ts' }; - after.manifest['src/_boot_.ts'].imports?.push('_addedSmall'); - after.sizes['scripts/added-small-after.js'] = 5; - - const report = await runReport(t, before, after); - - assert.match(report, /Chunk size diff \(2 updated, 1 added, 1 removed\)<\/summary>/); - assert.match(report, /Startup chunk size \(2 updated, 1 added, 1 removed\)<\/summary>/); - assert.equal(report.match(/`src\/_boot_\.ts`<\/summary>/g)?.length, 2); - assert.doesNotMatch(report, /`(?:vue|i18n|src\/added-small\.ts|src\/removed-small\.ts)`<\/summary>/); - assert.match(report, /\| \(total\) \| 225 B \| 276 B \|[^\n]*\n\| \| \| \| \| \|\n\|
`src\/_boot_\.ts`<\/summary>[^\n]*\n\| \(other generated chunks\) \| 30 B \| 70 B \|[^\n]*\n\| \(other\) \| 45 B \| 50 B \|/); - assert.match(report, /\| \(total\) \| 225 B \| 276 B \|[^\n]*\n\| \| \| \| \| \|\n\|
`src\/_boot_\.ts`<\/summary>[^\n]*\n\| \(other generated chunks\) \| 30 B \| 70 B \|[^\n]*\n\| \(other\) \| 95 B \| 100 B \|/); -}); - -test('fails instead of overwriting duplicate stable chunk keys', async t => { - const duplicateVue = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }); - duplicateVue.manifest._vueDuplicate = { file: 'scripts/vue-duplicate-before.js', name: 'vue' }; - duplicateVue.sizes['scripts/vue-duplicate-before.js'] = 60; - - const diagnostic = await runReport( - t, - duplicateVue, - fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }), - true, - ); - assert.match(diagnostic, /Duplicate stable chunk key "named:vue".*vue-before\.js.*vue-duplicate-before\.js/); -}); - -test('shows both filenames for an updated stable chunk', async t => { - const report = await runReport( - t, - fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }), - fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 55, i18n: 50 }), - ); - - assert.match(report, /`ja-JP\/entry-before\.js → ja-JP\/entry-after\.js`/); - assert.match(report, /`ja-JP\/vue-before\.js → ja-JP\/vue-after\.js`/); -}); - -test('fails descriptively when the startup entry is missing', async t => { - const before = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }); - delete before.manifest['src/_boot_.ts']; - delete before.sizes['scripts/entry-before.js']; - - const diagnostic = await runReport( - t, - before, - fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }), - true, - ); - assert.match(diagnostic, /Unable to find frontend startup entry in Vite manifest/); -}); - -test('fails descriptively when a static import is missing from the manifest', async t => { - const before = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }); - before.manifest['src/_boot_.ts'].imports = ['_missing']; - - const diagnostic = await runReport( - t, - before, - fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }), - true, - ); - assert.match(diagnostic, /Startup manifest key "_missing".*is missing/); -}); - -test('fails descriptively when a static import has no output file', async t => { - const before = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }); - before.manifest['src/_boot_.ts'].imports = ['_malformed']; - before.manifest._malformed = { name: 'malformed' }; - - const diagnostic = await runReport( - t, - before, - fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }), - true, - ); - assert.match(diagnostic, /Startup manifest key "_malformed".*has no output file/); -}); - -test('fails descriptively when a static import resolves to a non-JavaScript output', async t => { - const before = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }); - before.manifest['src/_boot_.ts'].imports = ['_malformed']; - before.manifest._malformed = { file: 'assets/malformed.css', name: 'malformed' }; - - const diagnostic = await runReport( - t, - before, - fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }), - true, - ); - assert.match(diagnostic, /Startup manifest key "_malformed".*non-JavaScript output "assets\/malformed\.css"/); -}); - -test('keeps source-backed additions and removals as individual rows', async t => { - const before = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }); - before.manifest._removed = { file: 'scripts/removed-before.js', src: 'src/removed.ts' }; - before.sizes['scripts/removed-before.js'] = 12; - const after = fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }); - after.manifest._added = { file: 'scripts/added-after.js', src: 'src/added.ts' }; - after.sizes['scripts/added-after.js'] = 13; - - const report = await runReport(t, before, after); - - assert.match(report, /`src\/added\.ts`<\/summary> `ja-JP\/added-after\.js` <\/details> \| 0 B \| 13 B \|/); - assert.match(report, /`src\/removed\.ts`<\/summary> `ja-JP\/removed-before\.js` <\/details> \| 12 B \| 0 B \|/); -}); - -test('counts an unmanifested localized JavaScript file in totals and the generated aggregate', async t => { - const before = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }); - before.sizes['scripts/unmanifested-before.js'] = 15; - - const report = await runReport( - t, - before, - fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }), - ); - - assert.match(report, /\| \(total\) \| 235 B \| 275 B \|/); - assert.match(report, /\| \(other generated chunks\) \| 45 B \| 70 B \|/); -}); - -test('counts duplicate manifest entries for one physical output only once', async t => { - const before = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }); - before.manifest._generatedAlias = { file: 'scripts/generated-a-before.js', name: 'dist' }; - - const report = await runReport( - t, - before, - fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }), - ); - - assert.match(report, /\| \(total\) \| 220 B \| 275 B \|/); -}); - -test('does not count generated-aggregate-only changes as individual chunk changes', async t => { - const report = await runReport( - t, - fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }), - fixture('after', 'esm', { entry: 100, generatedA: 30, generatedB: 40, vue: 40, i18n: 50 }), - ); - - assert.match(report, /Chunk size diff \(0 updated, 0 added, 0 removed\)<\/summary>/); - assert.match(report, /Startup chunk size \(0 updated, 0 added, 0 removed\)<\/summary>/); - assert.equal(report.match(/\| \(other generated chunks\) \| 30 B \| 70 B \|/g)?.length, 2); -}); diff --git a/.github/workflows/frontend-bundle-report.yml b/.github/workflows/frontend-bundle-diagnostics.inspect.yml similarity index 89% rename from .github/workflows/frontend-bundle-report.yml rename to .github/workflows/frontend-bundle-diagnostics.inspect.yml index b5af3bb0e1..31faaed79b 100644 --- a/.github/workflows/frontend-bundle-report.yml +++ b/.github/workflows/frontend-bundle-diagnostics.inspect.yml @@ -1,4 +1,4 @@ -name: frontend-bundle-report +name: Frontend bundle diagnostics (inspect) on: pull_request: @@ -21,16 +21,16 @@ 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.report.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: @@ -145,7 +145,7 @@ 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" @@ -157,8 +157,8 @@ jobs: 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' diff --git a/.github/workflows/frontend-bundle-diagnostics.report.yml b/.github/workflows/frontend-bundle-diagnostics.report.yml new file mode 100644 index 0000000000..260217ee53 --- /dev/null +++ b/.github/workflows/frontend-bundle-diagnostics.report.yml @@ -0,0 +1,179 @@ +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-bundle-diagnostics.render-md.mts + - .github/workflows/frontend-bundle-diagnostics.inspect.yml + - .github/workflows/frontend-bundle-diagnostics.report.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-diagnostics-report-${{ 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 + uses: thollander/actions-comment-pull-request@v3 + with: + pr-number: ${{ steps.load-pr-number.outputs.pr-number }} + comment-tag: frontend_bundle_diagnostics + file-path: ${{ runner.temp }}/frontend-bundle-report/frontend-bundle-diagnostics-report.md diff --git a/.github/workflows/frontend-bundle-report-comment.yml b/.github/workflows/frontend-bundle-report-comment.yml deleted file mode 100644 index b93f8a320f..0000000000 --- a/.github/workflows/frontend-bundle-report-comment.yml +++ /dev/null @@ -1,318 +0,0 @@ -name: frontend-bundle-report-comment - -on: - workflow_run: - workflows: - - frontend-bundle-report - types: - - completed - pull_request_target: - types: - - opened - - synchronize - - reopened - - ready_for_review - paths: - - packages/frontend/** - - packages/frontend-shared/** - - packages/frontend-builder/** - - packages/i18n/** - - packages/icons-subsetter/** - - packages/misskey-js/** - - packages/misskey-reversi/** - - packages/misskey-bubble-game/** - - package.json - - pnpm-lock.yaml - - pnpm-workspace.yaml - - .node-version - - .github/scripts/utility.mts - - .github/scripts/frontend-js-size.mts - - .github/workflows/frontend-bundle-report.yml - - .github/workflows/frontend-bundle-report-comment.yml - -permissions: - actions: read - contents: read - issues: write - pull-requests: write - -jobs: - comment: - name: Comment frontend bundle report - if: github.event_name == 'pull_request_target' || (github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success') - runs-on: ubuntu-latest - concurrency: - group: frontend-bundle-report-comment-${{ github.event.pull_request.number || github.event.workflow_run.id }} - cancel-in-progress: true - steps: - - name: Find bundle report run - if: github.event_name == 'pull_request_target' - id: find-report-run - uses: actions/github-script@v9 - with: - script: | - const workflow_id = 'frontend-bundle-report.yml'; - const artifactName = 'frontend-bundle-report'; - const headSha = context.payload.pull_request.head.sha; - const prNumber = context.payload.pull_request.number; - const pollIntervalMs = 30_000; - const timeoutMs = 90 * 60_000; - const startedAt = Date.now(); - const { owner, repo } = context.repo; - - async function listReportWorkflowRuns() { - const runsForHead = await github.paginate(github.rest.actions.listWorkflowRuns, { - owner, - repo, - workflow_id, - event: 'pull_request', - head_sha: headSha, - per_page: 100, - }); - - if (runsForHead.length > 0) { - return runsForHead; - } - - const recentRuns = await github.paginate(github.rest.actions.listWorkflowRuns, { - owner, - repo, - workflow_id, - event: 'pull_request', - per_page: 100, - }); - return recentRuns.filter((run) => - run.pull_requests?.some((pullRequest) => pullRequest.number === prNumber)); - } - - async function findReportRun() { - const runs = (await listReportWorkflowRuns()) - .sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at)); - - for (const run of runs) { - if (run.status !== 'completed') continue; - if (run.conclusion !== 'success') { - core.warning(`Frontend bundle report run ${run.id} completed with conclusion: ${run.conclusion}`); - return { done: true, run: null }; - } - - const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { - owner, - repo, - run_id: run.id, - per_page: 100, - }); - const report = artifacts.find((artifact) => artifact.name === artifactName && !artifact.expired); - if (report) return { done: true, run }; - - core.info(`Frontend bundle report run ${run.id} did not produce ${artifactName}.`); - return { done: true, run: null }; - } - - return { done: false, run: null }; - } - - while (Date.now() - startedAt < timeoutMs) { - const { done, run } = await findReportRun(); - if (run) { - core.info(`Found frontend bundle report on workflow run ${run.id}.`); - core.setOutput('run-id', String(run.id)); - return; - } - if (done) { - return; - } - - core.info('Waiting for frontend bundle report artifact...'); - await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); - } - - core.warning(`Timed out waiting for ${artifactName} from ${workflow_id} for ${headSha}.`); - - - name: Find bundle report artifact - if: github.event_name == 'workflow_run' - id: find-report-artifact - uses: actions/github-script@v9 - with: - script: | - const artifactName = 'frontend-bundle-report'; - const { owner, repo } = context.repo; - const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { - owner, - repo, - run_id: context.payload.workflow_run.id, - per_page: 100, - }); - const report = artifacts.find((artifact) => artifact.name === artifactName && !artifact.expired); - if (report) { - core.setOutput('exists', 'true'); - } else { - core.info(`Workflow run ${context.payload.workflow_run.id} did not produce ${artifactName}.`); - core.setOutput('exists', 'false'); - } - - - name: Download bundle report from workflow_run - if: github.event_name == 'workflow_run' && steps.find-report-artifact.outputs.exists == 'true' - uses: actions/download-artifact@v8 - with: - name: frontend-bundle-report - path: ${{ runner.temp }}/frontend-bundle-report - github-token: ${{ github.token }} - repository: ${{ github.repository }} - run-id: ${{ github.event.workflow_run.id }} - - - name: Download bundle report from pull_request_target - if: github.event_name == 'pull_request_target' && steps.find-report-run.outputs.run-id != '' - uses: actions/download-artifact@v8 - with: - name: frontend-bundle-report - path: ${{ runner.temp }}/frontend-bundle-report - github-token: ${{ github.token }} - repository: ${{ github.repository }} - run-id: ${{ steps.find-report-run.outputs.run-id }} - - - name: Comment on pull request - if: (github.event_name == 'workflow_run' && steps.find-report-artifact.outputs.exists == 'true') || steps.find-report-run.outputs.run-id != '' - uses: actions/github-script@v9 - with: - github-token: ${{ secrets.FRONTEND_BUNDLE_REPORT_COMMENT_TOKEN || secrets.FRONTEND_JS_SIZE_COMMENT_TOKEN || secrets.FRONTEND_BUNDLE_VISUALIZER_COMMENT_TOKEN || github.token }} - script: | - const fs = require('node:fs'); - const path = require('node:path'); - - const jsSizeMarker = ''; - const visualizerMarker = ''; - const reportMarkers = [jsSizeMarker, visualizerMarker]; - const reportDir = path.join(process.env.RUNNER_TEMP, 'frontend-bundle-report'); - const jsSizeReportPath = path.join(reportDir, 'frontend-js-size-report.md'); - const prNumberPath = path.join(reportDir, 'pr-number.txt'); - const headShaPath = path.join(reportDir, 'head-sha.txt'); - const workflowRun = context.payload.workflow_run; - const pullRequest = context.payload.pull_request; - const eventHeadSha = workflowRun?.head_sha ?? pullRequest?.head?.sha ?? null; - const { owner, repo } = context.repo; - - if (!fs.existsSync(jsSizeReportPath)) { - core.setFailed('The frontend bundle report artifact does not contain frontend-js-size-report.md.'); - return; - } - - const artifactHeadSha = fs.existsSync(headShaPath) - ? fs.readFileSync(headShaPath, 'utf8').trim() - : null; - if (eventHeadSha != null && artifactHeadSha != null && artifactHeadSha !== eventHeadSha) { - core.info(`The artifact head SHA (${artifactHeadSha}) differs from the event head SHA (${eventHeadSha}). Using artifact metadata for PR validation.`); - } - const reportHeadSha = artifactHeadSha ?? eventHeadSha; - - const artifactPrNumber = fs.existsSync(prNumberPath) - ? Number(fs.readFileSync(prNumberPath, 'utf8').trim()) - : null; - let issue_number = null; - if (pullRequest != null) { - issue_number = pullRequest.number; - if (Number.isInteger(artifactPrNumber) && artifactPrNumber !== issue_number) { - core.setFailed(`The artifact pull request number (${artifactPrNumber}) does not match the event pull request number (${issue_number}).`); - return; - } - } else if (workflowRun != null) { - const associatedPullRequests = new Map(); - for (const pullRequest of workflowRun.pull_requests ?? []) { - if (Number.isInteger(pullRequest.number)) { - associatedPullRequests.set(pullRequest.number, pullRequest); - } - } - - if (reportHeadSha != null) { - const pullRequestsForCommit = await github.paginate(github.rest.repos.listPullRequestsAssociatedWithCommit, { - owner, - repo, - commit_sha: reportHeadSha, - per_page: 100, - }); - for (const pullRequest of pullRequestsForCommit) { - associatedPullRequests.set(pullRequest.number, pullRequest); - } - } - - if (Number.isInteger(artifactPrNumber) && associatedPullRequests.has(artifactPrNumber)) { - issue_number = artifactPrNumber; - } else if (Number.isInteger(artifactPrNumber) && associatedPullRequests.size === 0) { - issue_number = artifactPrNumber; - } else if (!Number.isInteger(artifactPrNumber) && associatedPullRequests.size === 1) { - issue_number = [...associatedPullRequests.keys()][0]; - } else if (Number.isInteger(artifactPrNumber)) { - core.setFailed(`The artifact pull request number (${artifactPrNumber}) is not associated with ${reportHeadSha}.`); - return; - } else { - core.setFailed(`Could not determine the pull request associated with ${reportHeadSha}.`); - return; - } - } else { - core.setFailed('Could not determine the pull request event for this report.'); - return; - } - - const currentPullRequest = await github.rest.pulls.get({ - owner, - repo, - pull_number: issue_number, - }); - const currentHeadSha = currentPullRequest.data.head?.sha; - if (reportHeadSha != null && currentHeadSha != null && reportHeadSha !== currentHeadSha) { - core.info(`The report head SHA (${reportHeadSha}) is not the current pull request head SHA (${currentHeadSha}). Skipping stale frontend bundle report.`); - return; - } - - const jsSizeReport = fs.readFileSync(jsSizeReportPath, 'utf8').trim(); - if (!jsSizeReport.includes(jsSizeMarker)) { - core.setFailed('The frontend JS size report is missing the expected marker.'); - return; - } - let body = `${jsSizeReport}\n`; - - const maxCommentLength = 65_000; - if (body.length > maxCommentLength) { - const reportLocation = workflowRun?.html_url != null - ? `[workflow run](${workflowRun.html_url})` - : 'workflow artifact'; - const footer = [ - '', - '', - `_Report truncated because it exceeded ${maxCommentLength.toLocaleString('en-US')} characters. See the ${reportLocation} for the full report._`, - ].join('\n'); - body = `${body.slice(0, maxCommentLength - footer.length)}${footer}`; - } - - const comments = await github.paginate(github.rest.issues.listComments, { - owner, - repo, - issue_number, - per_page: 100, - }); - const previousReports = comments.filter((comment) => - comment.user?.type === 'Bot' && reportMarkers.some((reportMarker) => comment.body?.includes(reportMarker))); - - if (previousReports.length > 0) { - const [previous, ...duplicates] = previousReports; - await github.rest.issues.updateComment({ - owner, - repo, - comment_id: previous.id, - body, - }); - for (const duplicate of duplicates) { - await github.rest.issues.deleteComment({ - owner, - repo, - comment_id: duplicate.id, - }); - } - } else { - await github.rest.issues.createComment({ - owner, - repo, - issue_number, - body, - }); - } diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 63f8684c33..63ae5c7397 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -18,9 +18,6 @@ on: - packages/misskey-reversi/** - packages/shared/eslint.config.js - scripts/check-dts*.mjs - - .github/scripts/frontend-js-size*.mts - - .github/scripts/memory-stability-util*.mts - - .github/scripts/utility.mts - .github/workflows/lint.yml - package.json pull_request: @@ -37,9 +34,6 @@ on: - packages/misskey-reversi/** - packages/shared/eslint.config.js - scripts/check-dts*.mjs - - .github/scripts/frontend-js-size*.mts - - .github/scripts/memory-stability-util*.mts - - .github/scripts/utility.mts - .github/workflows/lint.yml - package.json jobs: @@ -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 From 68193dd38c4ad77473055e26bc735b32337544fc Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:46:59 +0900 Subject: [PATCH 082/168] refacotr(gh): refactor --- .github/workflows/frontend-bundle-diagnostics.report.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/frontend-bundle-diagnostics.report.yml b/.github/workflows/frontend-bundle-diagnostics.report.yml index 260217ee53..be63dd4575 100644 --- a/.github/workflows/frontend-bundle-diagnostics.report.yml +++ b/.github/workflows/frontend-bundle-diagnostics.report.yml @@ -1,9 +1,9 @@ -name: frontend-bundle-report-comment +name: Frontend bundle diagnostics (report) on: workflow_run: workflows: - - frontend-bundle-report + - Frontend bundle diagnostics (inspect) types: - completed pull_request_target: From b028d4ad5bbe132446e03022e66deefd624aa93c Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:53:36 +0900 Subject: [PATCH 083/168] refacotr(gh): rename some files --- ...diagnostics.report.yml => backend-diagnostics.comment.yml} | 2 +- .github/workflows/backend-diagnostics.inspect.yml | 4 ++-- ...cs.report.yml => frontend-browser-diagnostics.comment.yml} | 2 +- .github/workflows/frontend-browser-diagnostics.inspect.yml | 2 +- ...ics.report.yml => frontend-bundle-diagnostics.comment.yml} | 4 ++-- .github/workflows/frontend-bundle-diagnostics.inspect.yml | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) rename .github/workflows/{backend-diagnostics.report.yml => backend-diagnostics.comment.yml} (98%) rename .github/workflows/{frontend-browser-diagnostics.report.yml => frontend-browser-diagnostics.comment.yml} (96%) rename .github/workflows/{frontend-bundle-diagnostics.report.yml => frontend-bundle-diagnostics.comment.yml} (98%) diff --git a/.github/workflows/backend-diagnostics.report.yml b/.github/workflows/backend-diagnostics.comment.yml similarity index 98% rename from .github/workflows/backend-diagnostics.report.yml rename to .github/workflows/backend-diagnostics.comment.yml index f358216a62..4aa7eef68d 100644 --- a/.github/workflows/backend-diagnostics.report.yml +++ b/.github/workflows/backend-diagnostics.comment.yml @@ -1,4 +1,4 @@ -name: Backend diagnostics (report) +name: Backend diagnostics (comment) on: workflow_run: diff --git a/.github/workflows/backend-diagnostics.inspect.yml b/.github/workflows/backend-diagnostics.inspect.yml index 1474c78f0c..d5a47b804f 100644 --- a/.github/workflows/backend-diagnostics.inspect.yml +++ b/.github/workflows/backend-diagnostics.inspect.yml @@ -1,4 +1,4 @@ -# this name is used in backend-diagnostics.report.yml so be careful when change name +# this name is used in backend-diagnostics.comment.yml so be careful when change name name: Backend diagnostics (inspect) on: @@ -14,7 +14,7 @@ on: - .github/scripts/backend-diagnostics.inspect.mts - .github/scripts/memory-stability-util*.mts - .github/workflows/backend-diagnostics.inspect.yml - - .github/workflows/backend-diagnostics.report.yml + - .github/workflows/backend-diagnostics.comment.yml jobs: get-memory-usage: diff --git a/.github/workflows/frontend-browser-diagnostics.report.yml b/.github/workflows/frontend-browser-diagnostics.comment.yml similarity index 96% rename from .github/workflows/frontend-browser-diagnostics.report.yml rename to .github/workflows/frontend-browser-diagnostics.comment.yml index 7edc9f36c6..6b2f2b5a56 100644 --- a/.github/workflows/frontend-browser-diagnostics.report.yml +++ b/.github/workflows/frontend-browser-diagnostics.comment.yml @@ -1,4 +1,4 @@ -name: Frontend browser diagnostics (report) +name: Frontend browser diagnostics (comment) on: workflow_run: diff --git a/.github/workflows/frontend-browser-diagnostics.inspect.yml b/.github/workflows/frontend-browser-diagnostics.inspect.yml index 76af11bec6..63b43a05ae 100644 --- a/.github/workflows/frontend-browser-diagnostics.inspect.yml +++ b/.github/workflows/frontend-browser-diagnostics.inspect.yml @@ -29,7 +29,7 @@ on: - .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.report.yml + - .github/workflows/frontend-browser-diagnostics.comment.yml permissions: contents: read diff --git a/.github/workflows/frontend-bundle-diagnostics.report.yml b/.github/workflows/frontend-bundle-diagnostics.comment.yml similarity index 98% rename from .github/workflows/frontend-bundle-diagnostics.report.yml rename to .github/workflows/frontend-bundle-diagnostics.comment.yml index be63dd4575..ecc6f1dcfa 100644 --- a/.github/workflows/frontend-bundle-diagnostics.report.yml +++ b/.github/workflows/frontend-bundle-diagnostics.comment.yml @@ -1,4 +1,4 @@ -name: Frontend bundle diagnostics (report) +name: Frontend bundle diagnostics (comment) on: workflow_run: @@ -28,7 +28,7 @@ on: - .github/scripts/utility.mts - .github/scripts/frontend-bundle-diagnostics.render-md.mts - .github/workflows/frontend-bundle-diagnostics.inspect.yml - - .github/workflows/frontend-bundle-diagnostics.report.yml + - .github/workflows/frontend-bundle-diagnostics.comment.yml permissions: actions: read diff --git a/.github/workflows/frontend-bundle-diagnostics.inspect.yml b/.github/workflows/frontend-bundle-diagnostics.inspect.yml index 31faaed79b..3572f77ea8 100644 --- a/.github/workflows/frontend-bundle-diagnostics.inspect.yml +++ b/.github/workflows/frontend-bundle-diagnostics.inspect.yml @@ -23,7 +23,7 @@ on: - .github/scripts/utility.mts - .github/scripts/frontend-bundle-diagnostics.render-md.mts - .github/workflows/frontend-bundle-diagnostics.inspect.yml - - .github/workflows/frontend-bundle-diagnostics.report.yml + - .github/workflows/frontend-bundle-diagnostics.comment.yml permissions: contents: read From ceb31f16c842646bdaedcee0e5a2648663c54971 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:58:01 +0900 Subject: [PATCH 084/168] refacotr(gh): refactor --- .github/workflows/frontend-browser-diagnostics.comment.yml | 2 +- .github/workflows/frontend-bundle-diagnostics.comment.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/frontend-browser-diagnostics.comment.yml b/.github/workflows/frontend-browser-diagnostics.comment.yml index 6b2f2b5a56..5098e618ad 100644 --- a/.github/workflows/frontend-browser-diagnostics.comment.yml +++ b/.github/workflows/frontend-browser-diagnostics.comment.yml @@ -40,5 +40,5 @@ jobs: uses: thollander/actions-comment-pull-request@v3 with: pr-number: ${{ steps.load-pr-number.outputs.pr-number }} - comment-tag: frontend_browser_diagnostics + comment-tag: frontend-browser-diagnostics file-path: ${{ runner.temp }}/frontend-browser-diagnostics/frontend-browser-diagnostics.md diff --git a/.github/workflows/frontend-bundle-diagnostics.comment.yml b/.github/workflows/frontend-bundle-diagnostics.comment.yml index ecc6f1dcfa..3da6d0dcbc 100644 --- a/.github/workflows/frontend-bundle-diagnostics.comment.yml +++ b/.github/workflows/frontend-bundle-diagnostics.comment.yml @@ -175,5 +175,5 @@ jobs: uses: thollander/actions-comment-pull-request@v3 with: pr-number: ${{ steps.load-pr-number.outputs.pr-number }} - comment-tag: frontend_bundle_diagnostics + comment-tag: frontend-bundle-diagnostics file-path: ${{ runner.temp }}/frontend-bundle-report/frontend-bundle-diagnostics-report.md From 5d0ecf976d21a96610984abd6dd98ac15a270623 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Thu, 16 Jul 2026 22:12:15 +0900 Subject: [PATCH 085/168] refacotr(gh): refactor --- .../frontend-bundle-diagnostics.comment.yml | 149 +----------------- 1 file changed, 7 insertions(+), 142 deletions(-) diff --git a/.github/workflows/frontend-bundle-diagnostics.comment.yml b/.github/workflows/frontend-bundle-diagnostics.comment.yml index 3da6d0dcbc..709c735e69 100644 --- a/.github/workflows/frontend-bundle-diagnostics.comment.yml +++ b/.github/workflows/frontend-bundle-diagnostics.comment.yml @@ -6,29 +6,6 @@ on: - Frontend bundle diagnostics (inspect) 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-bundle-diagnostics.render-md.mts - - .github/workflows/frontend-bundle-diagnostics.inspect.yml - - .github/workflows/frontend-bundle-diagnostics.comment.yml permissions: actions: read @@ -39,120 +16,13 @@ permissions: 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') + 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.pull_request.number || github.event.workflow_run.id }} + group: frontend-bundle-diagnostics-report-${{ 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' + - name: Download bundle report uses: actions/download-artifact@v8 with: name: frontend-bundle-report @@ -161,15 +31,10 @@ jobs: 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: 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 From ab21fc725b71f78a96a22e792f6f4af6376a4723 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Thu, 16 Jul 2026 22:27:32 +0900 Subject: [PATCH 086/168] refacotr(gh): refactor --- .../scripts/frontend-browser-diagnostics.inspect.mts | 6 ++---- .github/workflows/backend-diagnostics.comment.yml | 2 +- .../workflows/frontend-browser-diagnostics.inspect.yml | 10 +++++----- .github/workflows/lint.yml | 4 ++-- 4 files changed, 10 insertions(+), 12 deletions(-) diff --git a/.github/scripts/frontend-browser-diagnostics.inspect.mts b/.github/scripts/frontend-browser-diagnostics.inspect.mts index ec0e08857d..eb3a228840 100644 --- a/.github/scripts/frontend-browser-diagnostics.inspect.mts +++ b/.github/scripts/frontend-browser-diagnostics.inspect.mts @@ -242,7 +242,7 @@ async function genReport(label: 'base' | 'head', repoDir: string, outputPath: st try { server = util.startServer(label, repoDir); - await util.waitForServer(baseUrl, server!); + await util.waitForServer(baseUrl, server); await rm(heapSnapshotWorkDirs[label], { recursive: true, force: true }); await mkdir(heapSnapshotWorkDirs[label], { recursive: true }); @@ -261,9 +261,7 @@ async function genReport(label: 'base' | 'head', repoDir: string, outputPath: st await writeFile(outputPath, JSON.stringify(report, null, '\t')); process.stderr.write(`[${label}] Wrote browser metrics report to ${outputPath}\n`); - if (heapSnapshotSavePath != null) { - await saveRepresentativeHeapSnapshot(label, report, heapSnapshotSavePath); - } + await saveRepresentativeHeapSnapshot(label, report, heapSnapshotSavePath); } finally { if (server != null) await util.stopServer(server); } diff --git a/.github/workflows/backend-diagnostics.comment.yml b/.github/workflows/backend-diagnostics.comment.yml index 4aa7eef68d..a19166a319 100644 --- a/.github/workflows/backend-diagnostics.comment.yml +++ b/.github/workflows/backend-diagnostics.comment.yml @@ -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; diff --git a/.github/workflows/frontend-browser-diagnostics.inspect.yml b/.github/workflows/frontend-browser-diagnostics.inspect.yml index 63b43a05ae..4841cb3822 100644 --- a/.github/workflows/frontend-browser-diagnostics.inspect.yml +++ b/.github/workflows/frontend-browser-diagnostics.inspect.yml @@ -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 @@ -133,7 +133,7 @@ jobs: id: upload-browser-base-heap-snapshot uses: actions/upload-artifact@v7 with: - path: base-heap-snapshot.heapsnapshot + path: ${{ runner.temp }}/frontend-browser-diagnostics/base-heap-snapshot.heapsnapshot archive: false if-no-files-found: error retention-days: 7 @@ -142,7 +142,7 @@ jobs: id: upload-browser-head-heap-snapshot uses: actions/upload-artifact@v7 with: - path: head-heap-snapshot.heapsnapshot + path: ${{ runner.temp }}/frontend-browser-diagnostics/head-heap-snapshot.heapsnapshot archive: false if-no-files-found: error retention-days: 7 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 63ae5c7397..0e77f896a2 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -123,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' From 0272ee73eba065f4db873c7c602a877fbb720a25 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Thu, 16 Jul 2026 22:40:45 +0900 Subject: [PATCH 087/168] refacotr(gh): refactor --- .../frontend-bundle-diagnostics.render-md.mts | 2 +- .../frontend-bundle-diagnostics.inspect.yml | 26 ------------------- 2 files changed, 1 insertion(+), 27 deletions(-) diff --git a/.github/scripts/frontend-bundle-diagnostics.render-md.mts b/.github/scripts/frontend-bundle-diagnostics.render-md.mts index 7ec0cfc61d..1e8cf37f24 100644 --- a/.github/scripts/frontend-bundle-diagnostics.render-md.mts +++ b/.github/scripts/frontend-bundle-diagnostics.render-md.mts @@ -9,7 +9,7 @@ import * as util from './utility.mts'; const marker = ''; -const locale = process.env.FRONTEND_JS_SIZE_LOCALE ?? 'ja-JP'; +const locale = 'ja-JP'; //function sharePercent(value, total) { // if (total === 0) return '0%'; diff --git a/.github/workflows/frontend-bundle-diagnostics.inspect.yml b/.github/workflows/frontend-bundle-diagnostics.inspect.yml index 3572f77ea8..6aa4f54fb4 100644 --- a/.github/workflows/frontend-bundle-diagnostics.inspect.yml +++ b/.github/workflows/frontend-bundle-diagnostics.inspect.yml @@ -37,8 +37,6 @@ 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 }} @@ -152,7 +128,6 @@ jobs: 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" @@ -161,7 +136,6 @@ jobs: 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 From 88c6cd1766b176821cebf996b76085840591ee12 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Thu, 16 Jul 2026 23:19:25 +0900 Subject: [PATCH 088/168] refactor(gh): unify heap snapshot output paths --- .../frontend-browser-diagnostics.inspect.mts | 18 +++++++++++------- .../frontend-browser-diagnostics.inspect.yml | 6 +++--- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/.github/scripts/frontend-browser-diagnostics.inspect.mts b/.github/scripts/frontend-browser-diagnostics.inspect.mts index eb3a228840..1f0d74875f 100644 --- a/.github/scripts/frontend-browser-diagnostics.inspect.mts +++ b/.github/scripts/frontend-browser-diagnostics.inspect.mts @@ -11,7 +11,7 @@ import { HeadlessChromeController, summarizeBrowserDiagnostics, summarizeNetwork 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, baseHeapSnapshotOutputArg, 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); @@ -20,6 +20,10 @@ 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; @@ -230,14 +234,14 @@ function heapSnapshotPath(label: 'base' | 'head', round: number) { return join(heapSnapshotWorkDirs[label], `round-${round}.heapsnapshot`); } -async function saveRepresentativeHeapSnapshot(label: 'base' | 'head', report: BrowserMetricsReport, outputPath: string) { +async function saveRepresentativeHeapSnapshot(label: 'base' | 'head', report: BrowserMetricsReport) { const representative = selectRepresentativeSample(report.samples, sample => sample.heapSnapshot.categories.total); - await copyFile(heapSnapshotPath(label, representative.round), outputPath); + 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 genReport(label: 'base' | 'head', repoDir: string, outputPath: string, heapSnapshotSavePath: string) { +async function genReport(label: 'base' | 'head', repoDir: string, outputPath: string) { let server: ReturnType | null = null; try { @@ -261,15 +265,15 @@ async function genReport(label: 'base' | 'head', repoDir: string, outputPath: st await writeFile(outputPath, JSON.stringify(report, null, '\t')); process.stderr.write(`[${label}] Wrote browser metrics report to ${outputPath}\n`); - await saveRepresentativeHeapSnapshot(label, report, heapSnapshotSavePath); + await saveRepresentativeHeapSnapshot(label, report); } finally { if (server != null) await util.stopServer(server); } } async function main() { - await genReport('base', resolve(baseDirArg), resolve(baseOutputArg), resolve(baseHeapSnapshotOutputArg)); - await genReport('head', resolve(headDirArg), resolve(headOutputArg), resolve(headHeapSnapshotOutputArg)); + await genReport('base', resolve(baseDirArg), resolve(baseOutputArg)); + await genReport('head', resolve(headDirArg), resolve(headOutputArg)); } await main(); diff --git a/.github/workflows/frontend-browser-diagnostics.inspect.yml b/.github/workflows/frontend-browser-diagnostics.inspect.yml index 4841cb3822..92f645ab26 100644 --- a/.github/workflows/frontend-browser-diagnostics.inspect.yml +++ b/.github/workflows/frontend-browser-diagnostics.inspect.yml @@ -127,13 +127,13 @@ jobs: run: | REPORT_DIR="$RUNNER_TEMP/frontend-browser-diagnostics" mkdir -p "$REPORT_DIR" - node after/.github/scripts/frontend-browser-diagnostics.inspect.mts before after "$REPORT_DIR/before-browser.json" "$REPORT_DIR/after-browser.json" "$REPORT_DIR/base-heap-snapshot.heapsnapshot" "$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: ${{ runner.temp }}/frontend-browser-diagnostics/base-heap-snapshot.heapsnapshot + path: base-heap-snapshot.heapsnapshot archive: false if-no-files-found: error retention-days: 7 @@ -142,7 +142,7 @@ jobs: id: upload-browser-head-heap-snapshot uses: actions/upload-artifact@v7 with: - path: ${{ runner.temp }}/frontend-browser-diagnostics/head-heap-snapshot.heapsnapshot + path: head-heap-snapshot.heapsnapshot archive: false if-no-files-found: error retention-days: 7 From 3760e7a9f85538aeed3015acf0e4f42b4078666e Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:05:21 +0900 Subject: [PATCH 089/168] chore(gh): fix workflow name --- .github/workflows/locale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/locale.yml b/.github/workflows/locale.yml index 000cfed91d..d87f18827e 100644 --- a/.github/workflows/locale.yml +++ b/.github/workflows/locale.yml @@ -1,4 +1,4 @@ -name: Lint +name: Locale on: push: From 3de6b610b54386dbcaec0209648ce3612890be7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=8A=E3=81=95=E3=82=80=E3=81=AE=E3=81=B2=E3=81=A8?= <46447427+samunohito@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:45:26 +0900 Subject: [PATCH 090/168] =?UTF-8?q?enhance:=20JSON=E5=BD=A2=E5=BC=8F?= =?UTF-8?q?=E3=81=AE=E6=A7=8B=E9=80=A0=E5=8C=96=E3=83=AD=E3=82=B0=E5=87=BA?= =?UTF-8?q?=E5=8A=9B=E3=81=AB=E5=AF=BE=E5=BF=9C=20(#17728)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * enhance: JSON形式の構造化ログ出力に対応 * 起動時ログがJSON化出来ていなかったのを調整 --------- Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com> --- .config/docker_example.yml | 2 + .config/example.yml | 2 + CHANGELOG.md | 1 + packages/backend/src/boot/entry.ts | 25 ++---- packages/backend/src/boot/master.ts | 20 ++++- .../backend/src/boot/process-error-handler.ts | 59 ++++++++++++++ packages/backend/src/boot/worker.ts | 1 + packages/backend/src/config.ts | 4 +- .../backend/src/logging/JsonConsoleBackend.ts | 71 +++++++++++++++++ .../backend/src/logging/logging-runtime.ts | 22 ++++-- packages/backend/src/logging/types.ts | 3 + .../test/unit/boot/process-error-handler.ts | 72 ++++++++++++++++++ .../test/unit/logging/JsonConsoleBackend.ts | 76 +++++++++++++++++++ .../test/unit/logging/logging-runtime.ts | 40 ++++++++++ 14 files changed, 368 insertions(+), 30 deletions(-) create mode 100644 packages/backend/src/boot/process-error-handler.ts create mode 100644 packages/backend/src/logging/JsonConsoleBackend.ts create mode 100644 packages/backend/test/unit/boot/process-error-handler.ts create mode 100644 packages/backend/test/unit/logging/JsonConsoleBackend.ts create mode 100644 packages/backend/test/unit/logging/logging-runtime.ts diff --git a/.config/docker_example.yml b/.config/docker_example.yml index bc2549d5cf..4e10f771b0 100644 --- a/.config/docker_example.yml +++ b/.config/docker_example.yml @@ -241,6 +241,8 @@ proxyBypassHosts: # Log settings # logging: +# # ログの出力形式。「json」で1行JSON、未指定時は「pretty」です。 +# format: pretty # # Minimum level for all loggers. Defaults to info in production and debug otherwise. # level: info # # The longest matching logger domain takes precedence over the global level. diff --git a/.config/example.yml b/.config/example.yml index 961417cb30..06a0ed0651 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -474,6 +474,8 @@ proxyBypassHosts: # Log settings # logging: +# # ログの出力形式。「json」で1行JSON、未指定時は「pretty」です。 +# format: pretty # # Minimum level for all loggers. Defaults to info in production and debug otherwise. # level: info # # The longest matching logger domain takes precedence over the global level. diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fc991d0a1..f7ff08304d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ - Enhance: URLプレビューの結果を内部でキャッシュするように - Enhance: API内部エラーのログに構造化属性と正規化したエラー情報を付与し、認証情報を自動的に秘匿するように(従来形式の表示は維持) - Enhance: ログ全体の既定levelとlogger domainごとの出力levelを設定できるように +- Enhance: バックエンドのログを1行JSON形式で出力できるように - Fix: `/stats` API のレスポンス型が正しくない問題を修正 - Fix: ハッシュタグに関連するデータを更新する際のエラーハンドリングを修正 - Fix: Sentry 使用環境下にて、Misskey が発行した SQL クエリが span に含まれない問題を修正 diff --git a/packages/backend/src/boot/entry.ts b/packages/backend/src/boot/entry.ts index 3c2d56c73c..03e5c395c3 100644 --- a/packages/backend/src/boot/entry.ts +++ b/packages/backend/src/boot/entry.ts @@ -10,10 +10,10 @@ import cluster from 'node:cluster'; import { EventEmitter } from 'node:events'; import { writeHeapSnapshot } from 'node:v8'; -import chalk from 'chalk'; import Xev from 'xev'; import Logger from '@/logger.js'; import { envOption } from '../env.js'; +import { installProcessErrorHandlers } from './process-error-handler.js'; import { isShutdownInProgress } from './shutdown-handler.js'; import { readyRef } from './ready.js'; @@ -28,6 +28,8 @@ const logger = new Logger('core', 'cyan'); const clusterLogger = logger.createSubLogger('cluster', 'orange'); const ev = new Xev(); +installProcessErrorHandlers({ logger, quiet: envOption.quiet }); + //#region Events // Listen new workers @@ -47,25 +49,11 @@ cluster.on('exit', worker => { return; } - // Replace the dead worker, - // we're not sentimental - clusterLogger.error(chalk.red(`[${worker.id}] died :(`)); + // 終了したワーカーは従来どおり再生成し、表示色は出力処理へ任せます。 + clusterLogger.error(`[${worker.id}] died :(`); cluster.fork(); }); -// Display detail of unhandled promise rejection -if (!envOption.quiet) { - process.on('unhandledRejection', console.dir); -} - -// Display detail of uncaught exception -process.on('uncaughtException', err => { - try { - logger.error(err); - console.trace(err); - } catch { } -}); - // Dying away... process.on('exit', code => { if (isShutdownInProgress()) return; @@ -76,12 +64,10 @@ process.on('exit', code => { if (!envOption.disableClustering) { if (cluster.isPrimary) { - logger.info(`Start main process... pid: ${process.pid}`); const { masterMain } = await import('./master.js'); await masterMain(); ev.mount(); } else if (cluster.isWorker) { - logger.info(`Start worker process... pid: ${process.pid}`); const { workerMain } = await import('./worker.js'); await workerMain(); } else { @@ -89,7 +75,6 @@ if (!envOption.disableClustering) { } } else { // 非clusterの場合はMasterのみが起動するため、Workerの処理は行わない(cluster.isWorker === trueの状態でこのブロックに来ることはない) - logger.info(`Start main process... pid: ${process.pid}`); const { masterMain } = await import('./master.js'); await masterMain(); ev.mount(); diff --git a/packages/backend/src/boot/master.ts b/packages/backend/src/boot/master.ts index 4f78e602d0..3cb340e963 100644 --- a/packages/backend/src/boot/master.ts +++ b/packages/backend/src/boot/master.ts @@ -12,6 +12,7 @@ import Logger from '@/logger.js'; import { loadConfig } from '@/config.js'; import type { Config } from '@/config.js'; import { configureLogging, shutdownLogging } from '@/logging/logging-runtime.js'; +import type { LogFormat } from '@/logging/types.js'; import { showMachineInfo } from '@/misc/show-machine-info.js'; import { envOption } from '@/env.js'; import { initTelemetry, shutdownTelemetry } from '@/core/telemetry/telemetry-registry.js'; @@ -23,7 +24,17 @@ const bootLogger = logger.createSubLogger('boot', 'magenta'); const themeColor = chalk.hex('#86b300'); -function greet(props: { version: string }) { +/** 起動時の案内を、選択されたログ形式に合わせて出力します。 */ +function greet(props: { version: string; format: LogFormat }) { + if (!envOption.quiet && props.format === 'json') { + // JSONモードでは生のコンソール出力を避け、各案内を1件ずつ構造化ログにします。 + bootLogger.info('Welcome to Misskey!'); + bootLogger.info(`Misskey v${props.version}`, null, true); + bootLogger.info('Misskey is an open-source decentralized microblogging platform.'); + bootLogger.info('If you like Misskey, please consider donating to support dev. https://misskey-hub.net/docs/donate/'); + return; + } + if (!envOption.quiet) { //#region Misskey logo const v = `v${props.version}`; @@ -54,7 +65,9 @@ export async function masterMain() { // initialize app try { config = loadConfigBoot(); - greet({ version: config.version }); + logger.info(`Start main process... pid: ${process.pid}`); + bootLogger.createSubLogger('config').succ('Loaded'); + greet({ version: config.version, format: config.logging?.format ?? 'pretty' }); showEnvironment(); await showMachineInfo(bootLogger); showNodejsVersion(); @@ -136,6 +149,7 @@ function showNodejsVersion(): void { nodejsLogger.info(`Version ${process.version} detected.`); } +/** 設定を読み込み、成功時に後続のログ出力形式を適用します。 */ function loadConfigBoot(): Config { const configLogger = bootLogger.createSubLogger('config'); let config; @@ -154,8 +168,6 @@ function loadConfigBoot(): Config { throw exception; } - configLogger.succ('Loaded'); - return config; } diff --git a/packages/backend/src/boot/process-error-handler.ts b/packages/backend/src/boot/process-error-handler.ts new file mode 100644 index 0000000000..c1224f1923 --- /dev/null +++ b/packages/backend/src/boot/process-error-handler.ts @@ -0,0 +1,59 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { LogWriteInput } from '@/logging/types.js'; + +/** プロセス例外をロガーへ渡すために必要な最小の処理対象です。 */ +export type ProcessErrorHandlerProcess = { + on(event: 'unhandledRejection', listener: (reason: unknown) => void): unknown; + on(event: 'uncaughtException', listener: (error: Error) => void): unknown; +}; + +/** 例外記録に必要なロガーの最小インターフェースです。 */ +export type ProcessErrorHandlerLogger = { + write(input: LogWriteInput): void; +}; + +/** プロセス例外ハンドラーの登録に使う依存関係です。 */ +export type ProcessErrorHandlerOptions = { + readonly process?: ProcessErrorHandlerProcess; + readonly logger: ProcessErrorHandlerLogger; + readonly quiet: boolean; +}; + +/** ログ記録の失敗が別の例外を起こさないよう、プロセス異常を安全に記録します。 */ +function writeProcessError( + logger: ProcessErrorHandlerLogger, + eventName: 'process.unhandled_rejection' | 'process.uncaught_exception', + message: string, + error: unknown, +): void { + try { + // 元の値をerrorへ渡し、JSON形式の出力処理で安全な形へ正規化します。 + logger.write({ + level: 'error', + eventName, + message, + error, + }); + } catch { + // 例外処理中のログ失敗で、さらにプロセスを不安定にしないよう握りつぶします。 + } +} + +/** 未処理のPromise拒否と未捕捉例外を構造化ログへ接続します。 */ +export function installProcessErrorHandlers(options: ProcessErrorHandlerOptions): void { + const processLike: ProcessErrorHandlerProcess = options.process ?? (process as unknown as ProcessErrorHandlerProcess); + + if (!options.quiet) { + processLike.on('unhandledRejection', reason => { + writeProcessError(options.logger, 'process.unhandled_rejection', 'Unhandled promise rejection', reason); + }); + } + + processLike.on('uncaughtException', error => { + writeProcessError(options.logger, 'process.uncaught_exception', 'Uncaught exception', error); + }); +} diff --git a/packages/backend/src/boot/worker.ts b/packages/backend/src/boot/worker.ts index ad15296440..71857f9a52 100644 --- a/packages/backend/src/boot/worker.ts +++ b/packages/backend/src/boot/worker.ts @@ -24,6 +24,7 @@ export async function workerMain() { try { config = loadConfig(); configureLogging(config.logging); + logger.info(`Start worker process... pid: ${process.pid}`); } catch (e) { bootLogger.error(e instanceof Error ? e : new Error(String(e)), null, true); process.exit(1); diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index ab46416b35..9b49a48dd3 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -10,7 +10,7 @@ import { type FastifyServerOptions } from 'fastify'; import type * as Sentry from '@sentry/node'; import type * as SentryVue from '@sentry/vue'; import type { RedisOptions } from 'ioredis'; -import type { LogLevelSetting } from './logging/types.js'; +import type { LogFormat, LogLevelSetting } from './logging/types.js'; type RedisOptionsSource = Partial & { host: string; @@ -133,6 +133,7 @@ type Source = { pidFile: string; logging?: { + format?: LogFormat; level?: LogLevelSetting; domains?: Record | null; sql?: { @@ -197,6 +198,7 @@ export type Config = { deliverJobMaxAttempts: number | undefined; inboxJobMaxAttempts: number | undefined; logging?: { + format?: LogFormat; level?: LogLevelSetting; domains?: Record | null; sql?: { diff --git a/packages/backend/src/logging/JsonConsoleBackend.ts b/packages/backend/src/logging/JsonConsoleBackend.ts new file mode 100644 index 0000000000..a13f6c262e --- /dev/null +++ b/packages/backend/src/logging/JsonConsoleBackend.ts @@ -0,0 +1,71 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { LogBackend } from './LogBackend.js'; +import type { LogRecord } from './types.js'; + +/** JSON形式のログを1行で出力する処理が外部から受け取る依存関係です。 */ +export type JsonConsoleBackendDependencies = { + readonly output: (line: string) => void; +}; + +/** JSON形式で出力する項目を、運用上安定した形として定義します。 */ +type JsonLogRecord = { + readonly timestamp: string; + readonly level: LogRecord['level']; + readonly message: string; + readonly loggerName: string; + readonly eventName?: string; + readonly attributes?: LogRecord['attributes']; + readonly error?: LogRecord['error']; + readonly processId: number; + readonly isPrimary: boolean; + readonly workerId: number | null; +}; + +const defaultDependencies: JsonConsoleBackendDependencies = { + output: line => console.log(line), +}; + +/** LogRecordからJSONへ出す項目だけを選び、内部情報を誤って含めないようにします。 */ +function createJsonLogRecord(record: LogRecord): JsonLogRecord { + // 色や旧APIの生データは表示専用の情報なので、機械向け形式へは持ち込みません。 + return { + timestamp: record.timestamp, + level: record.level, + message: record.message, + loggerName: record.loggerName, + // 任意項目は値がある場合だけ含め、空の項目を増やさないようにします。 + ...(record.eventName != null ? { eventName: record.eventName } : {}), + ...(record.attributes != null ? { attributes: record.attributes } : {}), + ...(record.error != null ? { error: record.error } : {}), + // 実行主体の情報は常に出し、ログを横断して検索できる形を保ちます。 + processId: record.processId, + isPrimary: record.isPrimary, + workerId: record.workerId, + }; +} + +/** + * LogRecordを1行のJSONへ変換し、ログ収集基盤が扱える標準出力へ渡します。 + * LoggerやLogManagerから出力形式を切り離し、Pretty形式と同じ記録を共有します。 + */ +export class JsonConsoleBackend implements LogBackend { + private readonly dependencies: JsonConsoleBackendDependencies; + + /** 出力処理を受け取り、テストや起動環境ごとに出力先を差し替えます。 */ + constructor(dependencies: Partial = {}) { + this.dependencies = { + ...defaultDependencies, + ...dependencies, + }; + } + + /** 1件のログをJSON文字列へ変換し、改行を含まない1回の出力として渡します。 */ + public write(record: LogRecord): void { + // JSON.stringifyが改行などをエスケープするため、1ログ1行の契約を保てます。 + this.dependencies.output(JSON.stringify(createJsonLogRecord(record))); + } +} diff --git a/packages/backend/src/logging/logging-runtime.ts b/packages/backend/src/logging/logging-runtime.ts index e4f97f56e6..3d842b31c7 100644 --- a/packages/backend/src/logging/logging-runtime.ts +++ b/packages/backend/src/logging/logging-runtime.ts @@ -5,8 +5,11 @@ import { LogManager } from './LogManager.js'; import { BootstrapConsoleBackend } from './BootstrapConsoleBackend.js'; +import { JsonConsoleBackend } from './JsonConsoleBackend.js'; import { PrettyConsoleBackend } from './PrettyConsoleBackend.js'; import type { LogManagerConfiguration } from './LogManager.js'; +import type { LogBackend } from './LogBackend.js'; +import type { LogFormat } from './types.js'; /** * プロセス内のすべてのLoggerが共有するLogManagerです。 @@ -14,13 +17,22 @@ import type { LogManagerConfiguration } from './LogManager.js'; */ export const logManager = new LogManager(new BootstrapConsoleBackend()); -/** 設定読込後のlogging設定とPretty backendを適用します。 */ -export function configureLogging(configuration?: LogManagerConfiguration): void { - logManager.configure(configuration); - logManager.setBackend(new PrettyConsoleBackend()); +/** ログ形式を検証し、指定された形式に対応する出力処理を作成します。 */ +function createLoggingBackend(format: unknown): LogBackend { + if (format == null || format === 'pretty') return new PrettyConsoleBackend(); + if (format === 'json') return new JsonConsoleBackend(); + throw new Error('logging.format must be either pretty or json'); } -/** プロセス終了前に現在のlogging backendをflushして閉じます。 */ +/** 起動時のログ設定を適用し、選択した出力処理へ切り替えます。 */ +export function configureLogging(configuration?: LogManagerConfiguration & { readonly format?: LogFormat }): void { + // 出力処理を先に検証し、設定値が不正な場合は現在の出力処理を壊さないようにします。 + const backend = createLoggingBackend(configuration?.format); + logManager.configure(configuration); + logManager.setBackend(backend); +} + +/** プロセス終了前に現在のログ出力処理を保留分まで書き出して閉じます。 */ export function shutdownLogging(): Promise { return logManager.shutdown(); } diff --git a/packages/backend/src/logging/types.ts b/packages/backend/src/logging/types.ts index 1993cd1809..1861681840 100644 --- a/packages/backend/src/logging/types.ts +++ b/packages/backend/src/logging/types.ts @@ -11,6 +11,9 @@ export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal'; /** 設定で指定できるログの閾値です。`off`はログイベントのlevelには使用しません。 */ export type LogLevelSetting = LogLevel | 'off'; +/** コンソールへ出すログ形式です。未指定時は見やすい形式を使用します。 */ +export type LogFormat = 'pretty' | 'json'; + /** 正規化後にログ属性として扱えるJSONの値です。 */ export type LogAttributeValue = | string diff --git a/packages/backend/test/unit/boot/process-error-handler.ts b/packages/backend/test/unit/boot/process-error-handler.ts new file mode 100644 index 0000000000..32e341cc48 --- /dev/null +++ b/packages/backend/test/unit/boot/process-error-handler.ts @@ -0,0 +1,72 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, test, vi } from 'vitest'; +import type { LogWriteInput } from '@/logging/types.js'; +import { installProcessErrorHandlers } from '@/boot/process-error-handler.js'; + +type ProcessListener = (...args: unknown[]) => void; + +/** テスト用に、登録されたプロセスイベントを呼び出せる処理対象を作成します。 */ +function createProcessLike(): { + readonly process: { on: (event: string, listener: ProcessListener) => void }; + readonly listeners: Map; +} { + const listeners = new Map(); + return { + process: { + on: (event, listener) => listeners.set(event, listener), + }, + listeners, + }; +} + +describe('installProcessErrorHandlers', () => { + test('records unhandled rejections and uncaught exceptions as structured errors', () => { + const { process, listeners } = createProcessLike(); + const write = vi.fn<(input: LogWriteInput) => void>(); + const rejection = new Error('rejected'); + const exception = new TypeError('uncaught'); + + installProcessErrorHandlers({ process: process as never, logger: { write }, quiet: false }); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + listeners.get('unhandledRejection')!(rejection); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + listeners.get('uncaughtException')!(exception); + + expect(write).toHaveBeenNthCalledWith(1, { + level: 'error', + eventName: 'process.unhandled_rejection', + message: 'Unhandled promise rejection', + error: rejection, + }); + expect(write).toHaveBeenNthCalledWith(2, { + level: 'error', + eventName: 'process.uncaught_exception', + message: 'Uncaught exception', + error: exception, + }); + }); + + test('does not register the unhandled rejection handler in quiet mode', () => { + const { process, listeners } = createProcessLike(); + + installProcessErrorHandlers({ process: process as never, logger: { write: vi.fn() }, quiet: true }); + + expect([...listeners.keys()]).toEqual(['uncaughtException']); + }); + + test('does not rethrow when the logger fails', () => { + const { process, listeners } = createProcessLike(); + const write = vi.fn(() => { + throw new Error('logger failed'); + }); + + installProcessErrorHandlers({ process: process as never, logger: { write }, quiet: false }); + + expect(() => listeners.get('unhandledRejection')!(new Error('rejected'))).not.toThrow(); + expect(() => listeners.get('uncaughtException')!(new Error('uncaught'))).not.toThrow(); + }); +}); diff --git a/packages/backend/test/unit/logging/JsonConsoleBackend.ts b/packages/backend/test/unit/logging/JsonConsoleBackend.ts new file mode 100644 index 0000000000..b7204bdd64 --- /dev/null +++ b/packages/backend/test/unit/logging/JsonConsoleBackend.ts @@ -0,0 +1,76 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, test, vi } from 'vitest'; +import type { LogRecord } from '@/logging/types.js'; +import { JsonConsoleBackend } from '@/logging/JsonConsoleBackend.js'; + +/** JSON形式のテストで使う共通のログを作成します。 */ +function createRecord(overrides: Partial = {}): LogRecord { + return { + level: 'error', + message: 'delivery failed', + context: [{ name: 'queue' }, { name: 'deliver' }], + timestamp: '2025-01-02T03:04:05.678Z', + loggerName: 'queue.deliver', + processId: 1234, + isPrimary: false, + workerId: 7, + ...overrides, + }; +} + +describe('JsonConsoleBackend', () => { + test('writes a stable one-line JSON record', () => { + const output = vi.fn<(line: string) => void>(); + const backend = new JsonConsoleBackend({ output }); + + backend.write(createRecord({ + eventName: 'queue.job.failed', + attributes: { jobId: '123', attempt: 2 }, + error: { type: 'TypeError', message: 'broken', stack: 'stack' }, + })); + + expect(output).toHaveBeenCalledOnce(); + expect(output.mock.calls[0][0]).toMatchInlineSnapshot(`"{\"timestamp\":\"2025-01-02T03:04:05.678Z\",\"level\":\"error\",\"message\":\"delivery failed\",\"loggerName\":\"queue.deliver\",\"eventName\":\"queue.job.failed\",\"attributes\":{\"jobId\":\"123\",\"attempt\":2},\"error\":{\"type\":\"TypeError\",\"message\":\"broken\",\"stack\":\"stack\"},\"processId\":1234,\"isPrimary\":false,\"workerId\":7}"`); + }); + + test('omits pretty-only compatibility data and context colors', () => { + const output = vi.fn<(line: string) => void>(); + const backend = new JsonConsoleBackend({ output }); + const record = createRecord({ + context: [{ name: 'queue', color: 'red' }], + compatibility: { + legacyLevel: 'success', + important: true, + data: { secret: 'must not be written' }, + }, + }); + + backend.write(record); + + expect(JSON.parse(output.mock.calls[0][0])).toEqual({ + timestamp: '2025-01-02T03:04:05.678Z', + level: 'error', + message: 'delivery failed', + loggerName: 'queue.deliver', + processId: 1234, + isPrimary: false, + workerId: 7, + }); + }); + + test('escapes newlines so each record remains one physical line', () => { + const output = vi.fn<(line: string) => void>(); + const backend = new JsonConsoleBackend({ output }); + const message = 'first line\nsecond line "quoted"'; + + backend.write(createRecord({ message })); + + const line = output.mock.calls[0][0]; + expect(line).not.toContain('\n'); + expect(JSON.parse(line).message).toBe(message); + }); +}); diff --git a/packages/backend/test/unit/logging/logging-runtime.ts b/packages/backend/test/unit/logging/logging-runtime.ts new file mode 100644 index 0000000000..9e8b2c2903 --- /dev/null +++ b/packages/backend/test/unit/logging/logging-runtime.ts @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { JsonConsoleBackend } from '@/logging/JsonConsoleBackend.js'; +import { logManager, configureLogging } from '@/logging/logging-runtime.js'; +import { PrettyConsoleBackend } from '@/logging/PrettyConsoleBackend.js'; + +describe('logging-runtime', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('uses Pretty backend when the format is omitted', () => { + const setBackend = vi.spyOn(logManager, 'setBackend'); + + configureLogging(); + + expect(setBackend).toHaveBeenCalledWith(expect.any(PrettyConsoleBackend)); + }); + + test('uses JSON backend when the format is json', () => { + const setBackend = vi.spyOn(logManager, 'setBackend'); + + configureLogging({ format: 'json' }); + + expect(setBackend).toHaveBeenCalledWith(expect.any(JsonConsoleBackend)); + }); + + test('rejects an unknown format before changing logging configuration', () => { + const setBackend = vi.spyOn(logManager, 'setBackend'); + const configure = vi.spyOn(logManager, 'configure'); + + expect(() => configureLogging({ format: 'xml' as never })).toThrow('logging.format'); + expect(setBackend).not.toHaveBeenCalled(); + expect(configure).not.toHaveBeenCalled(); + }); +}); From 5a6b9439b6621c593ed2f8bce738d5fced225b17 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 17 Jul 2026 03:49:06 +0000 Subject: [PATCH 091/168] Bump version to 2026.7.0-alpha.6 --- package.json | 2 +- packages/misskey-js/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index fd2253f431..5a01454ad2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "misskey", - "version": "2026.7.0-alpha.5", + "version": "2026.7.0-alpha.6", "codename": "nasubi", "repository": { "type": "git", diff --git a/packages/misskey-js/package.json b/packages/misskey-js/package.json index eb61b1a81e..0f63710f3b 100644 --- a/packages/misskey-js/package.json +++ b/packages/misskey-js/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "misskey-js", - "version": "2026.7.0-alpha.5", + "version": "2026.7.0-alpha.6", "description": "Misskey SDK for JavaScript", "license": "MIT", "main": "./built/index.js", From b5f0e96bf1def6889f7fc05f8693f2a94064f829 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:56:18 +0900 Subject: [PATCH 092/168] New Crowdin updates (#17717) * New translations ja-jp.yml (Spanish) [ci skip] * New translations ja-jp.yml (Indonesian) [ci skip] * New translations ja-jp.yml (Tagalog) [ci skip] --- locales/es-ES.yml | 4 ++-- locales/id-ID.yml | 3 +++ locales/tl-PH.yml | 1 + 3 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 locales/tl-PH.yml diff --git a/locales/es-ES.yml b/locales/es-ES.yml index 82879fdff7..043cc2fc89 100644 --- a/locales/es-ES.yml +++ b/locales/es-ES.yml @@ -584,7 +584,7 @@ showFixedPostForm: "Mostrar formulario de publicación sobre la línea de tiempo showFixedPostFormInChannel: "Mostrar el formulario de publicación por encima de la cronología (Canales)" withRepliesByDefaultForNewlyFollowed: "Incluir por defecto respuestas de usuarios recién seguidos en la línea de tiempo" newNoteRecived: "Tienes una nota nueva" -newNote: "Nueva nota" +newNote: "Nuevas notas" sounds: "Sonidos" sound: "Sonidos" notificationSoundSettings: "Configuración del sonido de las notificaciones" @@ -1219,7 +1219,7 @@ keepScreenOn: "Mantener pantalla encendida" verifiedLink: "Propiedad del enlace verificada" notifyNotes: "Notificar nuevas notas" unnotifyNotes: "Dejar de notificar nuevas notas" -notifyUsers: "Usuarios que han activado las notificaciones de publicaciones" +notifyUsers: "Cuentas con notificaciones activadas" authentication: "Autenticación" authenticationRequiredToContinue: "Por favor, autentifícate para continuar" dateAndTime: "Fecha y hora" diff --git a/locales/id-ID.yml b/locales/id-ID.yml index 24127b6a84..1b3e057583 100644 --- a/locales/id-ID.yml +++ b/locales/id-ID.yml @@ -1358,6 +1358,7 @@ _chat: invitations: "Undang" history: "Riwayat obrolan" noHistory: "Tidak ada riwayat" + join: "Gabung" members: "Anggota" home: "Beranda" send: "Kirim" @@ -2555,6 +2556,8 @@ _deck: useSimpleUiForNonRootPages: "Gunakan antarmuka sederhana ke halaman yang dituju" usedAsMinWidthWhenFlexible: "Lebar minimum akan digunakan untuk ini ketika opsi \"Atur-otomatis lebar\" dinyalakan" flexible: "Atur-otomatis lebar" + _howToUse: + settings_title: "Pengaturan UI" _columns: main: "Utama" widgets: "Widget" diff --git a/locales/tl-PH.yml b/locales/tl-PH.yml new file mode 100644 index 0000000000..ed97d539c0 --- /dev/null +++ b/locales/tl-PH.yml @@ -0,0 +1 @@ +--- From 8691babd56c9fde7fd27d52ca9783f49362126db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=8B=E3=81=A3=E3=81=93=E3=81=8B=E3=82=8A?= <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:44:03 +0900 Subject: [PATCH 093/168] fix(frontend): Update MkLightbox.item.vue --- packages/frontend/src/components/MkLightbox.item.vue | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/frontend/src/components/MkLightbox.item.vue b/packages/frontend/src/components/MkLightbox.item.vue index 14ee8d00d9..eb7f581019 100644 --- a/packages/frontend/src/components/MkLightbox.item.vue +++ b/packages/frontend/src/components/MkLightbox.item.vue @@ -795,8 +795,11 @@ function openMenu(ev: PointerEvent) { }); if (props.content.file != null) { - menu.push({ type: 'divider' }); - menu.push(...getFileMenu(props.content.file)); + const fileMenu = getFileMenu(props.content.file); + if (fileMenu.length > 0) { + menu.push({ type: 'divider' }); + menu.push(...fileMenu); + } } os.popupMenu(menu, (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined); From 0be4d352ec213b1f23977b4169f50a1149d104f2 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:52:05 +0900 Subject: [PATCH 094/168] New Crowdin updates (#17732) * New translations ja-jp.yml (Spanish) [ci skip] * New translations ja-jp.yml (Catalan) [ci skip] * New translations ja-jp.yml (German) [ci skip] * New translations ja-jp.yml (Italian) [ci skip] * New translations ja-jp.yml (Korean) [ci skip] * New translations ja-jp.yml (Portuguese) [ci skip] * New translations ja-jp.yml (Turkish) [ci skip] * New translations ja-jp.yml (Ukrainian) [ci skip] * New translations ja-jp.yml (Chinese Simplified) [ci skip] * New translations ja-jp.yml (Chinese Traditional) [ci skip] * New translations ja-jp.yml (English) [ci skip] * New translations ja-jp.yml (Vietnamese) [ci skip] * New translations ja-jp.yml (Thai) [ci skip] * New translations ja-jp.yml (Japanese, Kansai) [ci skip] --- locales/ca-ES.yml | 3 --- locales/de-DE.yml | 3 --- locales/en-US.yml | 3 --- locales/es-ES.yml | 3 --- locales/it-IT.yml | 3 --- locales/ja-KS.yml | 3 --- locales/ko-KR.yml | 3 --- locales/pt-PT.yml | 3 --- locales/th-TH.yml | 3 --- locales/tr-TR.yml | 3 --- locales/uk-UA.yml | 3 --- locales/vi-VN.yml | 2 -- locales/zh-CN.yml | 3 --- locales/zh-TW.yml | 3 --- 14 files changed, 41 deletions(-) diff --git a/locales/ca-ES.yml b/locales/ca-ES.yml index be6ece175e..ec49f966aa 100644 --- a/locales/ca-ES.yml +++ b/locales/ca-ES.yml @@ -1361,14 +1361,11 @@ information: "Informació" chat: "Xat" directMessage: "Xateja amb aquest usuari" directMessage_short: "Missatge" -migrateOldSettings: "Migrar la configuració anterior" -migrateOldSettings_description: "Normalment això es fa automàticament, però si la transició no es fa, el procés es pot iniciar manualment. S'esborrarà la configuració actual." compress: "Comprimir " right: "Dreta" bottom: "A baix " top: "A dalt " embed: "Incrustar" -settingsMigrating: "Estem migrant la teva configuració. Si us plau espera un moment... (També pots fer la migració més tard, manualment, anant a Preferències → Altres → Migrar configuració antiga)" readonly: "Només lectura" goToDeck: "Tornar al tauler" federationJobs: "Treballs de federació" diff --git a/locales/de-DE.yml b/locales/de-DE.yml index a53634aa58..17b1a8c7e7 100644 --- a/locales/de-DE.yml +++ b/locales/de-DE.yml @@ -1358,14 +1358,11 @@ information: "Über" chat: "Chat" directMessage: "Mit dem Benutzer chatten" directMessage_short: "Nachrichten" -migrateOldSettings: "Alte Client-Einstellungen migrieren" -migrateOldSettings_description: "Dies sollte normalerweise automatisch geschehen, aber wenn die Migration aus irgendeinem Grund nicht erfolgreich war, kannst du den Migrationsprozess selbst manuell auslösen. Die aktuellen Konfigurationsinformationen werden dabei überschrieben." compress: "Komprimieren" right: "Rechts" bottom: "Unten" top: "Oben" embed: "Einbetten" -settingsMigrating: "Deine Einstellungen werden gerade migriert. Bitte warte einen Moment... (Du kannst die Einstellungen später auch manuell migrieren, indem du zu Einstellungen → Anderes → Alte Einstellungen migrieren gehst)" readonly: "Nur Lesezugriff" goToDeck: "Zurück zum Deck" federationJobs: "Föderation Jobs" diff --git a/locales/en-US.yml b/locales/en-US.yml index d1627feb14..e998937dfb 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -1361,14 +1361,11 @@ information: "About" chat: "Chat" directMessage: "Chat with user" directMessage_short: "Message" -migrateOldSettings: "Migrate old client settings" -migrateOldSettings_description: "This should be done automatically but if for some reason the migration was not successful, you can trigger the migration process yourself manually. The current configuration information will be overwritten." compress: "Compress" right: "Right" bottom: "Bottom" top: "Top" embed: "Embed" -settingsMigrating: "Settings are being migrated, please wait a moment... (You can also migrate manually later by going to Settings→Others→Migrate old settings)" readonly: "Read only" goToDeck: "Return to Deck" federationJobs: "Federation Jobs" diff --git a/locales/es-ES.yml b/locales/es-ES.yml index 043cc2fc89..6d0508f989 100644 --- a/locales/es-ES.yml +++ b/locales/es-ES.yml @@ -1361,14 +1361,11 @@ information: "Información" chat: "Chat" directMessage: "Chatear" directMessage_short: "Mensajes" -migrateOldSettings: "Migrar la configuración anterior" -migrateOldSettings_description: "Esto debería hacerse automáticamente, pero si por alguna razón la migración no ha tenido éxito, puede activar usted mismo el proceso de migración manualmente. Se sobrescribirá la información de configuración actual." compress: "Compresión de la imagen" right: "Derecha" bottom: "Abajo" top: "Arriba" embed: "Insertar" -settingsMigrating: "La configuración está siendo migrada, por favor espera un momento... (También puedes migrar manualmente más tarde yendo a Ajustes otros migrar configuración antigua" readonly: "Solo Lectura" goToDeck: "Volver al Deck" federationJobs: "Trabajos de Federación" diff --git a/locales/it-IT.yml b/locales/it-IT.yml index 06880a4ea2..a7712ae5ca 100644 --- a/locales/it-IT.yml +++ b/locales/it-IT.yml @@ -1361,14 +1361,11 @@ information: "Informazioni" chat: "Chat" directMessage: "Chattare insieme" directMessage_short: "Messaggio" -migrateOldSettings: "Migrare le vecchie impostazioni" -migrateOldSettings_description: "Di solito, viene fatto automaticamente. Se per qualche motivo non fossero migrate con successo, è possibile avviare il processo di migrazione manualmente, sovrascrivendo le configurazioni attuali." compress: "Compressione" right: "Destra" bottom: "Sotto" top: "Sopra" embed: "Incorporare" -settingsMigrating: "Migrazione delle impostazioni. Attendere prego ... (Puoi anche migrare manualmente in un secondo momento, nel menu: Impostazioni → Altro → Migrazione delle impostazioni)" readonly: "Sola lettura" goToDeck: "Torna al Deck" federationJobs: "Coda di federazione" diff --git a/locales/ja-KS.yml b/locales/ja-KS.yml index 75f9b8224d..802695ea0e 100644 --- a/locales/ja-KS.yml +++ b/locales/ja-KS.yml @@ -1332,9 +1332,6 @@ preferenceSyncConflictChoiceCancel: "同期の有効化はやめとくわ" postForm: "投稿フォーム" information: "情報" directMessage: "チャットしよか" -migrateOldSettings: "旧設定情報をお引っ越し" -migrateOldSettings_description: "通常これは自動で行われるはずなんやけど、なんかの理由で上手く移行できへんかったときは手動で移行処理をポチっとできるで。今の設定情報は上書きされるで。" -settingsMigrating: "設定を移行しとるで。ちょっと待っとってな... (後で、設定→その他→旧設定情報を移行 で手動で移行することもできるで)" driveAboutTip: "ドライブでは、今までアップロードしたファイルがずらーっと表示されるで。
\nノートにファイルをもっかいのっけたり、あとで投稿するファイルをその辺に置いとくこともできるねん。
\nファイルをほかすと、前にそのファイルをのっけた全部の場所(ノート、ページ、アバター、バナー等)からも見えんくなるから気いつけてな。
\nフォルダを作って整理することもできるで。" turnItOn: "オンにしとこ" turnItOff: "オフでええわ" diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index 5b21341ae9..06c7b27c1e 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -1361,14 +1361,11 @@ information: "정보" chat: "채팅" directMessage: "채팅하기" directMessage_short: "메시지" -migrateOldSettings: "기존 설정 정보를 이전" -migrateOldSettings_description: "보통은 자동으로 이루어지지만, 어떤 이유로 인해 성공적으로 이전이 이루어지지 않는 경우 수동으로 이전을 실행할 수 있습니다. 현재 설정 정보는 덮어쓰게 됩니다." compress: "압축" right: "오른쪽" bottom: "아래" top: "위" embed: "임베드" -settingsMigrating: "설정을 이전하는 중입니다. 잠시 기다려주십시오... (나중에 '환경설정 → 기타 → 기존 설정 정보를 이전'에서 수동으로 이전할 수도 있습니다)" readonly: "읽기 전용" goToDeck: "덱으로 돌아가기" federationJobs: "연합 작업" diff --git a/locales/pt-PT.yml b/locales/pt-PT.yml index 436692db3d..cadbc1e603 100644 --- a/locales/pt-PT.yml +++ b/locales/pt-PT.yml @@ -1347,14 +1347,11 @@ information: "Sobre" chat: "Conversas" directMessage: "Conversar com usuário" directMessage_short: "Mensagem" -migrateOldSettings: "Migrar configurações antigas de cliente" -migrateOldSettings_description: "Isso deve ser feito automaticamente. Caso o processo de migração tenha falhado, você pode acioná-lo manualmente. As informações atuais de migração serão substituídas." compress: "Comprimir" right: "Direita" bottom: "Inferior" top: "Superior" embed: "Embed" -settingsMigrating: "Configurações estão sendo migradas, aguarde... (Você pode migrar manualmente em Configurações→Outros→Migrar configurações antigas de cliente)" readonly: "Ler apenas" goToDeck: "Voltar ao Deck" federationJobs: "Tarefas de Federação" diff --git a/locales/th-TH.yml b/locales/th-TH.yml index 69b8d69788..da1e8659af 100644 --- a/locales/th-TH.yml +++ b/locales/th-TH.yml @@ -1358,14 +1358,11 @@ information: "เกี่ยวกับ" chat: "แชต" directMessage: "แชตเลย" directMessage_short: "ข้อความ" -migrateOldSettings: "ย้ายข้อมูลการตั้งค่าเก่า" -migrateOldSettings_description: "โดยปกติจะทำโดยอัตโนมัติ แต่หากด้วยเหตุผลบางประการที่ไม่สามารถย้ายได้สำเร็จ สามารถสั่งย้ายด้วยตนเองได้ การตั้งค่าปัจจุบันจะถูกเขียนทับ" compress: "บีบอัด" right: "ขวา" bottom: "ล่าง" top: "บน" embed: "ฝัง" -settingsMigrating: "กำลังย้ายการตั้งค่า กรุณารอสักครู่... (สามารถย้ายด้วยตนเองภายหลังได้ที่ การตั้งค่า → อื่นๆ → ย้ายข้อมูลการตั้งค่าเก่า)" readonly: "อ่านได้อย่างเดียว" goToDeck: "กลับไปยังเด็ค" federationJobs: "งานสหพันธ์" diff --git a/locales/tr-TR.yml b/locales/tr-TR.yml index f98d684556..83a49d3850 100644 --- a/locales/tr-TR.yml +++ b/locales/tr-TR.yml @@ -1358,14 +1358,11 @@ information: "Hakkında" chat: "Sohbet" directMessage: "Kullanıcıyla sohbet et" directMessage_short: "Mesaj" -migrateOldSettings: "Eski istemci ayarlarını taşıma" -migrateOldSettings_description: "Bu işlem otomatik olarak yapılmalıdır, ancak herhangi bir nedenle geçiş başarısız olursa, geçiş işlemini manuel olarak kendin başlatabilirsin. Mevcut yapılandırma bilgileri üzerine yazılacaktır." compress: "Sıkıştır" right: "Sağ" bottom: "Alt" top: "Üst" embed: "Göm" -settingsMigrating: "Ayarlar taşınıyor, lütfen bir dakika bekle... (Daha sonra Ayarlar→Diğerler→Eski ayarları taşı seçeneğine giderek manuel olarak da taşıyabilirsin)" readonly: "Sadece okuma" goToDeck: "Güverteye Dön" federationJobs: "Federasyon İşleri" diff --git a/locales/uk-UA.yml b/locales/uk-UA.yml index 1cf4b29cd4..ca64191ab6 100644 --- a/locales/uk-UA.yml +++ b/locales/uk-UA.yml @@ -1314,14 +1314,11 @@ information: "Інформація" chat: "Чат" directMessage: "Чат із користувачем" directMessage_short: "Повідомлення" -migrateOldSettings: "Перенести минулі налаштування клієнта" -migrateOldSettings_description: "Це має відбутися автоматично, але якщо з якоїсь причини перенесення не вдалося, ви можете запустити його вручну. Поточні дані конфігурації буде перезаписано." compress: "Стиснути" right: "Праворуч" bottom: "Зверху" top: "Знизу" embed: "Вбудувати" -settingsMigrating: "Налаштування переносяться, зачекайте трохи... (Ви також можете перенести їх вручну пізніше: Налаштування → Інше → Перенести старі налаштування)" readonly: "Лише для читання" goToDeck: "Повернутися до Деки" federationJobs: "Завдання федерації" diff --git a/locales/vi-VN.yml b/locales/vi-VN.yml index a9d07a3020..6436fd4c0a 100644 --- a/locales/vi-VN.yml +++ b/locales/vi-VN.yml @@ -1219,8 +1219,6 @@ paste: "dán" postForm: "Mẫu đăng" information: "Giới thiệu" chat: "Trò chuyện" -migrateOldSettings: "Di chuyển cài đặt cũ" -migrateOldSettings_description: "Thông thường, quá trình này diễn ra tự động, nhưng nếu vì lý do nào đó mà quá trình di chuyển không thành công, bạn có thể kích hoạt thủ công quy trình di chuyển, quá trình này sẽ ghi đè lên thông tin cấu hình hiện tại của bạn." driveAboutTip: "Trong Drive, danh sách các tệp bạn đã tải lên trước đây sẽ được hiển thị.
\nBạn có thể sử dụng lại chúng khi đính kèm vào ghi chú, hoặc tải lên trước các tệp để đăng sau.
\nLưu ý rằng nếu bạn xóa một tệp, tệp đó cũng sẽ biến mất khỏi tất cả những nơi đã sử dụng tệp đó (ghi chú, trang, ảnh đại diện, biểu ngữ, v.v.).
\nBạn cũng có thể tạo các thư mục để sắp xếp chúng." inMinutes: "phút" inDays: "ngày" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index acae660cfb..f826b716c7 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -1361,14 +1361,11 @@ information: "关于" chat: "聊天" directMessage: "私信" directMessage_short: "消息" -migrateOldSettings: "迁移旧设置信息" -migrateOldSettings_description: "通常设置信息将自动迁移。但如果由于某种原因迁移不成功,则可以手动触发迁移过程。当前的配置信息将被覆盖。" compress: "压缩" right: "右" bottom: "下" top: "上" embed: "嵌入" -settingsMigrating: "正在迁移设置,请稍候。(之后也可以在设置 → 其它 → 迁移旧设置来手动迁移)" readonly: "只读" goToDeck: "返回至 Deck" federationJobs: "联邦作业" diff --git a/locales/zh-TW.yml b/locales/zh-TW.yml index 4b99e32f38..59f04cd83c 100644 --- a/locales/zh-TW.yml +++ b/locales/zh-TW.yml @@ -1361,14 +1361,11 @@ information: "關於" chat: "聊天" directMessage: "直接訊息" directMessage_short: "訊息" -migrateOldSettings: "遷移舊設定資訊" -migrateOldSettings_description: "通常情況下,這會自動進行,但若因某些原因未能順利遷移,您可以手動觸發遷移處理。請注意,當前的設定資訊將會被覆寫。" compress: "壓縮" right: "右" bottom: "下" top: "上" embed: "嵌入" -settingsMigrating: "正在移轉設定。請稍候……(之後也可以到「設定 → 其他 → 舊設定資訊移轉」中手動進行移轉)" readonly: "唯讀" goToDeck: "回到多欄模式" federationJobs: "聯邦通訊作業" From 1812f7c11c8244fe8dd6b4cb8c1ebe3f135b3086 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:21:42 +0900 Subject: [PATCH 095/168] chore(gh): tweak workflow --- .github/scripts/frontend-bundle-diagnostics.render-md.mts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/frontend-bundle-diagnostics.render-md.mts b/.github/scripts/frontend-bundle-diagnostics.render-md.mts index 1e8cf37f24..efb0a10c85 100644 --- a/.github/scripts/frontend-bundle-diagnostics.render-md.mts +++ b/.github/scripts/frontend-bundle-diagnostics.render-md.mts @@ -516,7 +516,7 @@ function renderFrontendChunkReport(before: Awaited', + '
', `${formatChunkChangeSummary('Chunk size diff', diffSummary)}`, '', chunkMarkdownTable(diffRows, diffTotal, diffGenerated, diffOther), From 71a50f3d346fe5ecd2482be1fb0d0eafaa18d200 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:21:46 +0900 Subject: [PATCH 096/168] chore(gh): tweak workflow --- .github/scripts/backend-diagnostics.render-md.mts | 4 ++-- .github/scripts/frontend-browser-diagnostics.render-md.mts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/scripts/backend-diagnostics.render-md.mts b/.github/scripts/backend-diagnostics.render-md.mts index e73c22a722..5a748584e1 100644 --- a/.github/scripts/backend-diagnostics.render-md.mts +++ b/.github/scripts/backend-diagnostics.render-md.mts @@ -111,7 +111,7 @@ function renderHeapSnapshotSection(base: MemoryReport, head: MemoryReport) { for (const graph of [ //heapSnapshotUtil.renderHeapSnapshotSankey(baseHeapSnapshotReport, 'Base'), - heapSnapshotUtil.renderHeapSnapshotSankey(headHeapSnapshotReport, 'Head'), + //heapSnapshotUtil.renderHeapSnapshotSankey(headHeapSnapshotReport, 'Head'), ]) { if (graph == null) continue; lines.push(graph); @@ -149,7 +149,7 @@ if (heapSnapshotSection != null) { 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(`Download representative heap snapshot: [base](${baseHeapSnapshotArtifactUrl}) / [head](${headHeapSnapshotArtifactUrl})`); lines.push(''); function getDiffPercent(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof memoryMetrics[number]) { diff --git a/.github/scripts/frontend-browser-diagnostics.render-md.mts b/.github/scripts/frontend-browser-diagnostics.render-md.mts index 76ff29b911..c25f3b9a6e 100644 --- a/.github/scripts/frontend-browser-diagnostics.render-md.mts +++ b/.github/scripts/frontend-browser-diagnostics.render-md.mts @@ -341,8 +341,8 @@ function renderMd(base: BrowserMetricsReport, head: BrowserMetricsReport, option '', heapSnapshotTable ?? '_No V8 heap snapshot data._', '', - heapSnapshotUtil.renderHeapSnapshotSankey(toHeapSnapshotReport(head), 'Head'), - '', + //heapSnapshotUtil.renderHeapSnapshotSankey(toHeapSnapshotReport(head), 'Head'), + //'', `Download representative heap snapshot: [base](${options.baseHeapSnapshotUrl}) / [head](${options.headHeapSnapshotUrl})`, '
', '', From 227268fa1b67f45c7d0c22b44231fb2cf25b2ff7 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:29:47 +0900 Subject: [PATCH 097/168] chore(gh): tweak workflow --- .github/scripts/frontend-bundle-diagnostics.render-md.mts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/scripts/frontend-bundle-diagnostics.render-md.mts b/.github/scripts/frontend-bundle-diagnostics.render-md.mts index efb0a10c85..a198b46b5c 100644 --- a/.github/scripts/frontend-bundle-diagnostics.render-md.mts +++ b/.github/scripts/frontend-bundle-diagnostics.render-md.mts @@ -7,8 +7,6 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; import * as util from './utility.mts'; -const marker = ''; - const locale = 'ja-JP'; //function sharePercent(value, total) { @@ -594,8 +592,6 @@ const afterVisualizerReport = collectVisualizerReport(afterStats); const visualizerArtifactLink = `[Open treemap HTML](${process.env.FRONTEND_BUNDLE_REPORT_ARTIFACT_URL})`; const body = [ - marker, - '', `## 📦 Frontend Bundle Report`, '', renderFrontendChunkReport(before, after), From 65bcca04bea0eb2ae85e9a4a3409baad3d504649 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:58:09 +0900 Subject: [PATCH 098/168] refactor(gh): tweak workflow --- .../scripts/frontend-browser-diagnostics.render-md.mts | 8 ++------ .../scripts/frontend-bundle-diagnostics.render-md.mts | 10 +++------- .github/scripts/utility.mts | 4 ++++ 3 files changed, 9 insertions(+), 13 deletions(-) diff --git a/.github/scripts/frontend-browser-diagnostics.render-md.mts b/.github/scripts/frontend-browser-diagnostics.render-md.mts index c25f3b9a6e..9c3af1a79d 100644 --- a/.github/scripts/frontend-browser-diagnostics.render-md.mts +++ b/.github/scripts/frontend-browser-diagnostics.render-md.mts @@ -91,10 +91,6 @@ export type BrowserMetricsReport = { samples: BrowserMeasurementSample[]; }; -function escapeCell(value: string) { - return String(value).replaceAll('|', '\\|').replaceAll('\n', '
'); -} - function truncate(value: string, maxLength = 140) { if (value.length <= maxLength) return value; return `${value.slice(0, maxLength - 3)}...`; @@ -278,7 +274,7 @@ function renderLargestRequests(report: BrowserMetricsReport, title: string) { ]; for (const request of report.summary.network.largestRequests.slice(0, 10)) { - lines.push(`| \`${escapeCell(truncate(request.url))}\` | ${escapeCell(request.resourceType)} | ${request.status ?? '-'} | ${util.formatBytes(request.encodedBytes)} | ${util.formatBytes(request.decodedBodyBytes)} |`); + lines.push(`| \`${util.escapeMdTableCell(truncate(request.url))}\` | ${util.escapeMdTableCell(request.resourceType)} | ${request.status ?? '-'} | ${util.formatBytes(request.encodedBytes)} | ${util.formatBytes(request.decodedBodyBytes)} |`); } lines.push('', '
'); @@ -296,7 +292,7 @@ function renderFailedRequests(report: BrowserMetricsReport, title: string) { ]; for (const request of report.summary.network.failedRequests.slice(0, 20)) { - lines.push(`| \`${escapeCell(truncate(request.url))}\` | ${escapeCell(request.resourceType)} | ${request.status ?? '-'} | ${escapeCell(request.errorText ?? '')} |`); + lines.push(`| \`${util.escapeMdTableCell(truncate(request.url))}\` | ${util.escapeMdTableCell(request.resourceType)} | ${request.status ?? '-'} | ${util.escapeMdTableCell(request.errorText ?? '')} |`); } lines.push('', '
'); diff --git a/.github/scripts/frontend-bundle-diagnostics.render-md.mts b/.github/scripts/frontend-bundle-diagnostics.render-md.mts index a198b46b5c..e6932d450a 100644 --- a/.github/scripts/frontend-bundle-diagnostics.render-md.mts +++ b/.github/scripts/frontend-bundle-diagnostics.render-md.mts @@ -14,10 +14,6 @@ const locale = 'ja-JP'; // return Math.round((value / total) * 100) + '%'; //} -function escapeCell(value: string) { - return String(value).replaceAll('|', '\\|').replaceAll('\n', '
'); -} - //function tableCell(value) { // return String(value).replaceAll('|', '\\|').replaceAll('\r', ' ').replaceAll('\n', ' '); //} @@ -460,11 +456,11 @@ function chunkMarkdownTable( for (const row of rows) { const chunkFile = chunkFileDisplay(row); if (row.changeType === 'added') { - lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{orange}{\\text{( + )}}$ |`); + lines.push(`|
\`${util.escapeMdTableCell(row.name)}\` \`${util.escapeMdTableCell(chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{orange}{\\text{( + )}}$ |`); } else if (row.changeType === 'removed') { - lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{green}{\\text{( - )}}$ |`); + lines.push(`|
\`${util.escapeMdTableCell(row.name)}\` \`${util.escapeMdTableCell(chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{green}{\\text{( - )}}$ |`); } else { - lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(row.beforeSize, row.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`); + lines.push(`|
\`${util.escapeMdTableCell(row.name)}\` \`${util.escapeMdTableCell(chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(row.beforeSize, row.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`); } } if (hasGenerated) { diff --git a/.github/scripts/utility.mts b/.github/scripts/utility.mts index caec5555ef..e5797d2522 100644 --- a/.github/scripts/utility.mts +++ b/.github/scripts/utility.mts @@ -98,6 +98,10 @@ export function escapeLatex(text: string) { .replaceAll('%', '\\%'); } +export function escapeMdTableCell(value: string) { + return String(value).replaceAll('|', '\\|').replaceAll('\n', '
'); +} + export function formatColoredDelta(delta: number, text: (value: number) => string, colorThreshold = 0) { if (delta === 0) return text(0); const sign = delta > 0 ? '+' : '-'; From 37b4a7a8c44a06696da3fed26d23f78a9e7587df Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:06:00 +0900 Subject: [PATCH 099/168] refactor(gh): tweak workflow --- ...frontend-browser-diagnostics.render-md.mts | 83 +++---------------- .../frontend-bundle-diagnostics.render-md.mts | 77 +---------------- .github/scripts/utility.mts | 11 +++ 3 files changed, 23 insertions(+), 148 deletions(-) diff --git a/.github/scripts/frontend-browser-diagnostics.render-md.mts b/.github/scripts/frontend-browser-diagnostics.render-md.mts index 9c3af1a79d..859caa940e 100644 --- a/.github/scripts/frontend-browser-diagnostics.render-md.mts +++ b/.github/scripts/frontend-browser-diagnostics.render-md.mts @@ -91,32 +91,16 @@ export type BrowserMetricsReport = { samples: BrowserMeasurementSample[]; }; -function truncate(value: string, maxLength = 140) { - if (value.length <= maxLength) return value; - return `${value.slice(0, maxLength - 3)}...`; -} - -function formatMs(value: number | null | undefined) { - if (value == null || !Number.isFinite(value)) return '-'; - if (value >= 1_000) return `${util.formatNumber(value / 1_000)} s`; - return `${util.formatNumber(value)} ms`; -} - -function formatSecondsAsMs(value: number | null | undefined) { - if (value == null || !Number.isFinite(value)) return '-'; - return formatMs(value * 1_000); -} - function formatDelta(delta: number, formatter: (value: number) => string, colorThreshold = 0) { if (delta === 0) return formatter(0); return util.formatColoredDelta(delta, v => formatter(v), colorThreshold); } -function finiteValues(values: (number | null | undefined)[]) { - return values.filter(value => Number.isFinite(value)) as number[]; -} - function sampleSpread(report: BrowserMetricsReport, getValue: (sample: BrowserMeasurementSample) => number | null | undefined) { + function finiteValues(values: (number | null | undefined)[]) { + return values.filter(value => Number.isFinite(value)) as number[]; + } + const values = finiteValues(report.samples.map(sample => getValue(sample))); if (values.length < 2) return null; @@ -170,7 +154,7 @@ function getMetric(report: BrowserMeasurement, key: string) { function renderSummaryTable(base: BrowserMetricsReport, head: BrowserMetricsReport, all = false) { const rows = [ - //metricRow('Scenario duration', base, head, summary => summary.durationMs, sample => sample.durationMs, formatMs), + //metricRow('Scenario duration', base, head, summary => summary.durationMs, sample => sample.durationMs, util.formatMs), metricRow('Requests', base, head, summary => summary.network.requestCount, sample => sample.network.requestCount, util.formatNumber, 1, !all), //metricRow('Failed requests', base, head, summary => summary.network.failedRequestCount, sample => sample.network.failedRequestCount, util.formatNumber), metricRow('Encoded network', base, head, summary => summary.network.totalEncodedBytes, sample => sample.network.totalEncodedBytes, util.formatBytes, 10000, !all), @@ -182,11 +166,11 @@ function renderSummaryTable(base: BrowserMetricsReport, head: BrowserMetricsRepo metricRow('Fetch/XHR encoded', base, head, summary => resourceTypeBytes(summary, ['Fetch', 'XHR']), sample => resourceTypeSampleBytes(sample, ['Fetch', 'XHR']), util.formatBytes, 10000, !all), metricRow('Image encoded', base, head, summary => resourceTypeBytes(summary, ['Image']), sample => resourceTypeSampleBytes(sample, ['Image']), util.formatBytes, 10000, !all), metricRow('Font encoded', base, head, summary => resourceTypeBytes(summary, ['Font']), sample => resourceTypeSampleBytes(sample, ['Font']), util.formatBytes, 10000, !all), - //metricRow('First contentful paint', base, head, summary => summary.performance.webVitals.firstContentfulPaintMs, sample => sample.performance.webVitals.firstContentfulPaintMs, formatMs), - //metricRow('Load event end', base, head, summary => summary.performance.webVitals.loadEventEndMs, sample => sample.performance.webVitals.loadEventEndMs, formatMs), + //metricRow('First contentful paint', base, head, summary => summary.performance.webVitals.firstContentfulPaintMs, sample => sample.performance.webVitals.firstContentfulPaintMs, util.formatMs), + //metricRow('Load event end', base, head, summary => summary.performance.webVitals.loadEventEndMs, sample => sample.performance.webVitals.loadEventEndMs, util.formatMs), //metricRow('Long tasks', base, head, summary => summary.performance.webVitals.longTaskCount, sample => sample.performance.webVitals.longTaskCount, util.formatNumber), - //metricRow('Long task duration', base, head, summary => summary.performance.webVitals.longTaskDurationMs, sample => sample.performance.webVitals.longTaskDurationMs, formatMs), - //metricRow('Max long task', base, head, summary => summary.performance.webVitals.maxLongTaskDurationMs, sample => sample.performance.webVitals.maxLongTaskDurationMs, formatMs), + //metricRow('Long task duration', base, head, summary => summary.performance.webVitals.longTaskDurationMs, sample => sample.performance.webVitals.longTaskDurationMs, util.formatMs), + //metricRow('Max long task', base, head, summary => summary.performance.webVitals.maxLongTaskDurationMs, sample => sample.performance.webVitals.maxLongTaskDurationMs, util.formatMs), //metricRow('JS heap used', base, head, summary => summary.performance.runtimeHeap?.usedSize ?? getMetric(summary, 'JSHeapUsedSize'), sample => sample.performance.runtimeHeap?.usedSize ?? getMetric(sample, 'JSHeapUsedSize'), util.formatBytes), //metricRow('JS heap total', base, head, summary => summary.performance.runtimeHeap?.totalSize ?? getMetric(summary, 'JSHeapTotalSize'), sample => sample.performance.runtimeHeap?.totalSize ?? getMetric(sample, 'JSHeapTotalSize'), util.formatBytes), //metricRow('V8 heap snapshot total', base, head, summary => summary.heapSnapshot.categories.total, sample => sample.heapSnapshot.categories.total, util.formatBytes, 10000), @@ -195,8 +179,8 @@ function renderSummaryTable(base: BrowserMetricsReport, head: BrowserMetricsRepo //metricRow('JS event listeners', base, head, summary => getMetric(summary, 'JSEventListeners'), sample => getMetric(sample, 'JSEventListeners'), util.formatNumber), //metricRow('Layout count', base, head, summary => getMetric(summary, 'LayoutCount'), sample => getMetric(sample, 'LayoutCount'), util.formatNumber), //metricRow('Recalc style count', base, head, summary => getMetric(summary, 'RecalcStyleCount'), sample => getMetric(sample, 'RecalcStyleCount'), util.formatNumber), - //metricRow('Script duration', base, head, summary => getMetric(summary, 'ScriptDuration'), sample => getMetric(sample, 'ScriptDuration'), formatSecondsAsMs), - //metricRow('Task duration', base, head, summary => getMetric(summary, 'TaskDuration'), sample => getMetric(sample, 'TaskDuration'), formatSecondsAsMs), + //metricRow('Script duration', base, head, summary => getMetric(summary, 'ScriptDuration'), sample => getMetric(sample, 'ScriptDuration'), util.formatSecondsAsMs), + //metricRow('Task duration', base, head, summary => getMetric(summary, 'TaskDuration'), sample => getMetric(sample, 'TaskDuration'), util.formatSecondsAsMs), 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), @@ -263,42 +247,6 @@ function renderResourceTypeTable(base: BrowserMetricsReport, head: BrowserMetric return lines.join('\n'); } -function renderLargestRequests(report: BrowserMetricsReport, title: string) { - if (report.summary.network.largestRequests.length === 0) return null; - - const lines = [ - `
${title}`, - '', - '| Resource | Type | Status | Encoded | Decoded |', - '| --- | --- | ---: | ---: | ---: |', - ]; - - for (const request of report.summary.network.largestRequests.slice(0, 10)) { - lines.push(`| \`${util.escapeMdTableCell(truncate(request.url))}\` | ${util.escapeMdTableCell(request.resourceType)} | ${request.status ?? '-'} | ${util.formatBytes(request.encodedBytes)} | ${util.formatBytes(request.decodedBodyBytes)} |`); - } - - lines.push('', '
'); - return lines.join('\n'); -} - -function renderFailedRequests(report: BrowserMetricsReport, title: string) { - if (report.summary.network.failedRequests.length === 0) return null; - - const lines = [ - `
${title}`, - '', - '| Resource | Type | Status | Error |', - '| --- | --- | ---: | --- |', - ]; - - for (const request of report.summary.network.failedRequests.slice(0, 20)) { - lines.push(`| \`${util.escapeMdTableCell(truncate(request.url))}\` | ${util.escapeMdTableCell(request.resourceType)} | ${request.status ?? '-'} | ${util.escapeMdTableCell(request.errorText ?? '')} |`); - } - - lines.push('', '
'); - return lines.join('\n'); -} - function toHeapSnapshotReport(report: BrowserMetricsReport): HeapSnapshotReport { return { summary: report.summary.heapSnapshot, @@ -344,15 +292,6 @@ function renderMd(base: BrowserMetricsReport, head: BrowserMetricsReport, option '', ]; - for (const section of [ - //renderLargestRequests(head, 'Largest representative head requests'), - //renderFailedRequests(base, 'Failed representative base requests'), - //renderFailedRequests(head, 'Failed representative head requests'), - ]) { - if (section == null) continue; - lines.push(section, ''); - } - return lines.filter(line => line != null).join('\n').trimEnd() + '\n'; } diff --git a/.github/scripts/frontend-bundle-diagnostics.render-md.mts b/.github/scripts/frontend-bundle-diagnostics.render-md.mts index e6932d450a..7e524c82e7 100644 --- a/.github/scripts/frontend-bundle-diagnostics.render-md.mts +++ b/.github/scripts/frontend-bundle-diagnostics.render-md.mts @@ -9,29 +9,6 @@ import * as util from './utility.mts'; const locale = 'ja-JP'; -//function sharePercent(value, total) { -// if (total === 0) return '0%'; -// return Math.round((value / total) * 100) + '%'; -//} - -//function tableCell(value) { -// return String(value).replaceAll('|', '\\|').replaceAll('\r', ' ').replaceAll('\n', ' '); -//} - -//function code(value) { -// const sanitized = String(value).replaceAll('\r', ' ').replaceAll('\n', ' '); -// const backtickRuns = sanitized.match(/`+/g) ?? []; -// const fenceLength = Math.max(1, ...backtickRuns.map((run) => run.length + 1)); -// const fence = '`'.repeat(fenceLength); -// const padding = sanitized.startsWith('`') || sanitized.endsWith('`') ? ' ' : ''; -// -// return `${fence}${padding}${sanitized}${padding}${fence}`; -//} - -//function tableCode(value) { -// return tableCell(code(value)); -//} - type Manifest = Record; const stableNamedChunks = new Set(['vue', 'i18n']); @@ -505,10 +482,6 @@ function renderFrontendChunkReport(before: Awaited b.sortSize - a.sortSize || a.name.localeCompare(b.name)) - // .slice(0, 30); - return [ '
', `${formatChunkChangeSummary('Chunk size diff', diffSummary)}`, @@ -526,57 +499,9 @@ function renderFrontendChunkReport(before: Awaited', '', - //'
', - //`Largest`, - //'', - //markdownTable(largeRows), - //'', - //'
', - //'', ].join('\n'); } -function renderFrontendBundleReport(before: ReturnType, after: ReturnType) { - const lines = [ - ...renderVisualizerSummaryTable(before, after), - '', - //'
', - //'Top 10', - //'', - ]; - - /* - for (const row of after.hotModules.slice(0, 10)) { - lines.push(`- ${code(row.id)}: ${sharePercent(row.renderedLength, after.metrics.renderedLength)} (${formatBytes(row.renderedLength)})`); - } - - lines.push( - '', - '
', - ); - - lines.push( - '', - '
', - 'Hot Modules (Self Size)', - '', - '| Module | Bundles | Rendered | Share | Gzip | Brotli | Imports | Imported By |', - '|---|---:|---:|---:|---:|---:|---:|---:|', - ); - - for (const row of after.hotModules.slice(0, 15)) { - lines.push(`| ${tableCode(row.id)} | ${row.bundles} | ${formatBytes(row.renderedLength)} | ${sharePercent(row.renderedLength, after.metrics.renderedLength)} | ${formatBytes(row.gzipLength)} | ${formatBytes(row.brotliLength)} | ${row.importedCount} | ${row.importedByCount} |`); - } - - lines.push( - '', - '
', - ); - */ - - return lines.join('\n'); -} - const args = process.argv.slice(2); const [beforeDir, afterDir, beforeStatsFile, afterStatsFile, outFile] = args; const before = await collectReport(beforeDir); @@ -594,7 +519,7 @@ const body = [ '', '## Bundle Stats', '', - renderFrontendBundleReport(beforeVisualizerReport, afterVisualizerReport), + renderVisualizerSummaryTable(beforeVisualizerReport, afterVisualizerReport), '', visualizerArtifactLink, ].join('\n'); diff --git a/.github/scripts/utility.mts b/.github/scripts/utility.mts index e5797d2522..c8d7d2914c 100644 --- a/.github/scripts/utility.mts +++ b/.github/scripts/utility.mts @@ -102,6 +102,17 @@ export function escapeMdTableCell(value: string) { return String(value).replaceAll('|', '\\|').replaceAll('\n', '
'); } +export function formatMs(value: number | null | undefined) { + if (value == null || !Number.isFinite(value)) return '-'; + if (value >= 1_000) return `${formatNumber(value / 1_000)} s`; + return `${formatNumber(value)} ms`; +} + +export function formatSecondsAsMs(value: number | null | undefined) { + if (value == null || !Number.isFinite(value)) return '-'; + return formatMs(value * 1_000); +} + export function formatColoredDelta(delta: number, text: (value: number) => string, colorThreshold = 0) { if (delta === 0) return text(0); const sign = delta > 0 ? '+' : '-'; From b674da1bc81599771002fecfd1eefe3cdb3a273c Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:16:44 +0900 Subject: [PATCH 100/168] refactor(gh): tweak workflow --- ...frontend-browser-diagnostics.render-md.mts | 23 +++++-------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/.github/scripts/frontend-browser-diagnostics.render-md.mts b/.github/scripts/frontend-browser-diagnostics.render-md.mts index 859caa940e..2cccbd9527 100644 --- a/.github/scripts/frontend-browser-diagnostics.render-md.mts +++ b/.github/scripts/frontend-browser-diagnostics.render-md.mts @@ -96,20 +96,9 @@ function formatDelta(delta: number, formatter: (value: number) => string, colorT return util.formatColoredDelta(delta, v => formatter(v), colorThreshold); } -function sampleSpread(report: BrowserMetricsReport, getValue: (sample: BrowserMeasurementSample) => number | null | undefined) { - function finiteValues(values: (number | null | undefined)[]) { - return values.filter(value => Number.isFinite(value)) as number[]; - } - - const values = finiteValues(report.samples.map(sample => getValue(sample))); - if (values.length < 2) return null; - - const center = util.median(values); - return util.median(values.map(value => Math.abs(value - center))); -} - function formatValueWithSpread(report: BrowserMetricsReport, value: number, getSampleValue: (sample: BrowserMeasurementSample) => number | null | undefined, formatter: (value: number) => string) { - const spread = sampleSpread(report, getSampleValue); + const values = report.samples.map(sample => getSampleValue(sample)).filter(v => Number.isFinite(v)) as number[]; + const spread = values.length < 2 ? null : util.median(values.map(value => Math.abs(value - util.median(values)))); if (spread == null) return formatter(value); return `${formatter(value)}
± ${formatter(spread)}`; } @@ -148,11 +137,11 @@ function resourceTypeSampleBytes(sample: BrowserMeasurementSample, resourceTypes return resourceTypeBytes(sample, resourceTypes); } -function getMetric(report: BrowserMeasurement, key: string) { - return report.performance.cdpMetrics[key]; -} - function renderSummaryTable(base: BrowserMetricsReport, head: BrowserMetricsReport, all = false) { + //function getMetric(report: BrowserMeasurement, key: string) { + // return report.performance.cdpMetrics[key]; + //} + const rows = [ //metricRow('Scenario duration', base, head, summary => summary.durationMs, sample => sample.durationMs, util.formatMs), metricRow('Requests', base, head, summary => summary.network.requestCount, sample => sample.network.requestCount, util.formatNumber, 1, !all), From fca7859c89a214a574f3cb9842ea86588c25fe43 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:19:41 +0900 Subject: [PATCH 101/168] refactor(gh): tweak workflow --- ...frontend-browser-diagnostics.render-md.mts | 57 +++++++++---------- 1 file changed, 26 insertions(+), 31 deletions(-) diff --git a/.github/scripts/frontend-browser-diagnostics.render-md.mts b/.github/scripts/frontend-browser-diagnostics.render-md.mts index 2cccbd9527..d90c3d32c1 100644 --- a/.github/scripts/frontend-browser-diagnostics.render-md.mts +++ b/.github/scripts/frontend-browser-diagnostics.render-md.mts @@ -91,11 +91,6 @@ export type BrowserMetricsReport = { samples: BrowserMeasurementSample[]; }; -function formatDelta(delta: number, formatter: (value: number) => string, colorThreshold = 0) { - if (delta === 0) return formatter(0); - return util.formatColoredDelta(delta, v => formatter(v), colorThreshold); -} - function formatValueWithSpread(report: BrowserMetricsReport, value: number, getSampleValue: (sample: BrowserMeasurementSample) => number | null | undefined, formatter: (value: number) => string) { const values = report.samples.map(sample => getSampleValue(sample)).filter(v => Number.isFinite(v)) as number[]; const spread = values.length < 2 ? null : util.median(values.map(value => Math.abs(value - util.median(values)))); @@ -103,7 +98,7 @@ function formatValueWithSpread(report: BrowserMetricsReport, value: number, getS return `${formatter(value)}
± ${formatter(spread)}`; } -function metricRow( +function renderMetricRow( label: string, base: BrowserMetricsReport, head: BrowserMetricsReport, @@ -122,11 +117,11 @@ function metricRow( if (skipIfNotSignificant && (Math.abs(summary.median) < significantThreshold)) return null; const percent = baseValue === 0 ? null : summary.median * 100 / baseValue; - //const deltaMedian = `${formatDelta(summary.median, formatter, colorThreshold)}
${percent == null ? '-' : util.formatDeltaPercent(percent, 0.1).replaceAll('\\%', '\\\\%')}`; - const deltaMedian = formatDelta(summary.median, formatter, significantThreshold); + //const deltaMedian = `${util.formatColoredDelta(summary.median, formatter, colorThreshold)}
${percent == null ? '-' : util.formatDeltaPercent(percent, 0.1).replaceAll('\\%', '\\\\%')}`; + const deltaMedian = util.formatColoredDelta(summary.median, formatter, significantThreshold); - //return `| **${label}** | ${formatValueWithSpread(base, baseValue, getSampleValue, formatter)} | ${formatValueWithSpread(head, headValue, getSampleValue, formatter)} | ${deltaMedian} | ${summary == null ? '-' : formatter(summary.mad)} | ${summary == null ? '-' : formatDelta(summary.min, formatter)} | ${summary == null ? '-' : formatDelta(summary.max, formatter)} |`; - return `| **${label}** | ${formatter(baseValue)} | ${formatter(headValue)} | ${deltaMedian} | ${summary == null ? '-' : formatter(summary.mad)} | ${summary == null ? '-' : formatDelta(summary.min, formatter, significantThreshold)} | ${summary == null ? '-' : formatDelta(summary.max, formatter, significantThreshold)} |`; + //return `| **${label}** | ${formatValueWithSpread(base, baseValue, getSampleValue, formatter)} | ${formatValueWithSpread(head, headValue, getSampleValue, formatter)} | ${deltaMedian} | ${summary == null ? '-' : formatter(summary.mad)} | ${summary == null ? '-' : util.formatColoredDelta(summary.min, formatter)} | ${summary == null ? '-' : util.formatColoredDelta(summary.max, formatter)} |`; + return `| **${label}** | ${formatter(baseValue)} | ${formatter(headValue)} | ${deltaMedian} | ${summary == null ? '-' : formatter(summary.mad)} | ${summary == null ? '-' : util.formatColoredDelta(summary.min, formatter, significantThreshold)} | ${summary == null ? '-' : util.formatColoredDelta(summary.max, formatter, significantThreshold)} |`; } function resourceTypeBytes(report: BrowserMeasurement, resourceTypes: string[]) { @@ -144,17 +139,17 @@ function renderSummaryTable(base: BrowserMetricsReport, head: BrowserMetricsRepo const rows = [ //metricRow('Scenario duration', base, head, summary => summary.durationMs, sample => sample.durationMs, util.formatMs), - metricRow('Requests', base, head, summary => summary.network.requestCount, sample => sample.network.requestCount, util.formatNumber, 1, !all), + renderMetricRow('Requests', base, head, summary => summary.network.requestCount, sample => sample.network.requestCount, util.formatNumber, 1, !all), //metricRow('Failed requests', base, head, summary => summary.network.failedRequestCount, sample => sample.network.failedRequestCount, util.formatNumber), - metricRow('Encoded network', base, head, summary => summary.network.totalEncodedBytes, sample => sample.network.totalEncodedBytes, util.formatBytes, 10000, !all), - metricRow('Decoded body', base, head, summary => summary.network.totalDecodedBodyBytes, sample => sample.network.totalDecodedBodyBytes, util.formatBytes, 10000, !all), - metricRow('Same-origin encoded', base, head, summary => summary.network.sameOriginEncodedBytes, sample => sample.network.sameOriginEncodedBytes, util.formatBytes, 10000, !all), - metricRow('Third-party encoded', base, head, summary => summary.network.thirdPartyEncodedBytes, sample => sample.network.thirdPartyEncodedBytes, util.formatBytes, 10000, !all), - metricRow('Script encoded', base, head, summary => resourceTypeBytes(summary, ['Script']), sample => resourceTypeSampleBytes(sample, ['Script']), util.formatBytes, 10000, !all), - metricRow('Stylesheet encoded', base, head, summary => resourceTypeBytes(summary, ['Stylesheet']), sample => resourceTypeSampleBytes(sample, ['Stylesheet']), util.formatBytes, 10000, !all), - metricRow('Fetch/XHR encoded', base, head, summary => resourceTypeBytes(summary, ['Fetch', 'XHR']), sample => resourceTypeSampleBytes(sample, ['Fetch', 'XHR']), util.formatBytes, 10000, !all), - metricRow('Image encoded', base, head, summary => resourceTypeBytes(summary, ['Image']), sample => resourceTypeSampleBytes(sample, ['Image']), util.formatBytes, 10000, !all), - metricRow('Font encoded', base, head, summary => resourceTypeBytes(summary, ['Font']), sample => resourceTypeSampleBytes(sample, ['Font']), util.formatBytes, 10000, !all), + renderMetricRow('Encoded network', base, head, summary => summary.network.totalEncodedBytes, sample => sample.network.totalEncodedBytes, util.formatBytes, 10000, !all), + renderMetricRow('Decoded body', base, head, summary => summary.network.totalDecodedBodyBytes, sample => sample.network.totalDecodedBodyBytes, util.formatBytes, 10000, !all), + renderMetricRow('Same-origin encoded', base, head, summary => summary.network.sameOriginEncodedBytes, sample => sample.network.sameOriginEncodedBytes, util.formatBytes, 10000, !all), + renderMetricRow('Third-party encoded', base, head, summary => summary.network.thirdPartyEncodedBytes, sample => sample.network.thirdPartyEncodedBytes, util.formatBytes, 10000, !all), + renderMetricRow('Script encoded', base, head, summary => resourceTypeBytes(summary, ['Script']), sample => resourceTypeSampleBytes(sample, ['Script']), util.formatBytes, 10000, !all), + renderMetricRow('Stylesheet encoded', base, head, summary => resourceTypeBytes(summary, ['Stylesheet']), sample => resourceTypeSampleBytes(sample, ['Stylesheet']), util.formatBytes, 10000, !all), + renderMetricRow('Fetch/XHR encoded', base, head, summary => resourceTypeBytes(summary, ['Fetch', 'XHR']), sample => resourceTypeSampleBytes(sample, ['Fetch', 'XHR']), util.formatBytes, 10000, !all), + renderMetricRow('Image encoded', base, head, summary => resourceTypeBytes(summary, ['Image']), sample => resourceTypeSampleBytes(sample, ['Image']), util.formatBytes, 10000, !all), + renderMetricRow('Font encoded', base, head, summary => resourceTypeBytes(summary, ['Font']), sample => resourceTypeSampleBytes(sample, ['Font']), util.formatBytes, 10000, !all), //metricRow('First contentful paint', base, head, summary => summary.performance.webVitals.firstContentfulPaintMs, sample => sample.performance.webVitals.firstContentfulPaintMs, util.formatMs), //metricRow('Load event end', base, head, summary => summary.performance.webVitals.loadEventEndMs, sample => sample.performance.webVitals.loadEventEndMs, util.formatMs), //metricRow('Long tasks', base, head, summary => summary.performance.webVitals.longTaskCount, sample => sample.performance.webVitals.longTaskCount, util.formatNumber), @@ -170,15 +165,15 @@ function renderSummaryTable(base: BrowserMetricsReport, head: BrowserMetricsRepo //metricRow('Recalc style count', base, head, summary => getMetric(summary, 'RecalcStyleCount'), sample => getMetric(sample, 'RecalcStyleCount'), util.formatNumber), //metricRow('Script duration', base, head, summary => getMetric(summary, 'ScriptDuration'), sample => getMetric(sample, 'ScriptDuration'), util.formatSecondsAsMs), //metricRow('Task duration', base, head, summary => getMetric(summary, 'TaskDuration'), sample => getMetric(sample, 'TaskDuration'), util.formatSecondsAsMs), - 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('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), + renderMetricRow('WebSocket connections', base, head, summary => summary.network.webSocketConnectionCount, sample => sample.network.webSocketConnectionCount, util.formatNumber, 1, !all), + renderMetricRow('WebSocket sent', base, head, summary => summary.network.webSocketSentBytes, sample => sample.network.webSocketSentBytes, util.formatBytes, 10000, !all), + renderMetricRow('WebSocket received', base, head, summary => summary.network.webSocketReceivedBytes, sample => sample.network.webSocketReceivedBytes, util.formatBytes, 10000, !all), + renderMetricRow('Page errors', base, head, summary => summary.diagnostics.pageErrorCount, sample => sample.diagnostics.pageErrorCount, util.formatNumber, 1, !all), + renderMetricRow('Console log', base, head, summary => summary.diagnostics.console.log, sample => sample.diagnostics.console.log, util.formatNumber, 1, !all), + renderMetricRow('Console warnings', base, head, summary => summary.diagnostics.console.warning, sample => sample.diagnostics.console.warning, util.formatNumber, 1, !all), + renderMetricRow('Console errors', base, head, summary => summary.diagnostics.console.error, sample => sample.diagnostics.console.error, util.formatNumber, 1, !all), + renderMetricRow('Console info', base, head, summary => summary.diagnostics.console.info, sample => sample.diagnostics.console.info, util.formatNumber, 1, !all), + renderMetricRow('Page-attributed memory', base, head, summary => summary.performance.tabMemory.totalBytes, sample => sample.performance.tabMemory.totalBytes, util.formatBytes, 10000, !all), ].filter(row => row != null); return [ @@ -223,10 +218,10 @@ function renderResourceTypeTable(base: BrowserMetricsReport, head: BrowserMetric lines.push(`${key}`); lines.push(`${util.formatNumber(baseRow.requests)}`); lines.push(`${util.formatNumber(headRow.requests)}`); - lines.push(`${formatDelta(headRow.requests - baseRow.requests, util.formatNumber)}`); + lines.push(`${util.formatColoredDelta(headRow.requests - baseRow.requests, util.formatNumber)}`); lines.push(`${util.formatBytes(baseRow.encodedBytes)}`); lines.push(`${util.formatBytes(headRow.encodedBytes)}`); - lines.push(`${formatDelta(headRow.encodedBytes - baseRow.encodedBytes, util.formatBytes)}`); + lines.push(`${util.formatColoredDelta(headRow.encodedBytes - baseRow.encodedBytes, util.formatBytes)}`); lines.push(''); } From bdf1547a42360830c833bfadef3937de3897c1ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=8B=E3=81=A3=E3=81=93=E3=81=8B=E3=82=8A?= <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Sat, 18 Jul 2026 23:04:21 +0900 Subject: [PATCH 102/168] =?UTF-8?q?refactor(frontend):=20=E3=83=A1?= =?UTF-8?q?=E3=83=87=E3=82=A3=E3=82=A2=E3=83=A1=E3=83=8B=E3=83=A5=E3=83=BC?= =?UTF-8?q?=E3=81=AE=E3=83=A1=E3=83=8B=E3=83=A5=E3=83=BC=E3=83=9C=E3=82=BF?= =?UTF-8?q?=E3=83=B3=E3=81=AE=E3=82=B9=E3=82=BF=E3=82=A4=E3=83=AB=E3=82=92?= =?UTF-8?q?=E7=B5=B1=E4=B8=80=20(#17742)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix * fix * refactor --- .../frontend/src/components/MkMediaImage.vue | 32 +++++++------------ .../frontend/src/components/MkMediaVideo.vue | 24 +++++--------- 2 files changed, 20 insertions(+), 36 deletions(-) diff --git a/packages/frontend/src/components/MkMediaImage.vue b/packages/frontend/src/components/MkMediaImage.vue index 01f449753b..dee6db5e26 100644 --- a/packages/frontend/src/components/MkMediaImage.vue +++ b/packages/frontend/src/components/MkMediaImage.vue @@ -61,8 +61,8 @@ SPDX-License-Identifier: AGPL-3.0-only
ALT
- - + + @@ -174,23 +174,6 @@ function onContextmenu(ev: PointerEvent) { cursor: pointer; } -.hide { - display: block; - position: absolute; - background-color: rgba(0, 0, 0, 0.3); - -webkit-backdrop-filter: var(--MI-blur, blur(15px)); - backdrop-filter: var(--MI-blur, blur(15px)); - border-radius: 0 0 0 9px; - color: #fff; - font-size: 12px; - opacity: .5; - padding: 5px 8px; - text-align: center; - cursor: pointer; - top: 0; - right: 0; -} - .hiddenTextWrapper { display: table-cell; text-align: center; @@ -221,16 +204,25 @@ html[data-color-scheme=light] .visible { background-color: rgba(0, 0, 0, 0.3); -webkit-backdrop-filter: var(--MI-blur, blur(15px)); backdrop-filter: var(--MI-blur, blur(15px)); - border-radius: 9px 0 0 0; color: #fff; font-size: 0.8em; width: 28px; height: 28px; text-align: center; +} + +.menuBottom { + border-radius: 8px 0 8px 0; bottom: 0; right: 0; } +.menuTop { + border-radius: 0 8px 0 8px; + top: 0; + right: 0; +} + .imageContainer { display: block; overflow: hidden; diff --git a/packages/frontend/src/components/MkMediaVideo.vue b/packages/frontend/src/components/MkMediaVideo.vue index a03b58d479..d9d02b0afe 100644 --- a/packages/frontend/src/components/MkMediaVideo.vue +++ b/packages/frontend/src/components/MkMediaVideo.vue @@ -42,8 +42,8 @@ SPDX-License-Identifier: AGPL-3.0-only - - + + @@ -184,29 +184,21 @@ function onContextmenu(ev: PointerEvent) { background-color: rgba(0, 0, 0, 0.3); -webkit-backdrop-filter: var(--MI-blur, blur(15px)); backdrop-filter: var(--MI-blur, blur(15px)); - border-radius: 9px 0 0 0; color: #fff; font-size: 0.8em; width: 28px; height: 28px; text-align: center; +} + +.menuBottom { + border-radius: 8px 0 8px 0; bottom: 0; right: 0; } -.hide { - display: block; - position: absolute; - background-color: rgba(0, 0, 0, 0.3); - -webkit-backdrop-filter: var(--MI-blur, blur(15px)); - backdrop-filter: var(--MI-blur, blur(15px)); - border-radius: 0 0 0 9px; - color: #fff; - font-size: 12px; - opacity: .5; - padding: 5px 8px; - text-align: center; - cursor: pointer; +.menuTop { + border-radius: 0 8px 0 8px; top: 0; right: 0; } From 95de10ee653f2ea7b51ea5c365e1249322821ca1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=8B=E3=81=A3=E3=81=93=E3=81=8B=E3=82=8A?= <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Mon, 20 Jul 2026 08:19:18 +0900 Subject: [PATCH 103/168] fix(backend): fix dev mode --- packages/backend/rolldown.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/rolldown.config.ts b/packages/backend/rolldown.config.ts index e6a9888a09..768c3afc03 100644 --- a/packages/backend/rolldown.config.ts +++ b/packages/backend/rolldown.config.ts @@ -160,7 +160,7 @@ export default defineConfig((args) => { clearScreen: false, }, // ビルドの高速化のために、watchモードのときは外部モジュールは全てバンドルしないようにする - external: isWatchMode ? /^(?!@\/)[^.\/](?!:[\/\\])/ : externalModules, + external: isWatchMode ? /^(?!@\/|\0)[^.\/](?!:[\/\\])/ : externalModules, }; } }); From 9eca9b6f0d719c5cec9aa4294985ae63dffbda2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=8B=E3=81=A3=E3=81=93=E3=81=8B=E3=82=8A?= <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:58:07 +0900 Subject: [PATCH 104/168] =?UTF-8?q?fix(frontend):=20MkLightbox=E3=82=92?= =?UTF-8?q?=E4=B8=8A=E4=B8=8B=E6=96=B9=E5=90=91=E3=81=AB=E3=82=B9=E3=83=AF?= =?UTF-8?q?=E3=82=A4=E3=83=97=E3=81=97=E3=81=A6=E9=96=89=E3=81=98=E3=82=8B?= =?UTF-8?q?=E3=81=93=E3=81=A8=E3=81=8C=E3=81=A7=E3=81=8D=E3=81=AA=E3=81=84?= =?UTF-8?q?=E3=81=93=E3=81=A8=E3=81=8C=E3=81=82=E3=82=8B=E5=95=8F=E9=A1=8C?= =?UTF-8?q?=E3=82=92=E4=BF=AE=E6=AD=A3=20(#17740)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(frontend): MkLightboxを上下方向にスワイプして閉じることができないことがある問題を修正 * fix * fix * fix * fix * fix * fix * fix * review fixes --- .../src/components/MkLightbox.item.vue | 127 ++++++++++++++---- .../frontend/src/components/MkLightbox.vue | 12 +- .../frontend/src/components/MkMediaImage.vue | 6 +- .../frontend/src/components/MkMediaVideo.vue | 7 +- 4 files changed, 115 insertions(+), 37 deletions(-) diff --git a/packages/frontend/src/components/MkLightbox.item.vue b/packages/frontend/src/components/MkLightbox.item.vue index eb7f581019..5870652d25 100644 --- a/packages/frontend/src/components/MkLightbox.item.vue +++ b/packages/frontend/src/components/MkLightbox.item.vue @@ -127,7 +127,6 @@ SPDX-License-Identifier: AGPL-3.0-only '; + + const html = renderHtml(base, head); + + expect(html).not.toContain(''); + expect(html).toContain('<script>alert(1)</script>'); +}); diff --git a/packages-private/diagnostics-frontend-browser/tsconfig.json b/packages-private/diagnostics-frontend-browser/tsconfig.json new file mode 100644 index 0000000000..6672c40acb --- /dev/null +++ b/packages-private/diagnostics-frontend-browser/tsconfig.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "strictFunctionTypes": true, + "strictNullChecks": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["node"] + }, + "include": [ + "src/**/*.ts", + "test/**/*.ts" + ], + "exclude": [] +} diff --git a/packages-private/diagnostics-frontend-bundle/eslint.config.js b/packages-private/diagnostics-frontend-bundle/eslint.config.js new file mode 100644 index 0000000000..11bc648243 --- /dev/null +++ b/packages-private/diagnostics-frontend-bundle/eslint.config.js @@ -0,0 +1,25 @@ +import tsParser from '@typescript-eslint/parser'; +import sharedConfig from '../../packages/shared/eslint.config.js'; + +// eslint-disable-next-line import/no-default-export +export default [ + ...sharedConfig, + { + ignores: [ + '**/node_modules', + '**/__snapshots__', + 'test/fixtures', + ], + }, + { + files: ['**/*.ts', '**/*.tsx'], + languageOptions: { + parserOptions: { + parser: tsParser, + project: ['./tsconfig.json'], + sourceType: 'module', + tsconfigRootDir: import.meta.dirname, + }, + }, + }, +]; diff --git a/packages-private/diagnostics-frontend-bundle/package.json b/packages-private/diagnostics-frontend-bundle/package.json new file mode 100644 index 0000000000..b0271a8101 --- /dev/null +++ b/packages-private/diagnostics-frontend-bundle/package.json @@ -0,0 +1,22 @@ +{ + "name": "diagnostics-frontend-bundle", + "private": true, + "type": "module", + "scripts": { + "eslint": "eslint './**/*.{js,jsx,ts,tsx}'", + "lint": "pnpm typecheck && pnpm eslint", + "render-md": "tsx src/render-md.ts", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "diagnostics-shared": "workspace:*" + }, + "devDependencies": { + "@types/node": "26.1.1", + "tsx": "4.23.1", + "typescript": "5.9.3", + "vite": "8.1.4", + "vitest": "4.1.10" + } +} diff --git a/packages-private/diagnostics-frontend-bundle/src/chunk-report.ts b/packages-private/diagnostics-frontend-bundle/src/chunk-report.ts new file mode 100644 index 0000000000..2b2492377c --- /dev/null +++ b/packages-private/diagnostics-frontend-bundle/src/chunk-report.ts @@ -0,0 +1,217 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { + calcAndFormatDeltaBytes, + calcAndFormatDeltaPercentInMdTable, + escapeMdTableCell, + formatBytes, +} from 'diagnostics-shared/format'; +import type { CollectedReport, FileEntry } from './manifest'; + +/** + * この差分以下のチャンクは個別に出さず `(other)` にまとめる。 + * ハッシュ文字列の揺れ等でサイズが数バイト動くだけの行がノイズになるため。 + */ +const smallDeltaThreshold = 5; + +/** diff表に個別行として出す上限。これを超えた分は `(other)` に集約する */ +const diffRowLimit = 30; + +function entryDisplayName(entry: FileEntry | undefined) { + if (entry == null) return ''; + return entry.displayName || entry.file; +} + +export function getChunkComparisonRows(keys: string[], before: Partial>, after: Partial>) { + return keys.map(key => { + const beforeEntry = before[key]; + const afterEntry = after[key]; + const beforeSize = beforeEntry?.size ?? 0; + const afterSize = afterEntry?.size ?? 0; + return { + key, + name: entryDisplayName(beforeEntry ?? afterEntry), + beforeFile: beforeEntry?.file, + afterFile: afterEntry?.file, + beforeSize, + afterSize, + changeType: beforeEntry == null ? 'added' : afterEntry == null ? 'removed' : beforeSize !== afterSize ? 'updated' : 'unchanged', + sortSize: Math.max(beforeSize, afterSize), + }; + }); +} + +export type ChunkComparisonRow = ReturnType[number]; + +export type ChunkAggregate = { + beforeSize: number; + afterSize: number; + beforeCount: number; + afterCount: number; +}; + +export function sumChunkSizes(chunks: FileEntry[]) { + return chunks.reduce((sum, chunk) => sum + chunk.size, 0); +} + +/** + * 比較キーを持たない (= before/after で対応付けできない) チャンクの合計。 + */ +export function generatedAggregate(before: FileEntry[], after: FileEntry[]): ChunkAggregate { + const beforeGenerated = before.filter(chunk => chunk.comparisonKey == null); + const afterGenerated = after.filter(chunk => chunk.comparisonKey == null); + return { + beforeSize: sumChunkSizes(beforeGenerated), + afterSize: sumChunkSizes(afterGenerated), + beforeCount: beforeGenerated.length, + afterCount: afterGenerated.length, + }; +} + +export function hasSmallDelta(row: ChunkComparisonRow) { + return Math.abs(row.afterSize - row.beforeSize) <= smallDeltaThreshold; +} + +export function comparisonRowsAggregate(rows: ChunkComparisonRow[]): ChunkAggregate { + return { + beforeSize: rows.reduce((sum, row) => sum + row.beforeSize, 0), + afterSize: rows.reduce((sum, row) => sum + row.afterSize, 0), + beforeCount: rows.filter(row => row.beforeFile != null).length, + afterCount: rows.filter(row => row.afterFile != null).length, + }; +} + +export function comparableMap(chunks: FileEntry[]) { + const entries: [string, FileEntry][] = []; + for (const chunk of chunks) { + if (chunk.comparisonKey != null) entries.push([chunk.comparisonKey, chunk]); + } + return Object.fromEntries(entries); +} + +export function summarizeChunkChanges(rows: ChunkComparisonRow[]) { + return { + updated: rows.filter((row) => row.changeType === 'updated').length, + added: rows.filter((row) => row.changeType === 'added').length, + removed: rows.filter((row) => row.changeType === 'removed').length, + }; +} + +export function formatChunkChangeSummary(label: string, summary: ReturnType) { + return `${label} (${summary.updated} updated, ${summary.added} added, ${summary.removed} removed)`; +} + +/** + * 差分の絶対値が大きい順。同着は増加側・元サイズ・名前の順で決定的に並べる。 + */ +export function compareChunkComparisonRows(a: ChunkComparisonRow, b: ChunkComparisonRow) { + return Math.abs(b.afterSize - b.beforeSize) - Math.abs(a.afterSize - a.beforeSize) + || (b.afterSize - b.beforeSize) - (a.afterSize - a.beforeSize) + || b.sortSize - a.sortSize + || a.name.localeCompare(b.name); +} + +export function chunkFileDisplay(row: ChunkComparisonRow) { + if (row.beforeFile == null) return row.afterFile ?? ''; + if (row.afterFile == null || row.beforeFile === row.afterFile) return row.beforeFile; + return `${row.beforeFile} → ${row.afterFile}`; +} + +export function chunkMarkdownTable( + rows: ChunkComparisonRow[], + total?: { beforeSize: number; afterSize: number }, + generated?: ChunkAggregate, + other?: ChunkAggregate, +) { + const hasGenerated = generated != null && (generated.beforeCount > 0 || generated.afterCount > 0); + const hasOther = other != null && (other.beforeCount > 0 || other.afterCount > 0); + if (rows.length === 0 && total == null && !hasGenerated && !hasOther) return '_No data_'; + + const lines = [ + '| Chunk | Before | After | Δ | Δ (%) |', + '| --- | ---: | ---: | ---: | ---: |', + ]; + if (total != null) { + lines.push(`| (total) | ${formatBytes(total.beforeSize)} | ${formatBytes(total.afterSize)} | ${calcAndFormatDeltaBytes(total.beforeSize, total.afterSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(total.beforeSize, total.afterSize, 0.1)} |`); + lines.push('| | | | | |'); + } + + for (const row of rows) { + const chunkFile = chunkFileDisplay(row); + if (row.changeType === 'added') { + lines.push(`|
\`${escapeMdTableCell(row.name)}\` \`${escapeMdTableCell(chunkFile)}\`
| ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{orange}{\\text{( + )}}$ |`); + } else if (row.changeType === 'removed') { + lines.push(`|
\`${escapeMdTableCell(row.name)}\` \`${escapeMdTableCell(chunkFile)}\`
| ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{green}{\\text{( - )}}$ |`); + } else { + lines.push(`|
\`${escapeMdTableCell(row.name)}\` \`${escapeMdTableCell(chunkFile)}\`
| ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(row.beforeSize, row.afterSize, 0.1)} |`); + } + } + if (hasGenerated) { + lines.push(`| (other generated chunks) | ${formatBytes(generated.beforeSize)} | ${formatBytes(generated.afterSize)} | ${calcAndFormatDeltaBytes(generated.beforeSize, generated.afterSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(generated.beforeSize, generated.afterSize, 0.1)} |`); + } + if (hasOther) { + lines.push(`| (other) | ${formatBytes(other.beforeSize)} | ${formatBytes(other.afterSize)} | ${calcAndFormatDeltaBytes(other.beforeSize, other.afterSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(other.beforeSize, other.afterSize, 0.1)} |`); + } + return lines.join('\n'); +} + +export function renderFrontendChunkReport(before: CollectedReport, after: CollectedReport) { + const beforeComparable = before.comparableChunks; + const afterComparable = after.comparableChunks; + const allChunkKeys = [...new Set([...Object.keys(beforeComparable), ...Object.keys(afterComparable)])]; + const allComparisonRows = getChunkComparisonRows(allChunkKeys, beforeComparable, afterComparable); + + const changedRows = allComparisonRows.filter((row) => row.changeType !== 'unchanged'); + const diffSummary = summarizeChunkChanges(changedRows); + const diffTotal = { + beforeSize: sumChunkSizes(before.chunks), + afterSize: sumChunkSizes(after.chunks), + }; + const diffGenerated = generatedAggregate(before.chunks, after.chunks); + const largeDeltaRows = changedRows.filter(row => !hasSmallDelta(row)).sort(compareChunkComparisonRows); + const diffRows = largeDeltaRows.slice(0, diffRowLimit); + // 表示上限で切り捨てた行も `(other)` に含める。落とすと合計が実際の変化量と合わなくなる + const diffOther = comparisonRowsAggregate([ + ...changedRows.filter(hasSmallDelta), + ...largeDeltaRows.slice(diffRowLimit), + ]); + + const beforeStartupFiles = new Set(before.startupFiles); + const afterStartupFiles = new Set(after.startupFiles); + const beforeStartupChunks = before.chunks.filter(chunk => beforeStartupFiles.has(chunk.file)); + const afterStartupChunks = after.chunks.filter(chunk => afterStartupFiles.has(chunk.file)); + const beforeStartupComparable = comparableMap(beforeStartupChunks); + const afterStartupComparable = comparableMap(afterStartupChunks); + const startupKeys = [...new Set([...Object.keys(beforeStartupComparable), ...Object.keys(afterStartupComparable)])]; + const startupComparisonRows = getChunkComparisonRows(startupKeys, beforeStartupComparable, afterStartupComparable); + const startupSummary = summarizeChunkChanges(startupComparisonRows); + const startupOther = comparisonRowsAggregate(startupComparisonRows.filter(hasSmallDelta)); + const startupRows = startupComparisonRows.filter(row => !hasSmallDelta(row)).sort(compareChunkComparisonRows); + const startupTotal = { + beforeSize: sumChunkSizes(beforeStartupChunks), + afterSize: sumChunkSizes(afterStartupChunks), + }; + const startupGenerated = generatedAggregate(beforeStartupChunks, afterStartupChunks); + + return [ + '
', + `${formatChunkChangeSummary('Chunk size diff', diffSummary)}`, + '', + chunkMarkdownTable(diffRows, diffTotal, diffGenerated, diffOther), + '', + '
', + '', + '
', + `${formatChunkChangeSummary('Startup chunk size', startupSummary)}`, + '', + chunkMarkdownTable(startupRows, startupTotal, startupGenerated, startupOther), + '', + '_Startup chunks are the Vite entry for \`src/_boot_.ts\` and its static imports._', + '', + '
', + '', + ].join('\n'); +} diff --git a/packages-private/diagnostics-frontend-bundle/src/fs-utils.ts b/packages-private/diagnostics-frontend-bundle/src/fs-utils.ts new file mode 100644 index 0000000000..74ed757b53 --- /dev/null +++ b/packages-private/diagnostics-frontend-bundle/src/fs-utils.ts @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +/** + * Windows の `\` 区切りを `/` に揃える。manifest 側のキーが常に `/` 区切りのため、 + * 実ファイルパスと突き合わせる前に正規化する必要がある。 + */ +export function normalizePath(filePath: string) { + return filePath.split(path.sep).join('/'); +} + +export async function fileExists(filePath: string) { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +export async function fileSize(filePath: string) { + const stat = await fs.stat(filePath); + return stat.size; +} + +export async function* traverseDirectory(dir: string): AsyncGenerator { + for (const entry of await fs.readdir(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + yield* traverseDirectory(fullPath); + } else if (entry.isFile()) { + yield fullPath; + } + } +} diff --git a/packages-private/diagnostics-frontend-bundle/src/manifest.ts b/packages-private/diagnostics-frontend-bundle/src/manifest.ts new file mode 100644 index 0000000000..d35a5cfbfb --- /dev/null +++ b/packages-private/diagnostics-frontend-bundle/src/manifest.ts @@ -0,0 +1,171 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { fileExists, fileSize, normalizePath, traverseDirectory } from './fs-utils'; +import type { Manifest, ManifestChunk } from 'vite'; + +/** + * 比較対象とするロケール。ロケール別チャンクは全ロケール分だと数が多すぎるため、 + * 代表として ja-JP のみを見る。 + */ +const locale = 'ja-JP'; + +/** + * `src` を持たないチャンクのうち、名前がビルド間で安定していて比較可能なもの。 + */ +const stableNamedChunks = new Set(['vue', 'i18n']); + +export type FileEntry = { + comparisonKey: string | null; + displayName: string; + file: string; + manifestKeys: string[]; + size: number; +}; + +export type CollectedReport = { + manifest: Manifest; + chunks: FileEntry[]; + comparableChunks: Record; + chunksByManifestKey: Record; + startupFiles: string[]; +}; + +export function findEntryKey(manifest: Manifest) { + const entries = Object.entries(manifest); + return entries.find(([key, chunk]) => key === 'src/_boot_.ts' || chunk.src === 'src/_boot_.ts')?.[0] + ?? entries.find(([, chunk]) => chunk.name === 'entry' && chunk.isEntry)?.[0] + ?? entries.find(([, chunk]) => chunk.isEntry)?.[0] + ?? null; +} + +/** + * ビルド間で安定するチャンク識別子。出力ファイル名はハッシュ付きで毎回変わるため、 + * これが取れないチャンクは before/after の対応付けができない。 + */ +export function stableChunkKey(chunk: ManifestChunk) { + if (chunk.src != null) return `src:${normalizePath(chunk.src)}`; + if (chunk.name != null && stableNamedChunks.has(chunk.name)) return `named:${chunk.name}`; + return null; +} + +/** + * 起動時に必ず読み込まれるチャンク (entry とその静的 import) の manifest キーを集める。 + */ +export function collectStartupManifestKeys(manifest: Manifest) { + const entryKey = findEntryKey(manifest); + const keys = new Set(); + if (entryKey == null) throw new Error('Unable to find frontend startup entry in Vite manifest.'); + + function visit(key: string, importedBy?: string) { + if (keys.has(key)) return; + const chunk = manifest[key]; + const importContext = importedBy == null ? '' : ` imported by "${importedBy}"`; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (chunk == null) throw new Error(`Startup manifest key "${key}"${importContext} is missing.`); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (chunk.file == null || chunk.file.length === 0) throw new Error(`Startup manifest key "${key}"${importContext} has no output file.`); + if (!chunk.file.endsWith('.js')) throw new Error(`Startup manifest key "${key}"${importContext} resolves to non-JavaScript output "${chunk.file}".`); + keys.add(key); + for (const importKey of chunk.imports ?? []) visit(importKey, key); + } + + visit(entryKey); + return keys; +} + +/** + * manifest 上の出力パスを実ファイルへ解決する。`scripts/` 配下はロケール別に + * 複製されて出力されるため、代表ロケールのものへ読み替える。 + */ +export async function resolveBuiltFile(outDir: string, file: string) { + if (file.startsWith('scripts/')) { + const localizedFile = file.slice('scripts/'.length); + const localizedPath = path.join(outDir, locale, localizedFile); + if (await fileExists(localizedPath)) { + return { + absolutePath: localizedPath, + relativePath: `${locale}/${localizedFile}`, + }; + } + + throw new Error(`Expected ${locale} localized chunk for ${file}, but ${localizedPath} was not found.`); + } + return { + absolutePath: path.join(outDir, file), + relativePath: file, + }; +} + +export async function collectReport(repoDir: string): Promise { + const outDir = path.join(repoDir, 'built/_frontend_vite_'); + const manifestPath = path.join(outDir, 'manifest.json'); + const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as Manifest; + const chunksByFile = new Map(); + const comparableChunks = new Map(); + const chunksByManifestKey = new Map(); + + for (const [manifestKey, chunk] of Object.entries(manifest)) { + if (!chunk.file.endsWith('.js')) continue; + const builtFile = await resolveBuiltFile(outDir, chunk.file); + const comparisonKey = stableChunkKey(chunk); + let entry = chunksByFile.get(builtFile.relativePath); + if (entry == null) { + entry = { + comparisonKey, + displayName: chunk.src ?? chunk.name ?? manifestKey, + file: builtFile.relativePath, + manifestKeys: [manifestKey], + size: await fileSize(builtFile.absolutePath), + }; + chunksByFile.set(entry.file, entry); + } else if (entry.comparisonKey !== comparisonKey) { + throw new Error(`Conflicting identities for ${entry.file}`); + } else { + entry.manifestKeys.push(manifestKey); + } + chunksByManifestKey.set(manifestKey, entry); + if (comparisonKey != null) { + const existing = comparableChunks.get(comparisonKey); + if (existing != null && existing.file !== entry.file) { + throw new Error(`Duplicate stable chunk key "${comparisonKey}": ${existing.file}, ${entry.file}`); + } + comparableChunks.set(comparisonKey, entry); + } + } + + // manifest に載らないロケール別チャンクも合計サイズには含めたいので拾っておく + const localeDir = path.join(outDir, locale); + if (await fileExists(localeDir)) { + for await (const fullPath of traverseDirectory(localeDir)) { + if (!fullPath.endsWith('.js')) continue; + const relativePath = normalizePath(path.relative(outDir, fullPath)); + if (chunksByFile.has(relativePath)) continue; + chunksByFile.set(relativePath, { + comparisonKey: null, + displayName: relativePath, + file: relativePath, + manifestKeys: [], + size: await fileSize(fullPath), + }); + } + } + + const startupFiles = new Set(); + for (const manifestKey of collectStartupManifestKeys(manifest)) { + const entry = chunksByManifestKey.get(manifestKey); + if (entry != null) startupFiles.add(entry.file); + } + + return { + manifest, + chunks: [...chunksByFile.values()], + comparableChunks: Object.fromEntries(comparableChunks), + chunksByManifestKey: Object.fromEntries(chunksByManifestKey), + startupFiles: [...startupFiles], + }; +} diff --git a/packages-private/diagnostics-frontend-bundle/src/render-md.ts b/packages-private/diagnostics-frontend-bundle/src/render-md.ts new file mode 100644 index 0000000000..d8fe0997dd --- /dev/null +++ b/packages-private/diagnostics-frontend-bundle/src/render-md.ts @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { readRequiredEnv } from 'diagnostics-shared/env'; +import { collectReport } from './manifest'; +import { renderBundleReportMarkdown } from './report'; +import type { VisualizerReport } from './visualizer'; + +async function main() { + const [beforeDir, afterDir, beforeStatsFile, afterStatsFile, outFile] = process.argv.slice(2).map(arg => path.resolve(arg)); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (outFile == null) throw new Error('Usage: render-md '); + + // 未設定のまま `undefined` という文字列をコメントに埋め込まないよう、ここで落とす + const visualizerArtifactUrl = readRequiredEnv('FRONTEND_BUNDLE_REPORT_ARTIFACT_URL'); + + const before = await collectReport(beforeDir); + const after = await collectReport(afterDir); + const beforeStats = JSON.parse(await fs.readFile(beforeStatsFile, 'utf8')) as VisualizerReport; + const afterStats = JSON.parse(await fs.readFile(afterStatsFile, 'utf8')) as VisualizerReport; + + await fs.writeFile(outFile, renderBundleReportMarkdown(before, after, beforeStats, afterStats, { visualizerArtifactUrl })); +} + +await main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/packages-private/diagnostics-frontend-bundle/src/report.ts b/packages-private/diagnostics-frontend-bundle/src/report.ts new file mode 100644 index 0000000000..db2e1cf6e3 --- /dev/null +++ b/packages-private/diagnostics-frontend-bundle/src/report.ts @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { renderFrontendChunkReport } from './chunk-report'; +import { collectVisualizerReport, renderVisualizerSummaryTable, type VisualizerReport } from './visualizer'; +import type { CollectedReport } from './manifest'; + +export type RenderBundleReportOptions = { + /** rollup-plugin-visualizer が出力したtreemap HTMLのartifact URL */ + visualizerArtifactUrl: string; +}; + +export function renderBundleReportMarkdown( + before: CollectedReport, + after: CollectedReport, + beforeStats: VisualizerReport, + afterStats: VisualizerReport, + options: RenderBundleReportOptions, +) { + return [ + '## 📦 Frontend Bundle Report', + '', + renderFrontendChunkReport(before, after), + '', + '## Bundle Stats', + '', + renderVisualizerSummaryTable(collectVisualizerReport(beforeStats), collectVisualizerReport(afterStats)), + '', + `[Open treemap HTML](${options.visualizerArtifactUrl})`, + ].join('\n'); +} diff --git a/packages-private/diagnostics-frontend-bundle/src/visualizer.ts b/packages-private/diagnostics-frontend-bundle/src/visualizer.ts new file mode 100644 index 0000000000..17e4f190b6 --- /dev/null +++ b/packages-private/diagnostics-frontend-bundle/src/visualizer.ts @@ -0,0 +1,204 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { + calcAndFormatDeltaBytes, + calcAndFormatDeltaNumber, + calcAndFormatDeltaPercent, + formatBytes, + formatNumber, +} from 'diagnostics-shared/format'; + +/** + * rollup-plugin-visualizer が出力する `stats.json` のうち、ここで使う部分のみの型。 + */ +export type VisualizerReport = { + nodeParts?: Record; + nodeMetas?: Record; + renderedLength: number; + gzipLength: number; + brotliLength: number; + }>; + options?: Record; +}; + +type ModuleRow = { + id: string; + bundles: number; + renderedLength: number; + gzipLength: number; + brotliLength: number; + importedByCount: number; + importedCount: number; +}; + +type BundleRow = { + id: string; + modules: number; + renderedLength: number; + gzipLength: number; + brotliLength: number; +}; + +export function collectVisualizerReport(data: VisualizerReport) { + const nodeParts = data.nodeParts ?? {}; + const nodeMetas = Object.values(data.nodeMetas ?? {}); + const moduleRows: ModuleRow[] = []; + const bundleMap = new Map(); + + for (const meta of nodeMetas) { + const row: ModuleRow = { + id: meta.id, + bundles: 0, + renderedLength: 0, + gzipLength: 0, + brotliLength: 0, + importedByCount: meta.importedBy?.length ?? 0, + importedCount: meta.imported?.length ?? 0, + }; + + for (const [bundleId, partUid] of Object.entries(meta.moduleParts ?? {})) { + const part = nodeParts[partUid]; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (part == null) continue; + + row.bundles += 1; + row.renderedLength += part.renderedLength; + row.gzipLength += part.gzipLength; + row.brotliLength += part.brotliLength; + + const bundle = bundleMap.get(bundleId) ?? { + id: bundleId, + modules: 0, + renderedLength: 0, + gzipLength: 0, + brotliLength: 0, + }; + bundle.modules += 1; + bundle.renderedLength += part.renderedLength; + bundle.gzipLength += part.gzipLength; + bundle.brotliLength += part.brotliLength; + bundleMap.set(bundleId, bundle); + } + + // どのバンドルにも含まれないモジュール (tree-shake 済み等) は集計対象外 + if (row.bundles > 0) { + moduleRows.push(row); + } + } + + let staticImports = 0; + let dynamicImports = 0; + for (const meta of nodeMetas) { + for (const imported of meta.imported ?? []) { + if (imported.dynamic) { + dynamicImports += 1; + } else { + staticImports += 1; + } + } + } + + const bundleRows = [...bundleMap.values()].sort((a, b) => b.renderedLength - a.renderedLength); + const hotModules = [...moduleRows].sort((a, b) => b.renderedLength - a.renderedLength); + const totalRendered = moduleRows.reduce((sum, row) => sum + row.renderedLength, 0); + const totalGzip = moduleRows.reduce((sum, row) => sum + row.gzipLength, 0); + const totalBrotli = moduleRows.reduce((sum, row) => sum + row.brotliLength, 0); + + return { + options: data.options ?? {}, + summary: { + bundles: bundleRows.length, + modules: moduleRows.length, + entries: nodeMetas.filter((meta) => meta.isEntry).length, + externals: nodeMetas.filter((meta) => meta.isExternal).length, + staticImports, + dynamicImports, + }, + metrics: { + renderedLength: totalRendered, + gzipLength: totalGzip, + brotliLength: totalBrotli, + }, + hotModules, + }; +} + +/** + * NOTE: 以前はこの関数が `string[]` を返しており、呼び出し側の `[...].join('\n')` の + * 要素として配列のまま埋め込まれていたため、テーブルがカンマ区切りの1行に潰れていた。 + * 呼び出し側で意識しなくて済むよう、ここで文字列にして返す。 + */ +export function renderVisualizerSummaryTable(before: ReturnType, after: ReturnType) { + const summary = [ + 'bundles', + 'modules', + 'entries', + //'externals', + 'staticImports', + 'dynamicImports', + ] as const; + + const metrics = [ + 'renderedLength', + 'gzipLength', + 'brotliLength', + ] as const; + + return [ + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + ...summary.map((key) => ``), + ...metrics.map((key) => ``), + '', + '', + '', + ...summary.map((key) => ``), + ...metrics.map((key) => ``), + '', + '', + '', + '', + ...summary.map((key) => ``), + ...metrics.map((key) => ``), + '', + '', + '', + ...summary.map((key) => ``), + ...metrics.map((key) => ``), + '', + '', + '
BundlesModulesEntriesImportsSize
StaticDynamicRenderedGzipBrotli
Before${formatNumber(before.summary[key])}${formatBytes(before.metrics[key])}
After${formatNumber(after.summary[key])}${formatBytes(after.metrics[key])}
Δ${calcAndFormatDeltaNumber(before.summary[key], after.summary[key], 0)}${calcAndFormatDeltaBytes(before.metrics[key], after.metrics[key], 1000)}
Δ (%)${calcAndFormatDeltaPercent(before.summary[key], after.summary[key], 0.1)}${calcAndFormatDeltaPercent(before.metrics[key], after.metrics[key], 0.1)}
', + ].join('\n'); +} diff --git a/packages-private/diagnostics-frontend-bundle/test/__snapshots__/render-md.md b/packages-private/diagnostics-frontend-bundle/test/__snapshots__/render-md.md new file mode 100644 index 0000000000..867cbf3b75 --- /dev/null +++ b/packages-private/diagnostics-frontend-bundle/test/__snapshots__/render-md.md @@ -0,0 +1,100 @@ +## 📦 Frontend Bundle Report + +
+Chunk size diff (2 updated, 0 added, 0 removed) + +| Chunk | Before | After | Δ | Δ (%) | +| --- | ---: | ---: | ---: | ---: | +| (total) | 120 KB | 127 KB | $\color{orange}{\text{+6.3 KB}}$ | $\color{orange}{\text{+5.2\\%}}$ | +| | | | | | +|
`vue` `assets/vue-b2.js`
| 90 KB | 96 KB | $\color{orange}{\text{+6 KB}}$ | $\color{orange}{\text{+6.7\\%}}$ | +| (other generated chunks) | 1.2 KB | 1.5 KB | $\text{+300 B}$ | $\color{orange}{\text{+25\\%}}$ | +| (other) | 20 KB | 20 KB | $\text{+3 B}$ | $\text{+0\\%}$ | + +
+ +
+Startup chunk size (2 updated, 0 added, 0 removed) + +| Chunk | Before | After | Δ | Δ (%) | +| --- | ---: | ---: | ---: | ---: | +| (total) | 114 KB | 120 KB | $\color{orange}{\text{+6 KB}}$ | $\color{orange}{\text{+5.3\\%}}$ | +| | | | | | +|
`vue` `assets/vue-b2.js`
| 90 KB | 96 KB | $\color{orange}{\text{+6 KB}}$ | $\color{orange}{\text{+6.7\\%}}$ | +| (other) | 24 KB | 24 KB | $\text{+3 B}$ | $\text{+0\\%}$ | + +_Startup chunks are the Vite entry for `src/_boot_.ts` and its static imports._ + +
+ + +## Bundle Stats + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BundlesModulesEntriesImportsSize
StaticDynamicRenderedGzipBrotli
Before2612321 KB6.3 KB5.3 KB
After2713331 KB8.4 KB7 KB
Δ0$\color{orange}{\text{+1}}$0$\color{orange}{\text{+1}}$0$\color{orange}{\text{+9.8 KB}}$$\color{orange}{\text{+2.1 KB}}$$\color{orange}{\text{+1.8 KB}}$
Δ (%)0%$\color{orange}{\text{+16.7\%}}$0%$\color{orange}{\text{+50\%}}$0%$\color{orange}{\text{+46.7\%}}$$\color{orange}{\text{+33.3\%}}$$\color{orange}{\text{+33.3\%}}$
+ +[Open treemap HTML](https://example.invalid/treemap) \ No newline at end of file diff --git a/packages-private/diagnostics-frontend-bundle/test/fixtures/after-stats.json b/packages-private/diagnostics-frontend-bundle/test/fixtures/after-stats.json new file mode 100644 index 0000000000..b69e3f4cea --- /dev/null +++ b/packages-private/diagnostics-frontend-bundle/test/fixtures/after-stats.json @@ -0,0 +1 @@ +{"nodeParts": {"p0": {"renderedLength": 1100.0, "gzipLength": 300, "brotliLength": 250}, "p1": {"renderedLength": 2200.0, "gzipLength": 600, "brotliLength": 500}, "p2": {"renderedLength": 3300.0000000000005, "gzipLength": 900, "brotliLength": 750}, "p3": {"renderedLength": 4400.0, "gzipLength": 1200, "brotliLength": 1000}, "p4": {"renderedLength": 5500.0, "gzipLength": 1500, "brotliLength": 1250}, "p5": {"renderedLength": 6600.000000000001, "gzipLength": 1800, "brotliLength": 1500}, "p6": {"renderedLength": 7700.000000000001, "gzipLength": 2100, "brotliLength": 1750}}, "nodeMetas": {"m0": {"id": "/src/mod0.ts", "isEntry": true, "importedBy": [], "imported": [{"id": "m1", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p0"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m1": {"id": "/src/mod1.ts", "isEntry": false, "importedBy": ["m0"], "imported": [{"id": "m2", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p1"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m2": {"id": "/src/mod2.ts", "isEntry": false, "importedBy": ["m1"], "imported": [{"id": "m3", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p2"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m3": {"id": "/src/mod3.ts", "isEntry": false, "importedBy": ["m2"], "imported": [{"id": "m4", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p3"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m4": {"id": "/src/mod4.ts", "isEntry": false, "importedBy": ["m3"], "imported": [{"id": "m5", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p4"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m5": {"id": "/src/mod5.ts", "isEntry": false, "importedBy": ["m4"], "imported": [{"id": "m6", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p5"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m6": {"id": "/src/mod6.ts", "isEntry": false, "importedBy": ["m5"], "imported": [], "moduleParts": {"assets/boot-a1.js": "p6"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}}, "options": {}} \ No newline at end of file diff --git a/packages-private/diagnostics-frontend-bundle/test/fixtures/before-stats.json b/packages-private/diagnostics-frontend-bundle/test/fixtures/before-stats.json new file mode 100644 index 0000000000..0a54b93148 --- /dev/null +++ b/packages-private/diagnostics-frontend-bundle/test/fixtures/before-stats.json @@ -0,0 +1 @@ +{"nodeParts": {"p0": {"renderedLength": 1000, "gzipLength": 300, "brotliLength": 250}, "p1": {"renderedLength": 2000, "gzipLength": 600, "brotliLength": 500}, "p2": {"renderedLength": 3000, "gzipLength": 900, "brotliLength": 750}, "p3": {"renderedLength": 4000, "gzipLength": 1200, "brotliLength": 1000}, "p4": {"renderedLength": 5000, "gzipLength": 1500, "brotliLength": 1250}, "p5": {"renderedLength": 6000, "gzipLength": 1800, "brotliLength": 1500}}, "nodeMetas": {"m0": {"id": "/src/mod0.ts", "isEntry": true, "importedBy": [], "imported": [{"id": "m1", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p0"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m1": {"id": "/src/mod1.ts", "isEntry": false, "importedBy": ["m0"], "imported": [{"id": "m2", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p1"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m2": {"id": "/src/mod2.ts", "isEntry": false, "importedBy": ["m1"], "imported": [{"id": "m3", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p2"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m3": {"id": "/src/mod3.ts", "isEntry": false, "importedBy": ["m2"], "imported": [{"id": "m4", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p3"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m4": {"id": "/src/mod4.ts", "isEntry": false, "importedBy": ["m3"], "imported": [{"id": "m5", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p4"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m5": {"id": "/src/mod5.ts", "isEntry": false, "importedBy": ["m4"], "imported": [], "moduleParts": {"assets/vue-b2.js": "p5"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}}, "options": {}} \ No newline at end of file diff --git a/packages-private/diagnostics-frontend-bundle/test/render-md.test.ts b/packages-private/diagnostics-frontend-bundle/test/render-md.test.ts new file mode 100644 index 0000000000..17adcef883 --- /dev/null +++ b/packages-private/diagnostics-frontend-bundle/test/render-md.test.ts @@ -0,0 +1,105 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { afterAll, beforeAll, expect, test } from 'vitest'; +import { collectReport } from '../src/manifest'; +import { renderBundleReportMarkdown } from '../src/report'; +import type { VisualizerReport } from '../src/visualizer'; + +const fixturesDir = join(import.meta.dirname, 'fixtures'); + +/** + * ビルド成果物のfixture。 + * + * `collectReport` はファイルの中身を見ずサイズしか使わないので、実体は指定バイト数の + * 詰め物でよい。ディレクトリ名が `built` になるためリポジトリにはコミットできず + * (ルートの .gitignore がビルド成果物として除外する)、テスト実行時に組み立てている。 + */ +const manifest = { + 'src/_boot_.ts': { file: 'assets/boot-a1.js', src: 'src/_boot_.ts', name: 'boot', isEntry: true, imports: ['_vue.js', '_i18n.js'] }, + '_vue.js': { file: 'assets/vue-b2.js', name: 'vue' }, + // `scripts/` 配下はロケール別に出力されるので ja-JP/ に解決される + '_i18n.js': { file: 'scripts/i18n-c3.js', name: 'i18n' }, + 'src/pages/foo.vue': { file: 'assets/foo-d4.js', src: 'src/pages/foo.vue', name: 'foo' }, + // .js 以外はチャンクとして数えない + 'src/pages/style.css': { file: 'assets/style-e5.css', src: 'src/pages/style.css' }, +}; + +const fileSizes = { + before: { + 'assets/boot-a1.js': 20_000, + 'assets/vue-b2.js': 90_000, + 'assets/foo-d4.js': 5_000, + 'assets/style-e5.css': 100, + 'ja-JP/i18n-c3.js': 4_000, + 'ja-JP/orphan.js': 1_200, + }, + after: { + // 差が小さすぎる (閾値5バイト以下) ので「(other)」に集約される + 'assets/boot-a1.js': 20_003, + // 明確に増えるので diff表に行として出る + 'assets/vue-b2.js': 96_000, + 'assets/foo-d4.js': 5_000, + 'assets/style-e5.css': 100, + 'ja-JP/i18n-c3.js': 4_000, + // manifestに載らない出力なので「(other generated chunks)」に集約される + 'ja-JP/orphan.js': 1_500, + }, +} as const satisfies Record<'before' | 'after', Record>; + +let repoDirs: { before: string; after: string }; +let workDir: string; + +beforeAll(async () => { + workDir = await mkdtemp(join(tmpdir(), 'diagnostics-frontend-bundle-')); + + for (const label of ['before', 'after'] as const) { + const outDir = join(workDir, label, 'built/_frontend_vite_'); + await mkdir(outDir, { recursive: true }); + await writeFile(join(outDir, 'manifest.json'), JSON.stringify(manifest)); + + for (const [file, size] of Object.entries(fileSizes[label])) { + const path = join(outDir, file); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, 'x'.repeat(size)); + } + } + + repoDirs = { + before: join(workDir, 'before'), + after: join(workDir, 'after'), + }; +}); + +afterAll(async () => { + await rm(workDir, { recursive: true, force: true }); +}); + +async function loadStats(name: string) { + return JSON.parse(await readFile(join(fixturesDir, `${name}-stats.json`), 'utf8')) as VisualizerReport; +} + +/** + * 出力をゴールデンファイルで固定する。 + * 意図的に変更したときは `vitest -u` で更新し、__snapshots__ の差分もレビューすること。 + */ +test('renders the frontend bundle report', async () => { + const markdown = renderBundleReportMarkdown( + await collectReport(repoDirs.before), + await collectReport(repoDirs.after), + await loadStats('before'), + await loadStats('after'), + { visualizerArtifactUrl: 'https://example.invalid/treemap' }, + ); + + await expect(markdown).toMatchFileSnapshot('./__snapshots__/render-md.md'); +}); + +test('fails loudly when the built output is missing', async () => { + await expect(collectReport(join(workDir, 'nonexistent'))).rejects.toThrow(); +}); diff --git a/packages-private/diagnostics-frontend-bundle/tsconfig.json b/packages-private/diagnostics-frontend-bundle/tsconfig.json new file mode 100644 index 0000000000..6672c40acb --- /dev/null +++ b/packages-private/diagnostics-frontend-bundle/tsconfig.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "strictFunctionTypes": true, + "strictNullChecks": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["node"] + }, + "include": [ + "src/**/*.ts", + "test/**/*.ts" + ], + "exclude": [] +} diff --git a/packages-private/diagnostics-shared/eslint.config.js b/packages-private/diagnostics-shared/eslint.config.js new file mode 100644 index 0000000000..11bc648243 --- /dev/null +++ b/packages-private/diagnostics-shared/eslint.config.js @@ -0,0 +1,25 @@ +import tsParser from '@typescript-eslint/parser'; +import sharedConfig from '../../packages/shared/eslint.config.js'; + +// eslint-disable-next-line import/no-default-export +export default [ + ...sharedConfig, + { + ignores: [ + '**/node_modules', + '**/__snapshots__', + 'test/fixtures', + ], + }, + { + files: ['**/*.ts', '**/*.tsx'], + languageOptions: { + parserOptions: { + parser: tsParser, + project: ['./tsconfig.json'], + sourceType: 'module', + tsconfigRootDir: import.meta.dirname, + }, + }, + }, +]; diff --git a/packages-private/diagnostics-shared/package.json b/packages-private/diagnostics-shared/package.json new file mode 100644 index 0000000000..1fd0a14682 --- /dev/null +++ b/packages-private/diagnostics-shared/package.json @@ -0,0 +1,23 @@ +{ + "name": "diagnostics-shared", + "private": true, + "type": "module", + "exports": { + "./env": "./src/env.ts", + "./format": "./src/format.ts", + "./html": "./src/html.ts", + "./stats": "./src/stats.ts", + "./heap-snapshot": "./src/heap-snapshot/index.ts" + }, + "scripts": { + "eslint": "eslint './**/*.{js,jsx,ts,tsx}'", + "lint": "pnpm typecheck && pnpm eslint", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/node": "26.1.1", + "typescript": "5.9.3", + "vitest": "4.1.10" + } +} diff --git a/packages-private/diagnostics-shared/src/env.ts b/packages-private/diagnostics-shared/src/env.ts new file mode 100644 index 0000000000..0875cb2373 --- /dev/null +++ b/packages-private/diagnostics-shared/src/env.ts @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export function readIntegerEnv(name: string, defaultValue: number, min: number) { + const rawValue = process.env[name]; + if (rawValue == null || rawValue === '') return defaultValue; + if (!/^\d+$/.test(rawValue)) throw new Error(`${name} must be an integer`); + + const value = Number(rawValue); + if (!Number.isSafeInteger(value) || value < min) throw new Error(`${name} must be >= ${min}`); + return value; +} + +export function readBooleanEnv(name: string, defaultValue: boolean) { + const rawValue = process.env[name]; + if (rawValue == null || rawValue === '') return defaultValue; + if (rawValue === '1' || rawValue === 'true') return true; + if (rawValue === '0' || rawValue === 'false') return false; + throw new Error(`${name} must be one of: 1, 0, true, false`); +} + +/** + * 必須の環境変数を読む。未設定・空文字ならエラーにする。 + */ +export function readRequiredEnv(name: string) { + const rawValue = process.env[name]?.trim(); + if (rawValue == null || rawValue === '') throw new Error(`${name} must be set`); + return rawValue; +} + +/** + * 任意の環境変数を読む。未設定・空文字なら null を返す。 + */ +export function readOptionalEnv(name: string) { + const rawValue = process.env[name]?.trim(); + if (rawValue == null || rawValue === '') return null; + return rawValue; +} diff --git a/packages-private/diagnostics-shared/src/format.ts b/packages-private/diagnostics-shared/src/format.ts new file mode 100644 index 0000000000..75dc835e7b --- /dev/null +++ b/packages-private/diagnostics-shared/src/format.ts @@ -0,0 +1,116 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +const numberFormatter = new Intl.NumberFormat('en-US', { + maximumFractionDigits: 1, +}); + +export function escapeLatex(text: string) { + return text + .replaceAll('\\', '\\\\') + .replaceAll('{', '\\{') + .replaceAll('}', '\\}') + .replaceAll('%', '\\%'); +} + +export function escapeMdTableCell(value: string) { + return String(value).replaceAll('|', '\\|').replaceAll('\n', '
'); +} + +export function escapeHtml(value: unknown) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll('\'', '''); +} + +export function formatNumber(value: number) { + return numberFormatter.format(value); +} + +export function formatBytes(value: number) { + if (value === 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB']; + let unitIndex = 0; + let size = value; + while (size >= 1000 && unitIndex < units.length - 1) { + size /= 1000; + unitIndex += 1; + } + + const maximumFractionDigits = size >= 10 || unitIndex === 0 ? 0 : 1; + return `${numberFormatter.format(Number(size.toFixed(maximumFractionDigits)))} ${units[unitIndex]}`; +} + +/** + * KiB単位の値をMB表記にする。/proc 由来の値の表示に使う。 + */ +export function formatKiBAsMb(valueKiB: number | null | undefined) { + if (valueKiB == null || !Number.isFinite(valueKiB)) return '-'; + return `${formatNumber(valueKiB / 1000)} MB`; +} + +export function formatPercent(value: number) { + return `${formatNumber(value)}%`; +} + +export function formatMs(value: number | null | undefined) { + if (value == null || !Number.isFinite(value)) return '-'; + if (value >= 1_000) return `${formatNumber(value / 1_000)} s`; + return `${formatNumber(value)} ms`; +} + +export function formatSecondsAsMs(value: number | null | undefined) { + if (value == null || !Number.isFinite(value)) return '-'; + return formatMs(value * 1_000); +} + +/** + * 差分値を符号付きで整形する。`colorThreshold` 以上の変化があるときだけ色を付ける。 + */ +export function formatColoredDelta(delta: number, text: (value: number) => string, colorThreshold = 0) { + if (delta === 0) return text(0); + const sign = delta > 0 ? '+' : '-'; + if (Math.abs(delta) < colorThreshold) return `$\\text{${sign}${escapeLatex(text(Math.abs(delta)))}}$`; + const color = delta > 0 ? 'orange' : 'green'; + return `$\\color{${color}}{\\text{${sign}${escapeLatex(text(Math.abs(delta)))}}}$`; +} + +export function formatDeltaBytes(deltaBytes: number, colorThreshold = 0) { + return formatColoredDelta(deltaBytes, formatBytes, colorThreshold); +} + +export function formatDeltaPercent(deltaPercent: number, colorThreshold = 0) { + return formatColoredDelta(deltaPercent, formatPercent, colorThreshold); +} + +/** + * Markdownのテーブルセル内に置く差分パーセント。 + * LaTeX由来の `\%` がMarkdownのエスケープで食われるため二重エスケープする。 + */ +export function formatDeltaPercentInMdTable(deltaPercent: number, colorThreshold = 0) { + return formatDeltaPercent(deltaPercent, colorThreshold).replaceAll('\\%', '\\\\%'); +} + +export function calcAndFormatDeltaNumber(before: number | null | undefined, after: number | null | undefined, colorThreshold = 0) { + if (before == null || after == null) return '-'; + return formatColoredDelta(after - before, formatNumber, colorThreshold); +} + +export function calcAndFormatDeltaBytes(before: number | null | undefined, after: number | null | undefined, colorThreshold = 0) { + if (before == null || after == null) return '-'; + return formatDeltaBytes(after - before, colorThreshold); +} + +export function calcAndFormatDeltaPercent(before: number | null | undefined, after: number | null | undefined, colorThreshold = 0) { + if (before == null || before === 0 || after == null) return '-'; + return formatDeltaPercent((after - before) / before * 100, colorThreshold); +} + +export function calcAndFormatDeltaPercentInMdTable(before: number | null | undefined, after: number | null | undefined, colorThreshold = 0) { + return calcAndFormatDeltaPercent(before, after, colorThreshold).replaceAll('\\%', '\\\\%'); +} diff --git a/packages-private/diagnostics-shared/src/heap-snapshot/analyze.ts b/packages-private/diagnostics-shared/src/heap-snapshot/analyze.ts new file mode 100644 index 0000000000..b3443e1889 --- /dev/null +++ b/packages-private/diagnostics-shared/src/heap-snapshot/analyze.ts @@ -0,0 +1,198 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { classifyHeapSnapshotBreakdown, collapseHeapSnapshotBreakdowns } from './breakdown'; +import { + createEmptyHeapSnapshotData, + heapSnapshotBreakdownCategories, + type HeapSnapshotCategory, + type HeapSnapshotData, +} from './categories'; + +/** + * `.heapsnapshot` のJSONを、フィールドオフセットを解決した状態で扱うためのビュー。 + */ +type HeapSnapshotView = { + nodes: number[]; + edges: number[]; + strings: string[]; + nodeFieldCount: number; + edgeFieldCount: number; + nodeTypeNames: string[]; + edgeTypeNames: string[]; + typeOffset: number; + nameOffset: number; + selfSizeOffset: number; + edgeCountOffset: number; + edgeTypeOffset: number; + edgeNameOffset: number; + edgeToNodeOffset: number; + extraNativeBytes: number; +}; + +function requireOffsets(fields: string[], names: string[], what: string) { + const offsets = names.map(name => fields.indexOf(name)); + if (offsets.some(offset => offset < 0)) throw new Error(`Heap snapshot is missing required ${what} fields`); + return offsets; +} + +function parseHeapSnapshot(snapshot: any): HeapSnapshotView { + const meta = snapshot?.snapshot?.meta; + const { nodes, edges, strings } = snapshot ?? {}; + if (meta == null || !Array.isArray(nodes) || !Array.isArray(edges) || !Array.isArray(strings)) { + throw new Error('Invalid heap snapshot format'); + } + + const nodeFields = meta.node_fields; + if (!Array.isArray(nodeFields)) throw new Error('Invalid heap snapshot node fields'); + const edgeFields = meta.edge_fields; + if (!Array.isArray(edgeFields)) throw new Error('Invalid heap snapshot edge fields'); + + const [typeOffset, nameOffset, selfSizeOffset, edgeCountOffset] = requireOffsets(nodeFields, ['type', 'name', 'self_size', 'edge_count'], 'node'); + const [edgeTypeOffset, edgeNameOffset, edgeToNodeOffset] = requireOffsets(edgeFields, ['type', 'name_or_index', 'to_node'], 'edge'); + + const nodeTypeNames = meta.node_types?.[typeOffset]; + if (!Array.isArray(nodeTypeNames)) throw new Error('Invalid heap snapshot node types'); + const edgeTypeNames = meta.edge_types?.[edgeTypeOffset]; + if (!Array.isArray(edgeTypeNames)) throw new Error('Invalid heap snapshot edge types'); + + return { + nodes, + edges, + strings, + nodeFieldCount: nodeFields.length, + edgeFieldCount: edgeFields.length, + nodeTypeNames, + edgeTypeNames, + typeOffset, + nameOffset, + selfSizeOffset, + edgeCountOffset, + edgeTypeOffset, + edgeNameOffset, + edgeToNodeOffset, + extraNativeBytes: Number.isFinite(snapshot.snapshot.extra_native_bytes) ? snapshot.snapshot.extra_native_bytes : 0, + }; +} + +/** + * ノードごとのedge開始位置と、各ノードが何本のedgeから参照されているかを求める。 + * JS配列のelementsストアが専有されているか判定するために使う。 + */ +function indexEdges(view: HeapSnapshotView) { + const edgeStartIndexes = new Map(); + const retainerCounts = new Map(); + let edgeIndex = 0; + + for (let nodeIndex = 0; nodeIndex < view.nodes.length; nodeIndex += view.nodeFieldCount) { + edgeStartIndexes.set(nodeIndex, edgeIndex); + const edgeCount = view.nodes[nodeIndex + view.edgeCountOffset] ?? 0; + for (let i = 0; i < edgeCount; i++, edgeIndex += view.edgeFieldCount) { + const toNodeIndex = view.edges[edgeIndex + view.edgeToNodeOffset]; + retainerCounts.set(toNodeIndex, (retainerCounts.get(toNodeIndex) ?? 0) + 1); + } + } + + return { edgeStartIndexes, retainerCounts }; +} + +export function analyzeHeapSnapshot(snapshot: any, options: { breakdownTopN?: number } = {}): HeapSnapshotData { + const view = parseHeapSnapshot(snapshot); + const { nodes, edges, strings, nodeFieldCount, edgeFieldCount, nodeTypeNames, edgeTypeNames } = view; + + const nativeType = nodeTypeNames.indexOf('native'); + const codeType = nodeTypeNames.indexOf('code'); + const hiddenType = nodeTypeNames.indexOf('hidden'); + const stringTypes = new Set([ + nodeTypeNames.indexOf('string'), + nodeTypeNames.indexOf('concatenated string'), + nodeTypeNames.indexOf('sliced string'), + ]); + const internalEdgeType = edgeTypeNames.indexOf('internal'); + + const { categories, nodeCounts } = createEmptyHeapSnapshotData(); + const breakdowns = {} as Record>; + for (const category of heapSnapshotBreakdownCategories) { + breakdowns[category] = {}; + } + + const { edgeStartIndexes, retainerCounts } = indexEdges(view); + const jsArrayElementNodeIndexes = new Set(); + + function addCategoryValue(category: HeapSnapshotCategory, value: number, type: string, name: string, counted = true) { + if (value <= 0) return; + categories[category] += value; + const label = classifyHeapSnapshotBreakdown(category, type, name); + breakdowns[category][label] = (breakdowns[category][label] ?? 0) + value; + if (counted) nodeCounts[category]++; + } + + /** + * 配列オブジェクト自身のself_sizeには要素ストアが含まれないため、 + * その配列だけが参照しているelementsノードの分を JS arrays に加算する。 + */ + function addJsArrayElementSize(nodeIndex: number) { + const beginEdgeIndex = edgeStartIndexes.get(nodeIndex) ?? 0; + const edgeCount = nodes[nodeIndex + view.edgeCountOffset] ?? 0; + for (let i = 0, currentEdgeIndex = beginEdgeIndex; i < edgeCount; i++, currentEdgeIndex += edgeFieldCount) { + if (edges[currentEdgeIndex + view.edgeTypeOffset] !== internalEdgeType) continue; + if (strings[edges[currentEdgeIndex + view.edgeNameOffset]] !== 'elements') continue; + + const elementsNodeIndex = edges[currentEdgeIndex + view.edgeToNodeOffset]; + if ((retainerCounts.get(elementsNodeIndex) ?? 0) === 1) { + addCategoryValue('jsArrays', nodes[elementsNodeIndex + view.selfSizeOffset] ?? 0, 'array elements', 'Array elements'); + jsArrayElementNodeIndexes.add(elementsNodeIndex); + } + break; + } + } + + if (view.extraNativeBytes > 0) { + addCategoryValue('otherNonJsObjects', view.extraNativeBytes, 'extra native bytes', 'extra native bytes', false); + } + + for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) { + const typeId = nodes[nodeIndex + view.typeOffset]; + const type = nodeTypeNames[typeId] ?? 'unknown'; + const name = strings[nodes[nodeIndex + view.nameOffset]] ?? ''; + const selfSize = nodes[nodeIndex + view.selfSizeOffset] ?? 0; + categories.total += selfSize; + nodeCounts.total++; + + if (typeId === hiddenType) { + addCategoryValue('systemObjects', selfSize, type, name); + } else if (typeId === nativeType) { + addCategoryValue(name === 'system / JSArrayBufferData' ? 'typedArrays' : 'otherNonJsObjects', selfSize, type, name); + } else if (typeId === codeType) { + addCategoryValue('code', selfSize, type, name); + } else if (stringTypes.has(typeId)) { + addCategoryValue('strings', selfSize, type, name); + } else if (name === 'Array') { + addCategoryValue('jsArrays', selfSize, type, name); + addJsArrayElementSize(nodeIndex); + } + } + + categories.total += view.extraNativeBytes; + + // 上のループで JS arrays に計上したelementsノードが確定してから、残りを Other JS objects に振り分ける + for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) { + if (jsArrayElementNodeIndexes.has(nodeIndex)) continue; + + const typeId = nodes[nodeIndex + view.typeOffset]; + if (typeId === hiddenType || typeId === nativeType || typeId === codeType || stringTypes.has(typeId)) continue; + + const name = strings[nodes[nodeIndex + view.nameOffset]] ?? ''; + if (name === 'Array') continue; + + addCategoryValue('otherJsObjects', nodes[nodeIndex + view.selfSizeOffset] ?? 0, nodeTypeNames[typeId] ?? 'unknown', name); + } + + return { + categories, + nodeCounts, + breakdowns: collapseHeapSnapshotBreakdowns(breakdowns, options.breakdownTopN), + }; +} diff --git a/packages-private/diagnostics-shared/src/heap-snapshot/breakdown.ts b/packages-private/diagnostics-shared/src/heap-snapshot/breakdown.ts new file mode 100644 index 0000000000..0dd5ffe324 --- /dev/null +++ b/packages-private/diagnostics-shared/src/heap-snapshot/breakdown.ts @@ -0,0 +1,94 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { + defaultHeapSnapshotBreakdownTopN, + heapSnapshotBreakdownCategories, + type HeapSnapshotCategory, + type HeapSnapshotData, +} from './categories'; + +function sanitizeLabel(value: unknown, fallback = 'unknown') { + const label = String(value ?? '').replace(/\s+/g, ' ').trim(); + if (label === '') return fallback; + if (label.length <= 80) return label; + return `${label.slice(0, 77)}...`; +} + +/** + * ノードの type / name から、内訳テーブルに出す粒度のラベルを決める。 + */ +export function classifyHeapSnapshotBreakdown(category: HeapSnapshotCategory, type: string, name: string) { + switch (category) { + case 'strings': + return type; + + case 'jsArrays': + if (type === 'array elements') return 'Array elements'; + if (type === 'object' && name === 'Array') return 'Array objects'; + return sanitizeLabel(`${type}: ${name}`); + + case 'typedArrays': + if (name === 'system / JSArrayBufferData') return 'ArrayBuffer data'; + return sanitizeLabel(`${type}: ${name}`); + + case 'systemObjects': + if (name.startsWith('system /') || name.startsWith('(system ')) return sanitizeLabel(name); + return sanitizeLabel(`${type}: ${name}`, type); + + case 'otherJsObjects': + if (type === 'object') return sanitizeLabel(`object: ${name}`, 'object: unknown'); + return type; + + case 'otherNonJsObjects': + if (type === 'extra native bytes') return 'Extra native bytes'; + if (type === 'native') return sanitizeLabel(`native: ${name}`, 'native: unknown'); + return sanitizeLabel(`${type}: ${name}`, type); + + case 'code': { + const lowerName = name.toLowerCase(); + if (lowerName.includes('bytecode')) return 'bytecode'; + if (lowerName.includes('builtin')) return 'builtins'; + if (lowerName.includes('regexp')) return 'regexp code'; + if (lowerName.includes('stub')) return 'stubs'; + return sanitizeLabel(`code: ${name}`, 'code: unknown'); + } + + default: + return sanitizeLabel(`${type}: ${name}`, type); + } +} + +/** + * 内訳を上位N件に丸め、残りを `Other` にまとめる。 + */ +export function collapseHeapSnapshotBreakdown(breakdown: Record, topN = defaultHeapSnapshotBreakdownTopN) { + const entries = Object.entries(breakdown) + .filter(([, value]) => value > 0) + .toSorted((a, b) => b[1] - a[1]); + + const collapsed = Object.fromEntries(entries.slice(0, topN)); + const otherValue = entries.slice(topN).reduce((sum, [, value]) => sum + value, 0); + if (otherValue > 0) collapsed.Other = otherValue; + return collapsed; +} + +export function collapseHeapSnapshotBreakdowns( + breakdowns: Partial>>, + topN = defaultHeapSnapshotBreakdownTopN, +) { + const collapsed: NonNullable = {}; + for (const category of heapSnapshotBreakdownCategories) { + const categoryBreakdown = breakdowns[category]; + if (categoryBreakdown == null) continue; + + const collapsedCategory = collapseHeapSnapshotBreakdown(categoryBreakdown, topN); + if (Object.keys(collapsedCategory).length > 0) { + collapsed[category] = collapsedCategory; + } + } + + return collapsed; +} diff --git a/packages-private/diagnostics-shared/src/heap-snapshot/categories.ts b/packages-private/diagnostics-shared/src/heap-snapshot/categories.ts new file mode 100644 index 0000000000..4e7caed6d8 --- /dev/null +++ b/packages-private/diagnostics-shared/src/heap-snapshot/categories.ts @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +// Chrome DevTools の heap snapshot Statistics ビューと同じ分類になるように保つこと。 +export const heapSnapshotCategory = { + total: { label: 'Total', color: 'gray', colorHex: '#888888' }, + code: { label: 'Code', color: 'orange', colorHex: '#f28e2c' }, + strings: { label: 'Strings', color: 'red', colorHex: '#e15759' }, + jsArrays: { label: 'JS arrays', color: 'cyan', colorHex: '#76b7b2' }, + typedArrays: { label: 'Typed arrays', color: 'green', colorHex: '#59a14f' }, + systemObjects: { label: 'System objects', color: 'yellow', colorHex: '#edc949' }, + otherJsObjects: { label: 'Other JS objs', color: 'violet', colorHex: '#af7aa1' }, + otherNonJsObjects: { label: 'Other non-JS objs', color: 'pink', colorHex: '#ff9da7' }, +} as const satisfies Record; + +export type HeapSnapshotCategory = keyof typeof heapSnapshotCategory; + +export const heapSnapshotCategories = Object.keys(heapSnapshotCategory) as HeapSnapshotCategory[]; + +/** `total` は他カテゴリの合算ではなく全体量なので、内訳を扱うときは除外する */ +export const heapSnapshotBreakdownCategories = heapSnapshotCategories.filter(category => category !== 'total'); + +export type HeapSnapshotData = { + categories: Record; + nodeCounts: Record; + /** 内訳が空でないカテゴリだけが入る (`total` は内訳を持たない) */ + breakdowns?: Partial>>; +}; + +export type HeapSnapshotReport = { + summary: HeapSnapshotData; + samples: { + round: number; + data: HeapSnapshotData; + }[]; +}; + +export const defaultHeapSnapshotBreakdownTopN = 6; + +export function createEmptyHeapSnapshotData(): HeapSnapshotData { + const categories = {} as HeapSnapshotData['categories']; + const nodeCounts = {} as HeapSnapshotData['nodeCounts']; + for (const category of heapSnapshotCategories) { + categories[category] = 0; + nodeCounts[category] = 0; + } + return { + categories, + nodeCounts, + breakdowns: {}, + }; +} diff --git a/packages-private/diagnostics-shared/src/heap-snapshot/index.ts b/packages-private/diagnostics-shared/src/heap-snapshot/index.ts new file mode 100644 index 0000000000..0634efac87 --- /dev/null +++ b/packages-private/diagnostics-shared/src/heap-snapshot/index.ts @@ -0,0 +1,10 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export * from './analyze'; +export * from './breakdown'; +export * from './categories'; +export * from './render'; +export * from './summarize'; diff --git a/packages-private/diagnostics-shared/src/heap-snapshot/render.ts b/packages-private/diagnostics-shared/src/heap-snapshot/render.ts new file mode 100644 index 0000000000..7690c834c5 --- /dev/null +++ b/packages-private/diagnostics-shared/src/heap-snapshot/render.ts @@ -0,0 +1,177 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { + formatBytes, + formatDeltaBytes, + formatDeltaPercentInMdTable, + formatPercent, +} from '../format'; +import { mad, pairedDeltaSummary } from '../stats'; +import { + heapSnapshotCategories, + heapSnapshotCategory, + type HeapSnapshotCategory, + type HeapSnapshotReport, +} from './categories'; + +/** これ未満のバイト差分には色を付けない (0.1 MB) */ +const byteColorThreshold = 100_000; + +function categoryValue(report: HeapSnapshotReport, category: HeapSnapshotCategory) { + return report.summary.categories[category]; +} + +function categorySampleSpread(report: HeapSnapshotReport, category: HeapSnapshotCategory) { + const values = report.samples + .map(sample => sample.data.categories[category]) + .filter(value => Number.isFinite(value)); + if (values.length < 2) throw new Error(`Not enough samples for category ${category}`); + + return mad(values); +} + +function swatch(category: HeapSnapshotCategory) { + return `$\\color{${heapSnapshotCategory[category].color}}{\\rule{8pt}{8pt}}$ **${heapSnapshotCategory[category].label}**`; +} + +/** + * base / head のheap snapshotをカテゴリ別に比較するMarkdownテーブルを描画する。 + */ +export function renderHeapSnapshotTable(base: HeapSnapshotReport, head: HeapSnapshotReport) { + const lines = [ + '| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |', + '| --- | ---: | ---: | ---: | ---: | ---: | ---: |', + ]; + const baseTotal = categoryValue(base, 'total'); + const headTotal = categoryValue(head, 'total'); + + for (const category of heapSnapshotCategories) { + const baseValue = categoryValue(base, category); + const headValue = categoryValue(head, category); + const summary = pairedDeltaSummary(base.samples, head.samples, sample => sample.data.categories[category]); + const deltaColumns = `${formatBytes(summary.mad)} | ${formatDeltaBytes(summary.min, byteColorThreshold)} | ${formatDeltaBytes(summary.max, byteColorThreshold)}`; + + if (category === 'total') { + // Totalだけはばらつきと変化率も併記する + const percent = summary.median * 100 / baseValue; + const deltaMedian = `${formatDeltaBytes(summary.median, byteColorThreshold)}
${formatDeltaPercentInMdTable(percent, 0.1)}`; + const baseText = `${formatBytes(baseValue)}
± ${formatBytes(categorySampleSpread(base, category))}`; + const headText = `${formatBytes(headValue)}
± ${formatBytes(categorySampleSpread(head, category))}`; + lines.push(`| ${swatch(category)} | ${baseText} | ${headText} | ${deltaMedian} | ${deltaColumns} |`); + lines.push('| | | | | | | |'); + } else { + // 各カテゴリはTotalに占める割合の推移をdetailsで見せる + const basePercent = formatPercent((baseValue * 100) / baseTotal); + const headPercent = formatPercent((headValue * 100) / headTotal); + const metricText = `
${swatch(category)}${basePercent} → ${headPercent}
`; + lines.push(`| ${metricText} | ${formatBytes(baseValue)} | ${formatBytes(headValue)} | ${formatDeltaBytes(summary.median, byteColorThreshold)} | ${deltaColumns} |`); + } + } + + if (lines.length === 2) return null; + return lines.join('\n'); +} + +const sankeyChildMinRatio = 0.3; +const sankeyParentMinPercent = 10; + +function escapeCsvValue(value: string) { + return `"${String(value).replaceAll('"', '""')}"`; +} + +function formatSankeyPercentValue(value: number) { + const rounded = Math.round(value * 100) / 100; + if (rounded === 0 && value > 0) return '0.01'; + if (Number.isInteger(rounded)) return String(rounded); + return rounded.toFixed(2).replace(/0+$/, '').replace(/\.$/, ''); +} + +/** + * heap snapshotの構成比をmermaidのsankey図として描画する。 + * 全体に占める割合が小さいカテゴリ・内訳は `Other` にまとめる。 + */ +export function renderHeapSnapshotSankey(report: HeapSnapshotReport, title: string) { + const total = categoryValue(report, 'total'); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (total == null || total <= 0) return null; + + const categories = heapSnapshotCategories + .filter(category => category !== 'total') + .map(category => { + const value = categoryValue(report, category); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (value == null || value <= 0) return null; + + const breakdownEntries = Object.entries(report.summary.breakdowns?.[category] ?? {}) + .filter(([, childValue]) => Number.isFinite(childValue) && childValue > 0) + .toSorted((a, b) => b[1] - a[1]); + const breakdownTotal = breakdownEntries.reduce((sum, [, childValue]) => sum + childValue, 0); + const percent = (value * 100) / total; + const childEntries: [string, number][] = []; + let otherPercent = 0; + + if (breakdownTotal > 0 && percent > sankeyParentMinPercent) { + for (const [childName, childValue] of breakdownEntries) { + const childRatio = childValue / breakdownTotal; + if (childRatio >= sankeyChildMinRatio) { + childEntries.push([childName.replace(/^[^:]+:\s*/, ''), percent * childRatio]); + } else { + otherPercent += percent * childRatio; + } + } + + if (childEntries.length > 0 && otherPercent > 0) { + childEntries.push(['Other', otherPercent]); + } + } + + return { category, percent, childEntries }; + }) + .filter(value => value != null); + + if (categories.length === 0) return null; + + const nodeColors: Record = { + [title]: heapSnapshotCategory.total.colorHex, + Other: '#888888', + }; + for (const { category, childEntries } of categories) { + nodeColors[category] = heapSnapshotCategory[category].colorHex; + for (const [childName] of childEntries) { + nodeColors[childName] = heapSnapshotCategory[category].colorHex; + } + } + + const lines = [ + `
${title} heap snapshot composition`, + '', + '```mermaid', + `%%{init: ${JSON.stringify({ + sankey: { + showValues: false, + linkColor: 'target', + labelStyle: 'outlined', + nodeAlignment: 'center', + nodePadding: 10, + nodeColors, + }, + })}}%%`, + 'sankey-beta', + ]; + + for (const { category, percent, childEntries } of categories) { + const categoryLabel = heapSnapshotCategory[category].label; + lines.push(`${escapeCsvValue(title)},${escapeCsvValue(categoryLabel)},${formatSankeyPercentValue(percent)}`); + + for (const [childName, childPercent] of childEntries) { + lines.push(`${escapeCsvValue(categoryLabel)},${escapeCsvValue(childName)},${formatSankeyPercentValue(childPercent)}`); + } + } + + lines.push('```', '', '
'); + + return lines.join('\n'); +} diff --git a/packages-private/diagnostics-shared/src/heap-snapshot/summarize.ts b/packages-private/diagnostics-shared/src/heap-snapshot/summarize.ts new file mode 100644 index 0000000000..bbc2a6d203 --- /dev/null +++ b/packages-private/diagnostics-shared/src/heap-snapshot/summarize.ts @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { finiteMedian } from '../stats'; +import { collapseHeapSnapshotBreakdown } from './breakdown'; +import { + heapSnapshotBreakdownCategories, + heapSnapshotCategories, + type HeapSnapshotCategory, + type HeapSnapshotData, +} from './categories'; + +function isComplete(values: Partial>): values is Record { + return heapSnapshotCategories.every(category => values[category] != null); +} + +/** + * 複数ラウンド分のheap snapshotを、カテゴリ・内訳ごとの中央値にまとめる。 + * 全カテゴリ分の値が揃わなければ null を返す。 + */ +export function summarizeHeapSnapshotDataSamples( + samples: T[], + getData: (sample: T) => HeapSnapshotData | null | undefined, + options: { breakdownTopN?: number } = {}, +) { + const data = samples.map(getData); + + const categories: Partial = {}; + const nodeCounts: Partial = {}; + for (const category of heapSnapshotCategories) { + const categoryValue = finiteMedian(data.map(snapshot => snapshot?.categories?.[category])); + if (categoryValue != null) categories[category] = categoryValue; + + const nodeCountValue = finiteMedian(data.map(snapshot => snapshot?.nodeCounts?.[category])); + if (nodeCountValue != null) nodeCounts[category] = nodeCountValue; + } + + // 一部のカテゴリだけ欠けた状態で返すと、呼び出し側が完全な値として扱って + // undefined を描画してしまう。全カテゴリ揃っていなければサマリ自体を無しとする + if (!isComplete(categories) || !isComplete(nodeCounts)) return null; + + const breakdowns: NonNullable = {}; + for (const category of heapSnapshotBreakdownCategories) { + const childKeys = new Set(data.flatMap(snapshot => Object.keys(snapshot?.breakdowns?.[category] ?? {}))); + + const categoryBreakdown = {} as Record; + for (const childKey of childKeys) { + const value = finiteMedian(data.map(snapshot => snapshot?.breakdowns?.[category]?.[childKey])); + if (value != null) categoryBreakdown[childKey] = value; + } + + const collapsed = collapseHeapSnapshotBreakdown(categoryBreakdown, options.breakdownTopN); + if (Object.keys(collapsed).length > 0) breakdowns[category] = collapsed; + } + + return { + categories, + nodeCounts, + ...(Object.keys(breakdowns).length > 0 ? { breakdowns } : {}), + } satisfies HeapSnapshotData; +} diff --git a/packages-private/diagnostics-shared/src/html.ts b/packages-private/diagnostics-shared/src/html.ts new file mode 100644 index 0000000000..907c089656 --- /dev/null +++ b/packages-private/diagnostics-shared/src/html.ts @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { escapeHtml } from './format'; + +/** + * エスケープ済み、あるいは意図的にエスケープしない生のHTML断片。 + * + * ただの文字列と型で区別するためだけの存在ではなく、`html` が実行時に + * 「この値はもうエスケープしなくてよい」と判定するためのマーカーでもある。 + * 型だけのブランドにすると実行時に判定できず、結局エスケープ漏れを防げない。 + */ +export class Raw { + constructor(private readonly value: string) {} + + toString() { + return this.value; + } +} + +/** + * 文字列をエスケープせずそのまま埋め込む。 + * 呼び出しが差分に残るので、レビューで「なぜ生で入れてよいのか」を確認できる。 + */ +export function raw(value: string) { + return new Raw(value); +} + +function interpolate(value: unknown): string { + if (value instanceof Raw) return value.toString(); + // 配列をそのまま文字列化するとカンマ区切りで潰れるので、要素ごとに処理する + if (Array.isArray(value)) return value.map(interpolate).join(''); + if (value == null) return ''; + return escapeHtml(value); +} + +/** + * HTMLを組み立てるタグ付きテンプレート。補間値は既定でエスケープされる。 + * エスケープしたくない場合は `raw()` で包む必要があるため、 + * 「うっかり生のまま埋め込む」ことが起きない。 + */ +export function html(strings: TemplateStringsArray, ...values: unknown[]) { + let result = strings[0]; + for (let i = 0; i < values.length; i++) { + result += interpolate(values[i]) + strings[i + 1]; + } + return new Raw(result); +} + +/** + * 断片を指定の区切り文字で連結する。 + * `html` の配列補間は区切り無しで繋ぐので、改行などを挟みたいときはこちらを使う。 + */ +export function joinHtml(parts: readonly Raw[], separator: string) { + return new Raw(parts.map(part => part.toString()).join(separator)); +} diff --git a/packages-private/diagnostics-shared/src/stats.ts b/packages-private/diagnostics-shared/src/stats.ts new file mode 100644 index 0000000000..7516242c32 --- /dev/null +++ b/packages-private/diagnostics-shared/src/stats.ts @@ -0,0 +1,84 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export function median(values: number[]) { + const sorted = values.toSorted((a, b) => a - b); + const center = Math.floor(sorted.length / 2); + if (sorted.length % 2 === 1) return sorted[center]; + return Math.round((sorted[center - 1] + sorted[center]) / 2); +} + +export function mad(values: number[]) { + if (values.length < 2) throw new Error('Not enough samples to calculate MAD'); + + const center = median(values); + return median(values.map(value => Math.abs(value - center))); +} + +/** + * 有限値のみを対象に中央値を求める。有限値が1つも無い場合は `defaultValue` を返す。 + */ +export function finiteMedian(values: (number | null | undefined)[]): number | null; +export function finiteMedian(values: (number | null | undefined)[], defaultValue: number): number; +export function finiteMedian(values: (number | null | undefined)[], defaultValue: number | null = null) { + const finiteValues = values.filter(value => Number.isFinite(value)) as number[]; + if (finiteValues.length === 0) return defaultValue; + return median(finiteValues); +} + +/** + * サンプルのばらつき (MAD) を求める。サンプルが2つ未満で求められない場合は null を返す。 + */ +export function sampleSpread(values: (number | null | undefined)[]) { + const finiteValues = values.filter(value => Number.isFinite(value)) as number[]; + if (finiteValues.length < 2) return null; + return mad(finiteValues); +} + +type RoundedSample = { round: number }; + +function indexByRound(samples: T[]) { + const samplesByRound = new Map(); + for (const sample of samples) { + // 負のroundはwarmupを表すため対象外 + if (sample.round <= 0) continue; + samplesByRound.set(sample.round, sample); + } + return samplesByRound; +} + +/** + * base / head を同じroundどうしで突き合わせ、その差分の分布を要約する。 + * 実行順による揺らぎの影響を抑えるため、単純な集計値どうしの引き算ではなくペア差分を使う。 + */ +export function pairedDeltaSummary(baseSamples: T[], headSamples: T[], getValue: (sample: T) => number | null | undefined) { + const baseSamplesByRound = indexByRound(baseSamples); + const headSamplesByRound = indexByRound(headSamples); + const values: number[] = []; + + for (const [round, baseSample] of baseSamplesByRound) { + const headSample = headSamplesByRound.get(round); + if (headSample == null) continue; + + const baseValue = getValue(baseSample); + const headValue = getValue(headSample); + if (baseValue == null || headValue == null) continue; + + values.push(headValue - baseValue); + } + + // 対応するroundが1つも無いと中央値も最小/最大も定義できない。 + // 静かにNaNやInfinityをレポートに載せるより、比較が成立していないと分かる形で落とす + if (values.length === 0) throw new Error('No paired samples to compare: base and head have no rounds in common'); + + return { + median: median(values), + // 1サンプルでは中央値からの偏差が常に0になる (mad() は統計として無意味なので拒否する) + mad: values.length < 2 ? 0 : mad(values), + min: Math.min(...values), + max: Math.max(...values), + samples: values.length, + }; +} diff --git a/packages-private/diagnostics-shared/test/format.test.ts b/packages-private/diagnostics-shared/test/format.test.ts new file mode 100644 index 0000000000..e391d86283 --- /dev/null +++ b/packages-private/diagnostics-shared/test/format.test.ts @@ -0,0 +1,102 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, test } from 'vitest'; +import { + calcAndFormatDeltaPercent, + calcAndFormatDeltaPercentInMdTable, + escapeHtml, + escapeLatex, + escapeMdTableCell, + formatBytes, + formatColoredDelta, + formatKiBAsMb, + formatMs, + formatNumber, +} from '../src/format'; + +describe('formatBytes', () => { + // 1024ではなく1000区切り。単位が2桁以上なら小数を落とす + test.each([ + [0, '0 B'], + [999, '999 B'], + [1_000, '1 KB'], + [1_500, '1.5 KB'], + [15_000, '15 KB'], + [1_234_567, '1.2 MB'], + [12_345_678, '12 MB'], + [1_500_000_000, '1.5 GB'], + [1_500_000_000_000, '1,500 GB'], + ])('formats %i as %s', (input, expected) => { + expect(formatBytes(input)).toBe(expected); + }); +}); + +describe('formatColoredDelta', () => { + test('leaves zero uncoloured and unsigned', () => { + expect(formatColoredDelta(0, formatNumber)).toBe('0'); + }); + + test('colours growth orange and shrinkage green', () => { + expect(formatColoredDelta(5, formatNumber)).toBe('$\\color{orange}{\\text{+5}}$'); + expect(formatColoredDelta(-5, formatNumber)).toBe('$\\color{green}{\\text{-5}}$'); + }); + + test('omits colour below the threshold but keeps the sign', () => { + expect(formatColoredDelta(5, formatNumber, 10)).toBe('$\\text{+5}$'); + }); +}); + +describe('escaping', () => { + test('escapeHtml covers the five metacharacters', () => { + expect(escapeHtml(`&<>"'`)).toBe('&<>"''); + }); + + test('escapeHtml maps nullish to an empty string', () => { + expect(escapeHtml(null)).toBe(''); + expect(escapeHtml(undefined)).toBe(''); + }); + + test('escapeLatex escapes braces and percent', () => { + expect(escapeLatex('100% {x}')).toBe('100\\% \\{x\\}'); + }); + + test('escapeMdTableCell keeps pipes and newlines from breaking the table', () => { + expect(escapeMdTableCell('a|b\nc')).toBe('a\\|b
c'); + }); +}); + +describe('percent helpers', () => { + // before が0だと変化率そのものが定義できない + test('returns a placeholder when the baseline is zero or missing', () => { + expect(calcAndFormatDeltaPercent(0, 10)).toBe('-'); + expect(calcAndFormatDeltaPercent(null, 10)).toBe('-'); + expect(calcAndFormatDeltaPercent(10, null)).toBe('-'); + }); + + // 0になったのは「消えた」という有効な結果なので、隠さず -100% として出す + test('formats a drop to zero as -100%', () => { + expect(calcAndFormatDeltaPercent(10, 0)).toBe('$\\color{green}{\\text{-100\\%}}$'); + }); + + // Markdownのテーブルセル内ではLaTeXの \% がさらに食われるため二重にする + test('doubles the percent escape for markdown table cells', () => { + expect(calcAndFormatDeltaPercentInMdTable(100, 110)).toContain('\\\\%'); + expect(calcAndFormatDeltaPercent(100, 110)).not.toContain('\\\\%'); + }); +}); + +describe('nullish handling', () => { + test('formatKiBAsMb and formatMs render a dash for missing values', () => { + expect(formatKiBAsMb(null)).toBe('-'); + expect(formatKiBAsMb(Number.NaN)).toBe('-'); + expect(formatMs(undefined)).toBe('-'); + }); + + test('formatMs switches to seconds past 1000ms', () => { + expect(formatMs(999)).toBe('999 ms'); + expect(formatMs(1_500)).toBe('1.5 s'); + }); +}); diff --git a/packages-private/diagnostics-shared/test/html.test.ts b/packages-private/diagnostics-shared/test/html.test.ts new file mode 100644 index 0000000000..6e9dbc48b0 --- /dev/null +++ b/packages-private/diagnostics-shared/test/html.test.ts @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, test } from 'vitest'; +import { html, joinHtml, raw, Raw } from '../src/html'; + +describe('html', () => { + test('escapes interpolated values by default', () => { + const value = ''; + expect(String(html`

${value}

`)).toBe('

<script>alert(1)</script>

'); + }); + + test('escapes values used in attribute position', () => { + const url = 'x">'; + expect(String(html``)).toBe(''); + }); + + test('leaves the static parts of the template untouched', () => { + expect(String(html`

x

`)).toBe('

x

'); + }); + + test('renders nullish values as an empty string', () => { + expect(String(html`

${null}${undefined}

`)).toBe('

'); + }); + + test('does not double-escape nested fragments', () => { + const inner = html`${'a&b'}`; + expect(String(html`

${inner}

`)).toBe('

a&b

'); + }); + + // 配列をそのまま文字列化するとカンマ区切りで潰れる。実際に過去これで表が壊れた + test('joins arrays without separators instead of stringifying them', () => { + const items = [html`
  • 1
  • `, html`
  • 2
  • `]; + expect(String(html`
      ${items}
    `)).toBe('
    • 1
    • 2
    '); + }); + + test('escapes array elements that are not fragments', () => { + expect(String(html`

    ${['', '']}

    `)).toBe('

    <a><b>

    '); + }); +}); + +describe('raw', () => { + test('embeds trusted markup without escaping', () => { + expect(String(html``)).toBe(''); + }); + + test('produces a Raw fragment', () => { + expect(raw('x')).toBeInstanceOf(Raw); + expect(html`x`).toBeInstanceOf(Raw); + }); +}); + +describe('joinHtml', () => { + test('joins fragments with the given separator', () => { + expect(String(joinHtml([html`
  • 1
  • `, html`
  • 2
  • `], '\n'))).toBe('
  • 1
  • \n
  • 2
  • '); + }); + + test('returns an empty fragment for an empty list', () => { + expect(String(joinHtml([], '\n'))).toBe(''); + }); +}); diff --git a/packages-private/diagnostics-shared/test/stats.test.ts b/packages-private/diagnostics-shared/test/stats.test.ts new file mode 100644 index 0000000000..1d57f0d67d --- /dev/null +++ b/packages-private/diagnostics-shared/test/stats.test.ts @@ -0,0 +1,109 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, test } from 'vitest'; +import { finiteMedian, mad, median, pairedDeltaSummary, sampleSpread } from '../src/stats'; + +describe('median', () => { + test('takes the middle value of an odd-length sample', () => { + expect(median([3, 1, 2])).toBe(2); + }); + + // 偶数長では平均を整数に丸める。KiB単位の整数値を扱う前提のため + test('rounds the average of an even-length sample', () => { + expect(median([1, 2])).toBe(2); + expect(median([1, 4])).toBe(3); + }); +}); + +describe('mad', () => { + test('measures the median absolute deviation', () => { + expect(mad([1, 1, 1])).toBe(0); + expect(mad([1, 2, 3])).toBe(1); + }); + + test('refuses to compute from a single sample', () => { + expect(() => mad([1])).toThrow(); + }); +}); + +describe('finiteMedian', () => { + test('ignores non-finite entries', () => { + expect(finiteMedian([1, null, undefined, Number.NaN, 3])).toBe(2); + }); + + test('returns null by default when nothing is finite', () => { + expect(finiteMedian([null, undefined])).toBeNull(); + }); + + test('returns the supplied default when nothing is finite', () => { + expect(finiteMedian([null, undefined], 0)).toBe(0); + }); +}); + +describe('sampleSpread', () => { + test('needs at least two finite samples', () => { + expect(sampleSpread([1])).toBeNull(); + expect(sampleSpread([1, null])).toBeNull(); + expect(sampleSpread([1, 3])).toBe(1); + }); +}); + +describe('pairedDeltaSummary', () => { + const base = [ + { round: 1, value: 100 }, + { round: 2, value: 200 }, + { round: 3, value: 300 }, + ]; + + test('compares base and head within the same round', () => { + const head = [ + { round: 1, value: 110 }, + { round: 2, value: 230 }, + { round: 3, value: 320 }, + ]; + + expect(pairedDeltaSummary(base, head, sample => sample.value)).toStrictEqual({ + median: 20, + mad: 10, + min: 10, + max: 30, + samples: 3, + }); + }); + + test('drops rounds that only one side has', () => { + const head = [ + { round: 1, value: 110 }, + { round: 2, value: 230 }, + { round: 9, value: 999 }, + ]; + + expect(pairedDeltaSummary(base, head, sample => sample.value).samples).toBe(2); + }); + + // 1サンプルでも中央値・最小・最大は定まる (偏差は常に0) + test('summarizes a single paired round without treating MAD as an error', () => { + expect(pairedDeltaSummary([base[0]], [{ round: 1, value: 130 }], sample => sample.value)).toStrictEqual({ + median: 30, + mad: 0, + min: 30, + max: 30, + samples: 1, + }); + }); + + test('fails loudly when no round is shared', () => { + expect(() => pairedDeltaSummary(base, [{ round: 9, value: 1 }], sample => sample.value)).toThrow(/no rounds in common/); + }); + + // 負のroundはwarmupを表すので集計に混ぜない + test('ignores warmup rounds', () => { + const warmupBase = [{ round: -1, value: 0 }, ...base]; + const warmupHead = [{ round: -1, value: 9999 }, { round: 1, value: 110 }, { round: 2, value: 230 }, { round: 3, value: 320 }]; + + expect(pairedDeltaSummary(warmupBase, warmupHead, sample => sample.value).samples).toBe(3); + }); +}); diff --git a/packages-private/diagnostics-shared/tsconfig.json b/packages-private/diagnostics-shared/tsconfig.json new file mode 100644 index 0000000000..6672c40acb --- /dev/null +++ b/packages-private/diagnostics-shared/tsconfig.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "strictFunctionTypes": true, + "strictNullChecks": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["node"] + }, + "include": [ + "src/**/*.ts", + "test/**/*.ts" + ], + "exclude": [] +} diff --git a/packages/backend/scripts/measure-memory.mts b/packages/backend/scripts/measure-memory.mts deleted file mode 100644 index a95eb3075c..0000000000 --- a/packages/backend/scripts/measure-memory.mts +++ /dev/null @@ -1,303 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { ChildProcess, fork } from 'node:child_process'; -import { dirname, join, resolve } from 'node:path'; -import { tmpdir } from 'node:os'; -import * as fs from 'node:fs/promises'; -import { analyzeHeapSnapshot, defaultHeapSnapshotBreakdownTopN, type HeapSnapshotData } from '../../../.github/scripts/heap-snapshot-util.mts'; -import { measureMemoryUntilStable } from '../../../.github/scripts/memory-stability-util.mts'; - -const backendDir = process.env.MK_MEMORY_BACKEND_DIR == null || process.env.MK_MEMORY_BACKEND_DIR === '' - ? join(import.meta.dirname, '..') - : resolve(process.env.MK_MEMORY_BACKEND_DIR); - -function readIntegerEnv(name, defaultValue, min) { - const rawValue = process.env[name]; - if (rawValue == null || rawValue === '') return defaultValue; - if (!/^\d+$/.test(rawValue)) throw new Error(`${name} must be an integer`); - - const value = Number(rawValue); - if (!Number.isSafeInteger(value) || value < min) throw new Error(`${name} must be >= ${min}`); - return value; -} - -function readBooleanEnv(name, defaultValue) { - const rawValue = process.env[name]; - if (rawValue == null || rawValue === '') return defaultValue; - if (rawValue === '1' || rawValue === 'true') return true; - if (rawValue === '0' || rawValue === 'false') return false; - throw new Error(`${name} must be one of: 1, 0, true, false`); -} - -const STARTUP_TIMEOUT = readIntegerEnv('MK_MEMORY_STARTUP_TIMEOUT_MS', 120000, 1); // Timeout for server startup -const IPC_TIMEOUT = readIntegerEnv('MK_MEMORY_IPC_TIMEOUT_MS', 30000, 1); // Timeout for IPC responses -const HEAP_SNAPSHOT = readBooleanEnv('MK_MEMORY_HEAP_SNAPSHOT', false); -const HEAP_SNAPSHOT_TIMEOUT = readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_TIMEOUT_MS', 120000, 1); -const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', defaultHeapSnapshotBreakdownTopN, 1); -const HEAP_SNAPSHOT_SAVE_PATH = process.env.MK_MEMORY_HEAP_SNAPSHOT_SAVE_PATH; - -const procStatusKeys = ['VmPeak', 'VmSize', 'VmHWM', 'VmRSS', 'VmData', 'VmStk', 'VmExe', 'VmLib', 'VmPTE', 'VmSwap'] as const; -const smapsRollupKeys = ['Pss', 'Shared_Clean', 'Shared_Dirty', 'Private_Clean', 'Private_Dirty', 'Swap', 'SwapPss'] as const; - -type GcMessage = 'gc ok' | 'gc unavailable'; -type RuntimeMemoryUsageMessage = { - type: 'memory usage'; - value: NodeJS.MemoryUsage; -}; -type HeapSnapshotMessage = { - type: 'heap snapshot'; - path?: string; -}; -type HeapSnapshotErrorMessage = { - type: 'heap snapshot error'; - message: string; -}; -type HeapSnapshotResponseMessage = HeapSnapshotMessage | HeapSnapshotErrorMessage; - -function parseMemoryFile(content: string, keys: KS, path: string): Record { - const result = {} as Record; - for (const _key of keys) { - const key = _key as KS[number]; - const match = content.match(new RegExp(`${key}:\\s+(\\d+)\\s+kB`)); - if (match) { - result[key] = parseInt(match[1], 10); - } else { - throw new Error(`Failed to parse ${key} from ${path}`); - } - } - return result; -} - -function bytesToKiB(value: number) { - return Math.round(value / 1024); -} - -async function getMemoryUsage(pid: number) { - const path = `/proc/${pid}/status`; - const status = await fs.readFile(path, 'utf-8'); - return parseMemoryFile(status, procStatusKeys, path); -} - -async function getSmapsRollupMemoryUsage(pid: number) { - const path = `/proc/${pid}/smaps_rollup`; - const smapsRollup = await fs.readFile(path, 'utf-8'); - return parseMemoryFile(smapsRollup, smapsRollupKeys, path); -} - -function isRecord(value: unknown): value is Record { - return value != null && typeof value === 'object'; -} - -function isGcMessage(message: unknown): message is GcMessage { - return message === 'gc ok' || message === 'gc unavailable'; -} - -function isRuntimeMemoryUsageMessage(message: unknown): message is RuntimeMemoryUsageMessage { - return isRecord(message) && message.type === 'memory usage' && isRecord(message.value); -} - -function isHeapSnapshotResponseMessage(message: unknown): message is HeapSnapshotResponseMessage { - if (!isRecord(message)) return false; - if (message.type === 'heap snapshot') return true; - return message.type === 'heap snapshot error' && typeof message.message === 'string'; -} - -function waitForMessage(serverProcess: ChildProcess, predicate: (message: unknown) => message is T, description: string, timeout = IPC_TIMEOUT) { - return new Promise((resolve, reject) => { - const timer = globalThis.setTimeout(() => { - serverProcess.off('message', onMessage); - reject(new Error(`Timed out waiting for ${description}`)); - }, timeout); - - const onMessage = (message: unknown) => { - if (!predicate(message)) return; - globalThis.clearTimeout(timer); - serverProcess.off('message', onMessage); - resolve(message); - }; - - serverProcess.on('message', onMessage); - }); -} - -async function getRuntimeMemoryUsage(serverProcess: ChildProcess) { - const response = waitForMessage( - serverProcess, - isRuntimeMemoryUsageMessage, - 'memory usage', - ); - - serverProcess.send('memory usage'); - - const message = await response; - const memoryUsage = message.value; - - return { - HeapTotal: bytesToKiB(memoryUsage.heapTotal), - HeapUsed: bytesToKiB(memoryUsage.heapUsed), - External: bytesToKiB(memoryUsage.external), - ArrayBuffers: bytesToKiB(memoryUsage.arrayBuffers), - }; -} - -async function getHeapSnapshotStatistics(serverProcess: ChildProcess): Promise { - if (!HEAP_SNAPSHOT) return null; - - const snapshotPath = join(tmpdir(), `misskey-backend-heap-${process.pid}-${serverProcess.pid}-${Date.now()}.heapsnapshot`); - const response = waitForMessage( - serverProcess, - isHeapSnapshotResponseMessage, - 'heap snapshot', - HEAP_SNAPSHOT_TIMEOUT, - ); - - serverProcess.send({ - type: 'heap snapshot', - path: snapshotPath, - }); - - const message = await response; - if (message.type === 'heap snapshot error') { - throw new Error(`Failed to write heap snapshot: ${message.message}`); - } - - const writtenPath = typeof message.path === 'string' ? message.path : snapshotPath; - - try { - if (HEAP_SNAPSHOT_SAVE_PATH != null && HEAP_SNAPSHOT_SAVE_PATH !== '') { - await fs.mkdir(dirname(HEAP_SNAPSHOT_SAVE_PATH), { recursive: true }); - await fs.copyFile(writtenPath, HEAP_SNAPSHOT_SAVE_PATH); - } - - const snapshot = JSON.parse(await fs.readFile(writtenPath, 'utf-8')); - return analyzeHeapSnapshot(snapshot, { breakdownTopN: HEAP_SNAPSHOT_BREAKDOWN_TOP_N }); - } finally { - await fs.unlink(writtenPath).catch(err => { - process.stderr.write(`Failed to delete heap snapshot ${writtenPath}: ${err.message}\n`); - }); - } -} - -async function getAllMemoryUsage(serverProcess: ChildProcess) { - const pid = serverProcess.pid!; - const stableSmapsRollup = await measureMemoryUntilStable( - () => getSmapsRollupMemoryUsage(pid), - ); - - return { - memoryUsage: { - ...await getMemoryUsage(pid), - ...stableSmapsRollup.memoryUsage, - ...await getRuntimeMemoryUsage(serverProcess), - }, - stability: stableSmapsRollup.stability, - }; -} - -async function measureMemory() { - const serverProcess = fork(join(backendDir, 'built/entry.js'), [], { - cwd: backendDir, - env: { - ...process.env, - NODE_ENV: 'production', - MK_DISABLE_CLUSTERING: '1', - MK_ONLY_SERVER: '1', - MK_NO_DAEMONS: '1', - }, - stdio: ['pipe', 'pipe', 'pipe', 'ipc'], - execArgv: [...process.execArgv, '--expose-gc'], - }); - - const serverReady = waitForMessage( - serverProcess, - (message): message is 'ok' => message === 'ok', - 'server startup', - STARTUP_TIMEOUT, - ); - - 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`); - }); - - async function triggerGc() { - const ok = waitForMessage( - serverProcess, - isGcMessage, - 'GC completion', - ); - - serverProcess.send('gc'); - - const message = await ok; - if (message === 'gc unavailable') { - throw new Error('GC is unavailable. Start the process with --expose-gc to enable this feature.'); - } - } - - const startupStartTime = Date.now(); - try { - await serverReady; - } catch (err) { - serverProcess.kill('SIGTERM'); - throw err; - } - - const startupTime = Date.now() - startupStartTime; - process.stderr.write(`Server started in ${startupTime}ms\n`); - - await triggerGc(); - - const afterGc = await getAllMemoryUsage(serverProcess); - process.stderr.write(`Memory ${afterGc.stability.converged ? 'stabilized' : 'did not stabilize'} after ${afterGc.stability.readingCount} readings over ${Math.round(afterGc.stability.elapsedMs)}ms\n`); - - const heapSnapshotAfterGc = await getHeapSnapshotStatistics(serverProcess); - - const serverExited = new Promise(resolve => { - const timer = globalThis.setTimeout(() => { - serverProcess.kill('SIGKILL'); - resolve(); - }, 10000); - serverProcess.once('exit', () => { - globalThis.clearTimeout(timer); - resolve(); - }); - }); - serverProcess.kill('SIGTERM'); - await serverExited; - - return { - timestamp: new Date().toISOString(), - phases: { - afterGc: { - memoryUsage: afterGc.memoryUsage, - memoryStability: afterGc.stability, - heapSnapshot: heapSnapshotAfterGc, - }, - }, - }; -} - -export type MemorySample = Awaited>; - -async function main() { - console.log(JSON.stringify(await measureMemory(), null, 2)); -} - -main().catch((err) => { - console.error(JSON.stringify({ - error: err.message, - timestamp: new Date().toISOString(), - })); - process.exit(1); -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f82d6b63f..53a344d0e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -70,6 +70,126 @@ importers: specifier: npm:@typescript/typescript6@6.0.2 version: '@typescript/typescript6@6.0.2' + packages-private/changelog-checker: + devDependencies: + '@types/mdast': + specifier: 4.0.4 + version: 4.0.4 + '@types/node': + specifier: 26.1.1 + version: 26.1.1 + '@vitest/coverage-v8': + specifier: 4.1.10 + version: 4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10) + mdast-util-to-string: + specifier: 4.0.0 + version: 4.0.0 + remark: + specifier: 15.0.1 + version: 15.0.1 + remark-parse: + specifier: 11.0.0 + version: 11.0.0 + tsx: + specifier: 4.23.1 + version: 4.23.1 + unified: + specifier: 11.0.5 + version: 11.0.5 + vitest: + specifier: 4.1.10 + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(typescript@7.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) + + packages-private/diagnostics-backend: + dependencies: + diagnostics-shared: + specifier: workspace:* + version: link:../diagnostics-shared + execa: + specifier: 9.6.1 + version: 9.6.1 + devDependencies: + '@types/node': + specifier: 26.1.1 + version: 26.1.1 + '@types/pg': + specifier: 8.20.0 + version: 8.20.0 + ioredis: + specifier: 5.11.1 + version: 5.11.1 + pg: + specifier: 8.22.0 + version: 8.22.0 + tsx: + specifier: 4.23.1 + version: 4.23.1 + typescript: + specifier: 5.9.3 + version: 5.9.3 + vitest: + specifier: 4.1.10 + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) + + packages-private/diagnostics-frontend-browser: + dependencies: + diagnostics-shared: + specifier: workspace:* + version: link:../diagnostics-shared + devDependencies: + '@types/node': + specifier: 26.1.1 + version: 26.1.1 + frontend: + specifier: workspace:* + version: link:../../packages/frontend + playwright: + specifier: 1.61.1 + version: 1.61.1 + tsx: + specifier: 4.23.1 + version: 4.23.1 + typescript: + specifier: 5.9.3 + version: 5.9.3 + vitest: + specifier: 4.1.10 + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) + + packages-private/diagnostics-frontend-bundle: + dependencies: + diagnostics-shared: + specifier: workspace:* + version: link:../diagnostics-shared + devDependencies: + '@types/node': + specifier: 26.1.1 + version: 26.1.1 + tsx: + specifier: 4.23.1 + version: 4.23.1 + typescript: + specifier: 5.9.3 + version: 5.9.3 + vite: + specifier: 8.1.4 + version: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1) + vitest: + specifier: 4.1.10 + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) + + packages-private/diagnostics-shared: + devDependencies: + '@types/node': + specifier: 26.1.1 + version: 26.1.1 + typescript: + specifier: 5.9.3 + version: 5.9.3 + vitest: + specifier: 4.1.10 + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) + packages/backend: dependencies: '@aws-sdk/client-s3': @@ -504,10 +624,10 @@ importers: version: 5.1.0 vite: specifier: 8.1.4 - version: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0) + version: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1) vitest: specifier: 4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) vitest-mock-extended: specifier: 4.0.0 version: 4.0.0(@typescript/typescript6@6.0.2)(vitest@4.1.10) @@ -944,7 +1064,7 @@ importers: version: 1.1.5 vite: specifier: 8.1.4 - version: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0) + version: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1) devDependencies: '@types/estree': specifier: 1.0.9 @@ -1263,7 +1383,7 @@ importers: version: 0.33.0 vitest: specifier: 4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) vitest-websocket-mock: specifier: 0.7.0 version: 0.7.0(vitest@4.1.10) @@ -7772,6 +7892,9 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + remark@15.0.1: + resolution: {integrity: sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==} + rename@1.0.4: resolution: {integrity: sha512-YMM6Fn3lrFOCjhORKjj+z/yizj8WSzv3F3YUlpJA20fteWCb0HbJU19nvuRBPUM5dWgxJcHP+kix3M+5NowJyA==} @@ -8674,6 +8797,11 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tsx@4.23.1: + resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==} + engines: {node: '>=18.0.0'} + hasBin: true + tsyringe@4.10.0: resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==} engines: {node: '>= 6.0.0'} @@ -12239,13 +12367,27 @@ snapshots: vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0) vue: 3.5.39(typescript@6.0.2) - '@vitest/browser-playwright@4.1.10(bufferutil@4.1.0)(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(playwright@1.61.1)(utf-8-validate@6.0.6)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0))(vitest@4.1.10)': + '@vitest/browser-playwright@4.1.10(bufferutil@4.1.0)(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(playwright@1.61.1)(utf-8-validate@6.0.6)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1))(vitest@4.1.10)': dependencies: - '@vitest/browser': 4.1.10(bufferutil@4.1.0)(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(utf-8-validate@6.0.6)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0))(vitest@4.1.10) - '@vitest/mocker': 4.1.10(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0)) + '@vitest/browser': 4.1.10(bufferutil@4.1.0)(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(utf-8-validate@6.0.6)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1))(vitest@4.1.10) + '@vitest/mocker': 4.1.10(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) playwright: 1.61.1 tinyrainbow: 3.1.0 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + optional: true + + '@vitest/browser-playwright@4.1.10(bufferutil@4.1.0)(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(playwright@1.61.1)(utf-8-validate@6.0.6)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1))(vitest@4.1.10)': + dependencies: + '@vitest/browser': 4.1.10(bufferutil@4.1.0)(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(utf-8-validate@6.0.6)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1))(vitest@4.1.10) + '@vitest/mocker': 4.1.10(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) + playwright: 1.61.1 + tinyrainbow: 3.1.0 + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) transitivePeerDependencies: - bufferutil - msw @@ -12266,16 +12408,48 @@ snapshots: - utf-8-validate - vite - '@vitest/browser@4.1.10(bufferutil@4.1.0)(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(utf-8-validate@6.0.6)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0))(vitest@4.1.10)': + '@vitest/browser-playwright@4.1.10(bufferutil@4.1.0)(msw@2.15.0(@types/node@26.1.1)(typescript@7.0.2))(playwright@1.61.1)(utf-8-validate@6.0.6)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1))(vitest@4.1.10)': + dependencies: + '@vitest/browser': 4.1.10(bufferutil@4.1.0)(msw@2.15.0(@types/node@26.1.1)(typescript@7.0.2))(utf-8-validate@6.0.6)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1))(vitest@4.1.10) + '@vitest/mocker': 4.1.10(msw@2.15.0(@types/node@26.1.1)(typescript@7.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) + playwright: 1.61.1 + tinyrainbow: 3.1.0 + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(typescript@7.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + optional: true + + '@vitest/browser@4.1.10(bufferutil@4.1.0)(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(utf-8-validate@6.0.6)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1))(vitest@4.1.10)': dependencies: '@blazediff/core': 1.9.1 - '@vitest/mocker': 4.1.10(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0)) + '@vitest/mocker': 4.1.10(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) '@vitest/utils': 4.1.10 magic-string: 0.30.21 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + optional: true + + '@vitest/browser@4.1.10(bufferutil@4.1.0)(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(utf-8-validate@6.0.6)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1))(vitest@4.1.10)': + dependencies: + '@blazediff/core': 1.9.1 + '@vitest/mocker': 4.1.10(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.1.0 + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil @@ -12301,6 +12475,24 @@ snapshots: - utf-8-validate - vite + '@vitest/browser@4.1.10(bufferutil@4.1.0)(msw@2.15.0(@types/node@26.1.1)(typescript@7.0.2))(utf-8-validate@6.0.6)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1))(vitest@4.1.10)': + dependencies: + '@blazediff/core': 1.9.1 + '@vitest/mocker': 4.1.10(msw@2.15.0(@types/node@26.1.1)(typescript@7.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.1.0 + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(typescript@7.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + optional: true + '@vitest/coverage-v8@4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10)': dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -12313,9 +12505,9 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(typescript@7.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) optionalDependencies: - '@vitest/browser': 4.1.10(bufferutil@4.1.0)(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(utf-8-validate@6.0.6)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0))(vitest@4.1.10) + '@vitest/browser': 4.1.10(bufferutil@4.1.0)(msw@2.15.0(@types/node@26.1.1)(typescript@7.0.2))(utf-8-validate@6.0.6)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1))(vitest@4.1.10) '@vitest/expect@2.0.5': dependencies: @@ -12341,14 +12533,23 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.10(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0))': + '@vitest/mocker@4.1.10(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2) - vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0) + vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1) + + '@vitest/mocker@4.1.10(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.15.0(@types/node@26.1.1)(typescript@5.9.3) + vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1) '@vitest/mocker@4.1.10(msw@2.15.0(@types/node@26.1.1)(typescript@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0))': dependencies: @@ -12359,6 +12560,15 @@ snapshots: msw: 2.15.0(@types/node@26.1.1)(typescript@6.0.2) vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0) + '@vitest/mocker@4.1.10(msw@2.15.0(@types/node@26.1.1)(typescript@7.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.15.0(@types/node@26.1.1)(typescript@7.0.2) + vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1) + '@vitest/pretty-format@2.0.5': dependencies: tinyrainbow: 1.2.0 @@ -15563,6 +15773,32 @@ snapshots: - '@types/node' optional: true + msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3): + dependencies: + '@inquirer/confirm': 6.0.12(@types/node@26.1.1) + '@mswjs/interceptors': 0.41.8 + '@open-draft/deferred-promise': 3.0.0 + '@types/statuses': 2.0.6 + cookie: 1.1.1 + graphql: 16.14.0 + headers-polyfill: 5.0.1 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.11.11 + statuses: 2.0.2 + strict-event-emitter: 0.5.1 + tough-cookie: 6.0.1 + type-fest: 5.6.0 + until-async: 3.0.2 + yargs: 17.7.2 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/node' + optional: true + msw@2.15.0(@types/node@26.1.1)(typescript@6.0.2): dependencies: '@inquirer/confirm': 6.0.12(@types/node@26.1.1) @@ -15588,6 +15824,32 @@ snapshots: transitivePeerDependencies: - '@types/node' + msw@2.15.0(@types/node@26.1.1)(typescript@7.0.2): + dependencies: + '@inquirer/confirm': 6.0.12(@types/node@26.1.1) + '@mswjs/interceptors': 0.41.8 + '@open-draft/deferred-promise': 3.0.0 + '@types/statuses': 2.0.6 + cookie: 1.1.1 + graphql: 16.14.0 + headers-polyfill: 5.0.1 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.11.11 + statuses: 2.0.2 + strict-event-emitter: 0.5.1 + tough-cookie: 6.0.1 + type-fest: 5.6.0 + until-async: 3.0.2 + yargs: 17.7.2 + optionalDependencies: + typescript: 7.0.2 + transitivePeerDependencies: + - '@types/node' + optional: true + muggle-string@0.4.1: {} multer@2.2.0: @@ -16494,6 +16756,15 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 + remark@15.0.1: + dependencies: + '@types/mdast': 4.0.4 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + rename@1.0.4: dependencies: debug: 2.6.9 @@ -17482,6 +17753,12 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tsx@4.23.1: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + tsyringe@4.10.0: dependencies: tslib: 1.14.1 @@ -17773,6 +18050,22 @@ snapshots: terser: 5.48.0 tsx: 4.23.0 + vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.5 + postcss: 8.5.18 + rolldown: 1.1.5 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.1.1 + esbuild: 0.28.1 + fsevents: 2.3.3 + sass: 1.100.0 + sass-embedded: 1.100.0 + terser: 5.48.0 + tsx: 4.23.1 + vitest-fetch-mock@0.4.5(vitest@4.1.10): dependencies: vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(typescript@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0)) @@ -17781,17 +18074,17 @@ snapshots: dependencies: ts-essentials: 10.2.0(@typescript/typescript6@6.0.2) typescript: '@typescript/typescript6@6.0.2' - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) vitest-websocket-mock@0.7.0(vitest@4.1.10): dependencies: mock-socket: 9.3.1 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) - vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0)): + vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0)) + '@vitest/mocker': 4.1.10(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -17808,12 +18101,43 @@ snapshots: tinyexec: 1.1.2 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0) + vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/node': 26.1.1 - '@vitest/browser-playwright': 4.1.10(bufferutil@4.1.0)(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(playwright@1.61.1)(utf-8-validate@6.0.6)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0))(vitest@4.1.10) + '@vitest/browser-playwright': 4.1.10(bufferutil@4.1.0)(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(playwright@1.61.1)(utf-8-validate@6.0.6)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1))(vitest@4.1.10) + '@vitest/coverage-v8': 4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10) + happy-dom: 20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - msw + + vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.1.2 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 26.1.1 + '@vitest/browser-playwright': 4.1.10(bufferutil@4.1.0)(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(playwright@1.61.1)(utf-8-validate@6.0.6)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1))(vitest@4.1.10) '@vitest/coverage-v8': 4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10) happy-dom: 20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: @@ -17850,6 +18174,37 @@ snapshots: transitivePeerDependencies: - msw + vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(typescript@7.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(msw@2.15.0(@types/node@26.1.1)(typescript@7.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.1.2 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 26.1.1 + '@vitest/browser-playwright': 4.1.10(bufferutil@4.1.0)(msw@2.15.0(@types/node@26.1.1)(typescript@7.0.2))(playwright@1.61.1)(utf-8-validate@6.0.6)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1))(vitest@4.1.10) + '@vitest/coverage-v8': 4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10) + happy-dom: 20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - msw + void-elements@3.1.0: {} vscode-jsonrpc@8.2.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b6b2b67cf5..c280df00f2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -11,6 +11,7 @@ packages: - packages/misskey-js/generator - packages/misskey-reversi - packages/misskey-bubble-game + - packages-private/* allowBuilds: '@nestjs/core': true '@parcel/watcher': true diff --git a/scripts/changelog-checker/.gitignore b/scripts/changelog-checker/.gitignore deleted file mode 100644 index 882936f9bd..0000000000 --- a/scripts/changelog-checker/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -node_modules -coverage -.idea \ No newline at end of file diff --git a/scripts/changelog-checker/package-lock.json b/scripts/changelog-checker/package-lock.json deleted file mode 100644 index f2f48d92f7..0000000000 --- a/scripts/changelog-checker/package-lock.json +++ /dev/null @@ -1,2773 +0,0 @@ -{ - "name": "changelog-checker", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "changelog-checker", - "version": "1.0.0", - "devDependencies": { - "@types/mdast": "4.0.4", - "@types/node": "26.1.1", - "@vitest/coverage-v8": "4.1.10", - "mdast-util-to-string": "4.0.0", - "remark": "15.0.1", - "remark-parse": "11.0.0", - "typescript": "7.0.2", - "unified": "11.0.5", - "vite": "8.1.4", - "vite-node": "6.0.0", - "vitest": "4.1.10" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@bcoe/v8-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", - "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", - "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.3" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, - "node_modules/@oxc-project/types": { - "version": "0.139.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", - "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", - "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", - "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", - "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", - "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", - "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", - "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", - "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", - "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", - "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", - "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", - "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", - "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", - "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1", - "@napi-rs/wasm-runtime": "^1.1.6" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", - "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", - "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", - "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/debug": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", - "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", - "dev": true, - "dependencies": { - "@types/ms": "*" - } - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/mdast": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", - "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/ms": { - "version": "0.7.34", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", - "integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==", - "dev": true - }, - "node_modules/@types/node": { - "version": "26.1.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", - "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~8.3.0" - } - }, - "node_modules/@types/unist": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.2.tgz", - "integrity": "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==", - "dev": true - }, - "node_modules/@typescript/typescript-aix-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", - "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-darwin-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", - "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-darwin-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", - "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-freebsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", - "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-freebsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", - "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-arm": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", - "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", - "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-loong64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", - "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-mips64el": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", - "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", - "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-riscv64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", - "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-s390x": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", - "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", - "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-netbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", - "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-netbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", - "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-openbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", - "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-openbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", - "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-sunos-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", - "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-win32-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", - "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-win32-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", - "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@vitest/coverage-v8": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", - "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.10", - "ast-v8-to-istanbul": "^1.0.0", - "istanbul-lib-coverage": "^3.2.2", - "istanbul-lib-report": "^3.0.1", - "istanbul-reports": "^3.2.0", - "magicast": "^0.5.2", - "obug": "^2.1.1", - "std-env": "^4.0.0-rc.1", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@vitest/browser": "4.1.10", - "vitest": "4.1.10" - }, - "peerDependenciesMeta": { - "@vitest/browser": { - "optional": true - } - } - }, - "node_modules/@vitest/expect": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", - "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", - "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.1.10", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", - "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", - "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.10", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", - "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.10", - "@vitest/utils": "4.1.10", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", - "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", - "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.10", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/ast-v8-to-istanbul": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", - "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.31", - "estree-walker": "^3.0.3", - "js-tokens": "^10.0.0" - } - }, - "node_modules/bail": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", - "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", - "dev": true, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/cac": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/cac/-/cac-7.0.0.tgz", - "integrity": "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/character-entities": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", - "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", - "dev": true, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decode-named-character-reference": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz", - "integrity": "sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==", - "dev": true, - "dependencies": { - "character-entities": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/devlop": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", - "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", - "dev": true, - "dependencies": { - "dequal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/es-module-lexer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", - "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", - "dev": true, - "license": "MIT" - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/expect-type": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", - "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "dev": true - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true - }, - "node_modules/is-plain-obj": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", - "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/js-tokens": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", - "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/longest-streak": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", - "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", - "dev": true, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/magicast": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", - "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", - "source-map-js": "^1.2.1" - } - }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mdast-util-from-markdown": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.0.tgz", - "integrity": "sha512-n7MTOr/z+8NAX/wmhhDji8O3bRvPTV/U0oTCaZJkjhPSKTPhS3xufVhKGF8s1pJ7Ox4QgoIU7KHseh09S+9rTA==", - "dev": true, - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark": "^4.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-decode-string": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-phrasing": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.0.0.tgz", - "integrity": "sha512-xadSsJayQIucJ9n053dfQwVu1kuXg7jCTdYsMK8rqzKZh52nLfSH/k0sAxE0u+pj/zKZX+o5wB+ML5mRayOxFA==", - "dev": true, - "dependencies": { - "@types/mdast": "^4.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-markdown": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.0.tgz", - "integrity": "sha512-SR2VnIEdVNCJbP6y7kVTJgPLifdr8WEU440fQec7qHoHOUz/oJ2jmNRqdDQ3rbiStOXb2mCDGTuwsK5OPUgYlQ==", - "dev": true, - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "longest-streak": "^3.0.0", - "mdast-util-phrasing": "^4.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark-util-decode-string": "^2.0.0", - "unist-util-visit": "^5.0.0", - "zwitch": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", - "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", - "dev": true, - "dependencies": { - "@types/mdast": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.0.tgz", - "integrity": "sha512-o/sd0nMof8kYff+TqcDx3VSrgBTcZpSvYcAHIfHhv5VAuNmisCxjhx6YmxS8PFEpb9z5WKWKPdzf0jM23ro3RQ==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "@types/debug": "^4.0.0", - "debug": "^4.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "micromark-core-commonmark": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-combine-extensions": "^2.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-subtokenize": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-core-commonmark": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.0.tgz", - "integrity": "sha512-jThOz/pVmAYUtkroV3D5c1osFXAMv9e0ypGDOIZuCeAe91/sD6BoE2Sjzt30yuXtwOYUmySOhMas/PVyh02itA==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "micromark-factory-destination": "^2.0.0", - "micromark-factory-label": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-factory-title": "^2.0.0", - "micromark-factory-whitespace": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-html-tag-name": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-subtokenize": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-destination": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.0.tgz", - "integrity": "sha512-j9DGrQLm/Uhl2tCzcbLhy5kXsgkHUrjJHg4fFAeoMRwJmJerT9aw4FEhIbZStWN8A3qMwOp1uzHr4UL8AInxtA==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-label": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.0.tgz", - "integrity": "sha512-RR3i96ohZGde//4WSe/dJsxOX6vxIg9TimLAS3i4EhBAFx8Sm5SmqVfR8E87DPSR31nEAjZfbt91OMZWcNgdZw==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-space": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.0.tgz", - "integrity": "sha512-TKr+LIDX2pkBJXFLzpyPyljzYK3MtmllMUMODTQJIUfDGncESaqB90db9IAUcz4AZAJFdd8U9zOp9ty1458rxg==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-title": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.0.tgz", - "integrity": "sha512-jY8CSxmpWLOxS+t8W+FG3Xigc0RDQA9bKMY/EwILvsesiRniiVMejYTE4wumNc2f4UbAa4WsHqe3J1QS1sli+A==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-whitespace": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.0.tgz", - "integrity": "sha512-28kbwaBjc5yAI1XadbdPYHX/eDnqaUFVikLwrO7FDnKG7lpgxnvk/XGRhX/PN0mOZ+dBSZ+LgunHS+6tYQAzhA==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-character": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.0.1.tgz", - "integrity": "sha512-3wgnrmEAJ4T+mGXAUfMvMAbxU9RDG43XmGce4j6CwPtVxB3vfwXSZ6KhFwDzZ3mZHhmPimMAXg71veiBGzeAZw==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-chunked": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.0.tgz", - "integrity": "sha512-anK8SWmNphkXdaKgz5hJvGa7l00qmcaUQoMYsBwDlSKFKjc6gjGXPDw3FNL3Nbwq5L8gE+RCbGqTw49FK5Qyvg==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-classify-character": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.0.tgz", - "integrity": "sha512-S0ze2R9GH+fu41FA7pbSqNWObo/kzwf8rN/+IGlW/4tC6oACOs8B++bh+i9bVyNnwCcuksbFwsBme5OCKXCwIw==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-combine-extensions": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.0.tgz", - "integrity": "sha512-vZZio48k7ON0fVS3CUgFatWHoKbbLTK/rT7pzpJ4Bjp5JjkZeasRfrS9wsBdDJK2cJLHMckXZdzPSSr1B8a4oQ==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-chunked": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-numeric-character-reference": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.1.tgz", - "integrity": "sha512-bmkNc7z8Wn6kgjZmVHOX3SowGmVdhYS7yBpMnuMnPzDq/6xwVA604DuOXMZTO1lvq01g+Adfa0pE2UKGlxL1XQ==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-string": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.0.tgz", - "integrity": "sha512-r4Sc6leeUTn3P6gk20aFMj2ntPwn6qpDZqWvYmAG6NgvFTIlj4WtrAudLi65qYoaGdXYViXYw2pkmn7QnIFasA==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-encode": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.0.tgz", - "integrity": "sha512-pS+ROfCXAGLWCOc8egcBvT0kf27GoWMqtdarNfDcjb6YLuV5cM3ioG45Ys2qOVqeqSbjaKg72vU+Wby3eddPsA==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromark-util-html-tag-name": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.0.tgz", - "integrity": "sha512-xNn4Pqkj2puRhKdKTm8t1YHC/BAjx6CEwRFXntTaRf/x16aqka6ouVoutm+QdkISTlT7e2zU7U4ZdlDLJd2Mcw==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromark-util-normalize-identifier": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.0.tgz", - "integrity": "sha512-2xhYT0sfo85FMrUPtHcPo2rrp1lwbDEEzpx7jiH2xXJLqBuy4H0GgXk5ToU8IEwoROtXuL8ND0ttVa4rNqYK3w==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-resolve-all": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.0.tgz", - "integrity": "sha512-6KU6qO7DZ7GJkaCgwBNtplXCvGkJToU86ybBAUdavvgsCiG8lSSvYxr9MhwmQ+udpzywHsl4RpGJsYWG1pDOcA==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-sanitize-uri": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.0.tgz", - "integrity": "sha512-WhYv5UEcZrbAtlsnPuChHUAsu/iBPOVaEVsntLBIdpibO0ddy8OzavZz3iL2xVvBZOpolujSliP65Kq0/7KIYw==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-subtokenize": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.0.0.tgz", - "integrity": "sha512-vc93L1t+gpR3p8jxeVdaYlbV2jTYteDje19rNSS/H5dlhxUYll5Fy6vJ2cDwP8RnsXi818yGty1ayP55y3W6fg==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-symbol": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", - "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromark-util-types": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.0.tgz", - "integrity": "sha512-oNh6S2WMHWRZrmutsRmDDfkzKtxF+bc2VxLC9dvtrDIRFln627VsFP6fLMgTryGDljgLPjkrzQSDcPrjPyDJ5w==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT" - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.20", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.20.tgz", - "integrity": "sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.16", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/remark": { - "version": "15.0.1", - "resolved": "https://registry.npmjs.org/remark/-/remark-15.0.1.tgz", - "integrity": "sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==", - "dev": true, - "dependencies": { - "@types/mdast": "^4.0.0", - "remark-parse": "^11.0.0", - "remark-stringify": "^11.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-parse": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", - "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", - "dev": true, - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-from-markdown": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-stringify": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", - "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", - "dev": true, - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-to-markdown": "^2.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/rolldown": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", - "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.139.0", - "@rolldown/pluginutils": "^1.0.0" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.1.5", - "@rolldown/binding-darwin-arm64": "1.1.5", - "@rolldown/binding-darwin-x64": "1.1.5", - "@rolldown/binding-freebsd-x64": "1.1.5", - "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", - "@rolldown/binding-linux-arm64-gnu": "1.1.5", - "@rolldown/binding-linux-arm64-musl": "1.1.5", - "@rolldown/binding-linux-ppc64-gnu": "1.1.5", - "@rolldown/binding-linux-s390x-gnu": "1.1.5", - "@rolldown/binding-linux-x64-gnu": "1.1.5", - "@rolldown/binding-linux-x64-musl": "1.1.5", - "@rolldown/binding-openharmony-arm64": "1.1.5", - "@rolldown/binding-wasm32-wasi": "1.1.5", - "@rolldown/binding-win32-arm64-msvc": "1.1.5", - "@rolldown/binding-win32-x64-msvc": "1.1.5" - } - }, - "node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", - "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", - "dev": true, - "license": "MIT" - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", - "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/trough": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/trough/-/trough-2.1.0.tgz", - "integrity": "sha512-AqTiAOLcj85xS7vQ8QkAV41hPDIJ71XJB4RCUrzo/1GM2CQwhkJGaf9Hgr7BOugMRpgGUrqRg/DrBDl4H40+8g==", - "dev": true, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true - }, - "node_modules/typescript": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", - "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc" - }, - "engines": { - "node": ">=16.20.0" - }, - "optionalDependencies": { - "@typescript/typescript-aix-ppc64": "7.0.2", - "@typescript/typescript-darwin-arm64": "7.0.2", - "@typescript/typescript-darwin-x64": "7.0.2", - "@typescript/typescript-freebsd-arm64": "7.0.2", - "@typescript/typescript-freebsd-x64": "7.0.2", - "@typescript/typescript-linux-arm": "7.0.2", - "@typescript/typescript-linux-arm64": "7.0.2", - "@typescript/typescript-linux-loong64": "7.0.2", - "@typescript/typescript-linux-mips64el": "7.0.2", - "@typescript/typescript-linux-ppc64": "7.0.2", - "@typescript/typescript-linux-riscv64": "7.0.2", - "@typescript/typescript-linux-s390x": "7.0.2", - "@typescript/typescript-linux-x64": "7.0.2", - "@typescript/typescript-netbsd-arm64": "7.0.2", - "@typescript/typescript-netbsd-x64": "7.0.2", - "@typescript/typescript-openbsd-arm64": "7.0.2", - "@typescript/typescript-openbsd-x64": "7.0.2", - "@typescript/typescript-sunos-x64": "7.0.2", - "@typescript/typescript-win32-arm64": "7.0.2", - "@typescript/typescript-win32-x64": "7.0.2" - } - }, - "node_modules/undici-types": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", - "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/unified": { - "version": "11.0.5", - "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", - "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "bail": "^2.0.0", - "devlop": "^1.0.0", - "extend": "^3.0.0", - "is-plain-obj": "^4.0.0", - "trough": "^2.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-is": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz", - "integrity": "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==", - "dev": true, - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-stringify-position": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", - "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", - "dev": true, - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", - "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", - "dev": true, - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit-parents": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz", - "integrity": "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==", - "dev": true, - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.1.tgz", - "integrity": "sha512-1bYqc7pt6NIADBJ98UiG0Bn/CHIVOoZ/IyEkqIruLg0mE1BKzkOXY2D6CSqQIcKqgadppE5lrxgWXJmXd7zZJw==", - "dev": true, - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-stringify-position": "^4.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile-message": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.2.tgz", - "integrity": "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==", - "dev": true, - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vite": { - "version": "8.1.4", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", - "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.5", - "postcss": "^8.5.16", - "rolldown": "~1.1.4", - "tinyglobby": "^0.2.17" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.3.0", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vite-node": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-6.0.0.tgz", - "integrity": "sha512-oj4PVrT+pDh6GYf5wfUXkcZyekYS8kKPfLPXVl8qe324Ec6l4K2DUKNadRbZ3LQl0qGcDz+PyOo7ZAh00Y+JjQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^7.0.0", - "es-module-lexer": "^2.0.0", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "vite": "^8.0.0" - }, - "bin": { - "vite-node": "dist/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://opencollective.com/antfu" - } - }, - "node_modules/vitest": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", - "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "4.1.10", - "@vitest/mocker": "4.1.10", - "@vitest/pretty-format": "4.1.10", - "@vitest/runner": "4.1.10", - "@vitest/snapshot": "4.1.10", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.10", - "@vitest/browser-preview": "4.1.10", - "@vitest/browser-webdriverio": "4.1.10", - "@vitest/coverage-istanbul": "4.1.10", - "@vitest/coverage-v8": "4.1.10", - "@vitest/ui": "4.1.10", - "happy-dom": "*", - "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { - "optional": true - }, - "@vitest/coverage-istanbul": { - "optional": true - }, - "@vitest/coverage-v8": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - }, - "vite": { - "optional": false - } - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, - "node_modules/zwitch": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", - "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", - "dev": true, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - } - } -} diff --git a/scripts/changelog-checker/package.json b/scripts/changelog-checker/package.json deleted file mode 100644 index f557a8c072..0000000000 --- a/scripts/changelog-checker/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "changelog-checker", - "version": "1.0.0", - "description": "", - "type": "module", - "scripts": { - "run": "vite-node src/index.ts", - "test": "vitest run", - "test:coverage": "vitest run --coverage" - }, - "devDependencies": { - "@types/mdast": "4.0.4", - "@types/node": "26.1.1", - "@vitest/coverage-v8": "4.1.10", - "mdast-util-to-string": "4.0.0", - "remark": "15.0.1", - "remark-parse": "11.0.0", - "typescript": "7.0.2", - "unified": "11.0.5", - "vite": "8.1.4", - "vite-node": "6.0.0", - "vitest": "4.1.10" - } -} diff --git a/scripts/changelog-checker/vite.config.ts b/scripts/changelog-checker/vite.config.ts deleted file mode 100644 index 46db02c806..0000000000 --- a/scripts/changelog-checker/vite.config.ts +++ /dev/null @@ -1,6 +0,0 @@ -import {defineConfig} from 'vite'; - - -const config = defineConfig({}); - -export default config; From b2d07ca6e3bb3977c04b95e74016321de9f09137 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:01:21 +0900 Subject: [PATCH 119/168] Update .coderabbit.yaml --- .coderabbit.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 415c9ddcae..e7d070843c 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -15,3 +15,9 @@ reviews: - "dependabot[bot]" - "renovate[bot]" - "github-actions[bot]" + path_instructions: + - path: "**/*" + instructions: | + 【レビュー対象外】 + - フォーマット違反、型エラー、SPDXヘッダーの記載漏れ等、静的解析で検出可能な問題については、別Workflow(CI/CD)でチェックされるため、レビュー対象外として無視してください。 + - これらの問題についての指摘やコメントは行わないでください。 From ccd04fbcd9c686f7ac644a89e0e92143e6a1b70c Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:03:23 +0900 Subject: [PATCH 120/168] Update .coderabbit.yaml --- .coderabbit.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index e7d070843c..007c507c7e 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -20,4 +20,5 @@ reviews: instructions: | 【レビュー対象外】 - フォーマット違反、型エラー、SPDXヘッダーの記載漏れ等、静的解析で検出可能な問題については、別Workflow(CI/CD)でチェックされるため、レビュー対象外として無視してください。 + - CHANGELOGに関する問題も無視してください。 - これらの問題についての指摘やコメントは行わないでください。 From 1dcd6c287cb55609f448764ea287c48e787e7876 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:06:26 +0900 Subject: [PATCH 121/168] Update .coderabbit.yaml --- .coderabbit.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 007c507c7e..033f614715 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -22,3 +22,6 @@ reviews: - フォーマット違反、型エラー、SPDXヘッダーの記載漏れ等、静的解析で検出可能な問題については、別Workflow(CI/CD)でチェックされるため、レビュー対象外として無視してください。 - CHANGELOGに関する問題も無視してください。 - これらの問題についての指摘やコメントは行わないでください。 + + 【注力してほしい観点】 + - ビジネスロジックの不備、セキュリティ、パフォーマンス、設計パターンの適切性などに焦点を当ててレビューしてください。 From f138a48419605af5b445cc353d0f234eed7866ef Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:09:21 +0900 Subject: [PATCH 122/168] Update .coderabbit.yaml --- .coderabbit.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 033f614715..b5a669a5c3 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -20,8 +20,8 @@ reviews: instructions: | 【レビュー対象外】 - フォーマット違反、型エラー、SPDXヘッダーの記載漏れ等、静的解析で検出可能な問題については、別Workflow(CI/CD)でチェックされるため、レビュー対象外として無視してください。 - - CHANGELOGに関する問題も無視してください。 - - これらの問題についての指摘やコメントは行わないでください。 【注力してほしい観点】 - ビジネスロジックの不備、セキュリティ、パフォーマンス、設計パターンの適切性などに焦点を当ててレビューしてください。 + path_filters: + - "!CHANGELOG.md" From 159b1a4484f56d5087ec29280a7ce5cc863a3e4d Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:30:46 +0900 Subject: [PATCH 123/168] Update .coderabbit.yaml --- .coderabbit.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index b5a669a5c3..379e3f2607 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -25,3 +25,4 @@ reviews: - ビジネスロジックの不備、セキュリティ、パフォーマンス、設計パターンの適切性などに焦点を当ててレビューしてください。 path_filters: - "!CHANGELOG.md" + - "!**/__snapshots__/**" From b51d5ba0a085f1b72aa206ef64d1acc890185ec5 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:26:53 +0900 Subject: [PATCH 124/168] =?UTF-8?q?frontend=20bundle=20diagnostics=20?= =?UTF-8?q?=E3=81=A8=20frontend=20browser=20diagnostics=20=E3=82=92?= =?UTF-8?q?=E7=B5=B1=E5=90=88=20(#17757)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(gh): unify frontend diagnostics * refactor(gh): unify frontend diagnostics markdown rendering * docs(gh): restore frontend diagnostics comments * wip * fix * refactor: 呼称をbase/headに統一 * tweak * Update types.ts * fix --- .../frontend-browser-diagnostics.comment.yml | 44 ------ .../frontend-bundle-diagnostics.comment.yml | 44 ------ .../frontend-bundle-diagnostics.inspect.yml | 148 ------------------ .../frontend-diagnostics.comment.yml | 75 +++++++++ ...t.yml => frontend-diagnostics.inspect.yml} | 145 ++++++++++------- .github/workflows/packages-private.yml | 3 +- packages-private/README.md | 12 +- .../src/render-md.ts | 31 ---- .../test/render.test.ts | 69 -------- .../eslint.config.js | 25 --- .../diagnostics-frontend-bundle/package.json | 22 --- .../src/render-md.ts | 32 ---- .../diagnostics-frontend-bundle/src/report.ts | 33 ---- .../test/__snapshots__/render-md.md | 100 ------------ .../diagnostics-frontend-bundle/tsconfig.json | 20 --- .../eslint.config.js | 0 .../package.json | 6 +- .../src/browser/controller.ts | 4 +- .../src/browser/diagnostics.ts | 2 +- .../src/browser/network.ts | 2 +- .../src/browser}/report/html-styles.ts | 0 .../src/browser}/report/html.ts | 2 +- .../src/browser}/scenario.ts | 4 +- .../src/browser}/server.ts | 11 +- .../src/browser}/summarize.ts | 2 +- .../src/browser}/types.ts | 5 + .../src/bundle}/chunk-report.ts | 128 +++++++-------- .../src/bundle}/fs-utils.ts | 5 +- .../src/bundle}/manifest.ts | 27 ++-- .../src/bundle}/visualizer.ts | 22 +-- .../src/inspect-browser.ts} | 10 +- .../src/render-browser-html.ts} | 8 +- .../diagnostics-frontend/src/render-md.ts | 62 ++++++++ .../src/report.ts} | 54 +++++-- .../test/__snapshots__/report.md} | 101 +++++++++++- .../browser}/__snapshots__/render-html.html | 0 .../test/browser}/fixtures/base.json | 0 .../test/browser}/fixtures/head.json | 0 .../test/browser/render.test.ts | 44 ++++++ .../test/browser/server.test.ts | 47 ++++++ .../test/bundle/fixtures/base-stats.json} | 2 +- .../test/bundle/fixtures/head-stats.json} | 2 +- .../test/bundle/fs-utils.test.ts | 29 ++++ .../test/bundle/manifest.test.ts | 24 +++ .../test/report.test.ts} | 73 ++++++--- .../tsconfig.json | 0 pnpm-lock.yaml | 24 +-- 47 files changed, 693 insertions(+), 810 deletions(-) delete mode 100644 .github/workflows/frontend-browser-diagnostics.comment.yml delete mode 100644 .github/workflows/frontend-bundle-diagnostics.comment.yml delete mode 100644 .github/workflows/frontend-bundle-diagnostics.inspect.yml create mode 100644 .github/workflows/frontend-diagnostics.comment.yml rename .github/workflows/{frontend-browser-diagnostics.inspect.yml => frontend-diagnostics.inspect.yml} (51%) delete mode 100644 packages-private/diagnostics-frontend-browser/src/render-md.ts delete mode 100644 packages-private/diagnostics-frontend-browser/test/render.test.ts delete mode 100644 packages-private/diagnostics-frontend-bundle/eslint.config.js delete mode 100644 packages-private/diagnostics-frontend-bundle/package.json delete mode 100644 packages-private/diagnostics-frontend-bundle/src/render-md.ts delete mode 100644 packages-private/diagnostics-frontend-bundle/src/report.ts delete mode 100644 packages-private/diagnostics-frontend-bundle/test/__snapshots__/render-md.md delete mode 100644 packages-private/diagnostics-frontend-bundle/tsconfig.json rename packages-private/{diagnostics-frontend-browser => diagnostics-frontend}/eslint.config.js (100%) rename packages-private/{diagnostics-frontend-browser => diagnostics-frontend}/package.json (76%) rename packages-private/{diagnostics-frontend-browser => diagnostics-frontend}/src/browser/controller.ts (99%) rename packages-private/{diagnostics-frontend-browser => diagnostics-frontend}/src/browser/diagnostics.ts (92%) rename packages-private/{diagnostics-frontend-browser => diagnostics-frontend}/src/browser/network.ts (99%) rename packages-private/{diagnostics-frontend-browser/src => diagnostics-frontend/src/browser}/report/html-styles.ts (100%) rename packages-private/{diagnostics-frontend-browser/src => diagnostics-frontend/src/browser}/report/html.ts (98%) rename packages-private/{diagnostics-frontend-browser/src => diagnostics-frontend/src/browser}/scenario.ts (87%) rename packages-private/{diagnostics-frontend-browser/src => diagnostics-frontend/src/browser}/server.ts (90%) rename packages-private/{diagnostics-frontend-browser/src => diagnostics-frontend/src/browser}/summarize.ts (98%) rename packages-private/{diagnostics-frontend-browser/src => diagnostics-frontend/src/browser}/types.ts (87%) rename packages-private/{diagnostics-frontend-bundle/src => diagnostics-frontend/src/bundle}/chunk-report.ts (50%) rename packages-private/{diagnostics-frontend-bundle/src => diagnostics-frontend/src/bundle}/fs-utils.ts (90%) rename packages-private/{diagnostics-frontend-bundle/src => diagnostics-frontend/src/bundle}/manifest.ts (90%) rename packages-private/{diagnostics-frontend-bundle/src => diagnostics-frontend/src/bundle}/visualizer.ts (83%) rename packages-private/{diagnostics-frontend-browser/src/inspect.ts => diagnostics-frontend/src/inspect-browser.ts} (95%) rename packages-private/{diagnostics-frontend-browser/src/render-html.ts => diagnostics-frontend/src/render-browser-html.ts} (68%) create mode 100644 packages-private/diagnostics-frontend/src/render-md.ts rename packages-private/{diagnostics-frontend-browser/src/report/markdown.ts => diagnostics-frontend/src/report.ts} (86%) rename packages-private/{diagnostics-frontend-browser/test/__snapshots__/render-md.md => diagnostics-frontend/test/__snapshots__/report.md} (62%) rename packages-private/{diagnostics-frontend-browser/test => diagnostics-frontend/test/browser}/__snapshots__/render-html.html (100%) rename packages-private/{diagnostics-frontend-browser/test => diagnostics-frontend/test/browser}/fixtures/base.json (100%) rename packages-private/{diagnostics-frontend-browser/test => diagnostics-frontend/test/browser}/fixtures/head.json (100%) create mode 100644 packages-private/diagnostics-frontend/test/browser/render.test.ts create mode 100644 packages-private/diagnostics-frontend/test/browser/server.test.ts rename packages-private/{diagnostics-frontend-bundle/test/fixtures/before-stats.json => diagnostics-frontend/test/bundle/fixtures/base-stats.json} (99%) rename packages-private/{diagnostics-frontend-bundle/test/fixtures/after-stats.json => diagnostics-frontend/test/bundle/fixtures/head-stats.json} (98%) create mode 100644 packages-private/diagnostics-frontend/test/bundle/fs-utils.test.ts create mode 100644 packages-private/diagnostics-frontend/test/bundle/manifest.test.ts rename packages-private/{diagnostics-frontend-bundle/test/render-md.test.ts => diagnostics-frontend/test/report.test.ts} (53%) rename packages-private/{diagnostics-frontend-browser => diagnostics-frontend}/tsconfig.json (100%) diff --git a/.github/workflows/frontend-browser-diagnostics.comment.yml b/.github/workflows/frontend-browser-diagnostics.comment.yml deleted file mode 100644 index 5098e618ad..0000000000 --- a/.github/workflows/frontend-browser-diagnostics.comment.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Frontend browser diagnostics (comment) - -on: - workflow_run: - workflows: - - Frontend browser diagnostics (inspect) - types: - - completed - -permissions: - actions: read - contents: read - issues: write - pull-requests: write - -jobs: - comment: - 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-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-diagnostics - path: ${{ runner.temp }}/frontend-browser-diagnostics - 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-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-diagnostics - file-path: ${{ runner.temp }}/frontend-browser-diagnostics/frontend-browser-diagnostics.md diff --git a/.github/workflows/frontend-bundle-diagnostics.comment.yml b/.github/workflows/frontend-bundle-diagnostics.comment.yml deleted file mode 100644 index 709c735e69..0000000000 --- a/.github/workflows/frontend-bundle-diagnostics.comment.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Frontend bundle diagnostics (comment) - -on: - workflow_run: - workflows: - - Frontend bundle diagnostics (inspect) - types: - - completed - -permissions: - actions: read - contents: read - issues: write - pull-requests: write - -jobs: - comment: - name: Comment frontend bundle report - if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' - runs-on: ubuntu-latest - concurrency: - group: frontend-bundle-diagnostics-report-${{ github.event.workflow_run.id }} - cancel-in-progress: true - steps: - - name: Download bundle report - uses: actions/download-artifact@v8 - with: - name: frontend-bundle-report - path: ${{ runner.temp }}/frontend-bundle-report - github-token: ${{ github.token }} - repository: ${{ github.repository }} - run-id: ${{ github.event.workflow_run.id }} - - - name: Load PR number - id: load-pr-number - shell: bash - run: echo "pr-number=$(cat "$RUNNER_TEMP/frontend-bundle-report/pr-number.txt")" >> "$GITHUB_OUTPUT" - - - name: Comment on pull request - uses: thollander/actions-comment-pull-request@v3 - with: - pr-number: ${{ steps.load-pr-number.outputs.pr-number }} - comment-tag: frontend-bundle-diagnostics - file-path: ${{ runner.temp }}/frontend-bundle-report/frontend-bundle-diagnostics-report.md diff --git a/.github/workflows/frontend-bundle-diagnostics.inspect.yml b/.github/workflows/frontend-bundle-diagnostics.inspect.yml deleted file mode 100644 index 0332ea6a3e..0000000000 --- a/.github/workflows/frontend-bundle-diagnostics.inspect.yml +++ /dev/null @@ -1,148 +0,0 @@ -name: Frontend bundle diagnostics (inspect) - -on: - pull_request: - 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 - - packages-private/diagnostics-shared/** - - packages-private/diagnostics-frontend-bundle/** - - .github/workflows/frontend-bundle-diagnostics.inspect.yml - - .github/workflows/frontend-bundle-diagnostics.comment.yml - -permissions: - contents: read - pull-requests: read - -concurrency: - 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 - steps: - - name: Checkout base - uses: actions/checkout@v6.0.3 - with: - repository: ${{ github.event.pull_request.base.repo.full_name }} - ref: ${{ github.event.pull_request.base.sha }} - path: before - submodules: true - - - name: Checkout pull request - uses: actions/checkout@v6.0.3 - with: - repository: ${{ github.event.pull_request.head.repo.full_name }} - ref: ${{ github.event.pull_request.head.sha }} - path: after - submodules: true - - - name: Setup pnpm - uses: pnpm/action-setup@v6.0.9 - with: - package_json_file: after/package.json - - - name: Setup Node.js - uses: actions/setup-node@v6.4.0 - with: - node-version-file: after/.node-version - cache: pnpm - cache-dependency-path: | - before/pnpm-lock.yaml - after/pnpm-lock.yaml - - - name: Install dependencies for base - working-directory: before - run: pnpm i --frozen-lockfile - - - name: Build frontend dependencies for base - working-directory: before - run: pnpm --filter "frontend^..." run build - - - name: Prepare report output - run: mkdir -p "$RUNNER_TEMP/frontend-bundle-report" - - - name: Build frontend report for base - working-directory: before - env: - FRONTEND_BUNDLE_VISUALIZER: 'true' - FRONTEND_BUNDLE_VISUALIZER_FILE: ${{ runner.temp }}/frontend-bundle-report/before-stats.json - run: pnpm --filter frontend run build - - - name: Install dependencies for pull request - working-directory: after - run: pnpm i --frozen-lockfile - - - name: Build frontend dependencies for pull request - working-directory: after - run: pnpm --filter "frontend^..." run build - - - name: Build frontend report for pull request - working-directory: after - env: - FRONTEND_BUNDLE_VISUALIZER: 'true' - FRONTEND_BUNDLE_VISUALIZER_FILE: ${{ runner.temp }}/frontend-bundle-report/after-stats.json - FRONTEND_BUNDLE_VISUALIZER_HTML_FILE: ${{ runner.temp }}/frontend-bundle-report/frontend-bundle-visualizer.html - run: pnpm --filter frontend run build - - - name: Upload bundle visualizer - id: upload-bundle-visualizer - uses: actions/upload-artifact@v7 - with: - name: frontend-bundle-visualizer - path: ${{ runner.temp }}/frontend-bundle-report/frontend-bundle-visualizer.html - if-no-files-found: error - archive: false - retention-days: 7 - - - name: Generate report markdown - shell: bash - working-directory: after - env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - PR_NUMBER: ${{ github.event.pull_request.number }} - FRONTEND_BUNDLE_REPORT_ARTIFACT_URL: ${{ steps.upload-bundle-visualizer.outputs.artifact-url }} - run: | - REPORT_DIR="$RUNNER_TEMP/frontend-bundle-report" - pnpm --filter diagnostics-frontend-bundle run render-md \ - "$GITHUB_WORKSPACE/before" "$GITHUB_WORKSPACE/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 - 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-bundle-diagnostics-report.md" - cat "$REPORT_DIR/frontend-bundle-diagnostics-report.md" >> "$GITHUB_STEP_SUMMARY" - - - name: Upload bundle report - uses: actions/upload-artifact@v7 - with: - name: frontend-bundle-report - path: ${{ runner.temp }}/frontend-bundle-report/ - if-no-files-found: error - retention-days: 7 diff --git a/.github/workflows/frontend-diagnostics.comment.yml b/.github/workflows/frontend-diagnostics.comment.yml new file mode 100644 index 0000000000..96deddcc58 --- /dev/null +++ b/.github/workflows/frontend-diagnostics.comment.yml @@ -0,0 +1,75 @@ +name: Frontend diagnostics (comment) + +on: + workflow_run: + workflows: + - Frontend diagnostics (inspect) + types: + - completed + +permissions: + actions: read + contents: read + issues: write + pull-requests: write + +jobs: + comment: + name: Comment frontend diagnostics report + if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + concurrency: + group: frontend-diagnostics-report-${{ github.event.workflow_run.id }} + cancel-in-progress: true + steps: + - name: Download frontend diagnostics report + uses: actions/download-artifact@v8 + with: + name: frontend-diagnostics + path: ${{ runner.temp }}/frontend-diagnostics + github-token: ${{ github.token }} + repository: ${{ github.repository }} + run-id: ${{ github.event.workflow_run.id }} + + - name: Resolve current pull request + id: resolve-pr + uses: actions/github-script@v9 + env: + PR_NUMBER_FILE: ${{ runner.temp }}/frontend-diagnostics/pr-number.txt + with: + script: | + const fs = require('fs/promises'); + const { owner, repo } = context.repo; + const workflowRun = context.payload.workflow_run; + const pullRequestNumberText = (await fs.readFile(process.env.PR_NUMBER_FILE, 'utf8')).trim(); + if (!/^[1-9]\d*$/.test(pullRequestNumberText)) { + throw new Error('Invalid pull request number in frontend diagnostics artifact.'); + } + + const pullRequestNumber = Number(pullRequestNumberText); + if (!Number.isSafeInteger(pullRequestNumber)) { + throw new Error('Pull request number in frontend diagnostics artifact is not a safe integer.'); + } + + const { data: currentPullRequest } = await github.rest.pulls.get({ + owner, + repo, + pull_number: pullRequestNumber, + }); + + if (currentPullRequest.state !== 'open' || currentPullRequest.head.sha !== workflowRun.head_sha) { + core.notice(`Skipping stale frontend diagnostics report for pull request #${pullRequestNumber}.`); + core.setOutput('is-current', 'false'); + return; + } + + core.setOutput('is-current', 'true'); + core.setOutput('pr-number', String(pullRequestNumber)); + + - name: Comment on pull request + if: steps.resolve-pr.outputs.is-current == 'true' + uses: thollander/actions-comment-pull-request@v3 + with: + pr-number: ${{ steps.resolve-pr.outputs.pr-number }} + comment-tag: frontend-diagnostics + file-path: ${{ runner.temp }}/frontend-diagnostics/frontend-diagnostics.md diff --git a/.github/workflows/frontend-browser-diagnostics.inspect.yml b/.github/workflows/frontend-diagnostics.inspect.yml similarity index 51% rename from .github/workflows/frontend-browser-diagnostics.inspect.yml rename to .github/workflows/frontend-diagnostics.inspect.yml index 7b6238c81d..ecf06c3092 100644 --- a/.github/workflows/frontend-browser-diagnostics.inspect.yml +++ b/.github/workflows/frontend-diagnostics.inspect.yml @@ -1,4 +1,4 @@ -name: Frontend browser diagnostics (inspect) +name: Frontend diagnostics (inspect) on: pull_request: @@ -23,21 +23,21 @@ on: - .node-version - .github/misskey/test.yml - packages-private/diagnostics-shared/** - - packages-private/diagnostics-frontend-browser/** - - .github/workflows/frontend-browser-diagnostics.inspect.yml - - .github/workflows/frontend-browser-diagnostics.comment.yml + - packages-private/diagnostics-frontend/** + - .github/workflows/frontend-diagnostics.inspect.yml + - .github/workflows/frontend-diagnostics.comment.yml permissions: contents: read pull-requests: read concurrency: - group: frontend-browser-diagnostics-inspect-${{ github.event.pull_request.number }} + group: frontend-diagnostics-inspect-${{ github.event.pull_request.number }} cancel-in-progress: true jobs: report: - name: Measure frontend browser metrics + name: Measure frontend diagnostics runs-on: ubuntu-latest timeout-minutes: 90 @@ -61,79 +61,100 @@ jobs: persist-credentials: false repository: ${{ github.event.pull_request.base.repo.full_name }} ref: ${{ github.event.pull_request.base.sha }} - path: before + path: base submodules: true - - name: Checkout pull request + - name: Checkout head uses: actions/checkout@v6.0.3 with: persist-credentials: false repository: ${{ github.event.pull_request.head.repo.full_name }} ref: ${{ github.event.pull_request.head.sha }} - path: after + path: head submodules: true - name: Setup pnpm uses: pnpm/action-setup@v6.0.9 with: - package_json_file: after/package.json + package_json_file: head/package.json - name: Setup Node.js uses: actions/setup-node@v6.4.0 with: - node-version-file: after/.node-version + node-version-file: head/.node-version cache: pnpm cache-dependency-path: | - before/pnpm-lock.yaml - after/pnpm-lock.yaml + base/pnpm-lock.yaml + head/pnpm-lock.yaml + + - name: Prepare report output + shell: bash + run: mkdir -p "$RUNNER_TEMP/frontend-diagnostics" - name: Install dependencies for base - working-directory: before + working-directory: base run: pnpm i --frozen-lockfile - name: Configure base - working-directory: before + working-directory: base run: cp .github/misskey/test.yml .config - name: Build base - working-directory: before + working-directory: base + env: + FRONTEND_BUNDLE_VISUALIZER: 'true' + FRONTEND_BUNDLE_VISUALIZER_FILE: ${{ runner.temp }}/frontend-diagnostics/base-bundle-stats.json run: pnpm build - - name: Install dependencies for pull request - working-directory: after + - name: Install dependencies for head + working-directory: head run: pnpm i --frozen-lockfile - - name: Configure pull request - working-directory: after + - name: Configure head + working-directory: head run: cp .github/misskey/test.yml .config - - name: Build pull request - working-directory: after + - name: Build head + working-directory: head + env: + FRONTEND_BUNDLE_VISUALIZER: 'true' + FRONTEND_BUNDLE_VISUALIZER_FILE: ${{ runner.temp }}/frontend-diagnostics/head-bundle-stats.json + FRONTEND_BUNDLE_VISUALIZER_HTML_FILE: ${{ runner.temp }}/frontend-diagnostics/frontend-bundle-visualizer.html run: pnpm build + - name: Upload bundle visualizer + id: upload-bundle-visualizer + uses: actions/upload-artifact@v7 + with: + name: frontend-bundle-visualizer + path: ${{ runner.temp }}/frontend-diagnostics/frontend-bundle-visualizer.html + if-no-files-found: error + archive: false + retention-days: 7 + - name: Install Playwright browsers - working-directory: after/packages-private/diagnostics-frontend-browser + working-directory: head/packages-private/diagnostics-frontend run: pnpm exec playwright install --with-deps --no-shell chromium - name: Measure frontend browser metrics shell: bash - working-directory: after + working-directory: head env: FRONTEND_BROWSER_METRICS_SAMPLE_COUNT: 5 MK_ENABLE_CROSS_ORIGIN_ISOLATION: "true" - # 計測ハーネスはafter側のものを両方に使うが、成果物はrunnerのworkspace直下に出す + # 計測ハーネスはhead側のものを両方に使うが、成果物はrunnerのworkspace直下に出す FRONTEND_BROWSER_HEAP_SNAPSHOT_OUTPUT_DIR: ${{ github.workspace }} run: | - REPORT_DIR="$RUNNER_TEMP/frontend-browser-diagnostics" - mkdir -p "$REPORT_DIR" - pnpm --filter diagnostics-frontend-browser run inspect \ - "$GITHUB_WORKSPACE/before" "$GITHUB_WORKSPACE/after" \ - "$REPORT_DIR/before-browser.json" "$REPORT_DIR/after-browser.json" + REPORT_DIR="$RUNNER_TEMP/frontend-diagnostics" + pnpm --filter diagnostics-frontend run inspect-browser \ + "$GITHUB_WORKSPACE/base" "$GITHUB_WORKSPACE/head" \ + "$REPORT_DIR/base-browser.json" "$REPORT_DIR/head-browser.json" - name: Upload browser base heap snapshot id: upload-browser-base-heap-snapshot uses: actions/upload-artifact@v7 with: + name: frontend-browser-base-heap-snapshot path: base-heap-snapshot.heapsnapshot archive: false if-no-files-found: error @@ -143,6 +164,7 @@ jobs: id: upload-browser-head-heap-snapshot uses: actions/upload-artifact@v7 with: + name: frontend-browser-head-heap-snapshot path: head-heap-snapshot.heapsnapshot archive: false if-no-files-found: error @@ -150,13 +172,13 @@ jobs: - name: Generate browser detailed html shell: bash - working-directory: after + working-directory: head run: | - REPORT_DIR="$RUNNER_TEMP/frontend-browser-diagnostics" - test -s "$REPORT_DIR/before-browser.json" - test -s "$REPORT_DIR/after-browser.json" - pnpm --filter diagnostics-frontend-browser run render-html \ - "$REPORT_DIR/before-browser.json" "$REPORT_DIR/after-browser.json" \ + REPORT_DIR="$RUNNER_TEMP/frontend-diagnostics" + test -s "$REPORT_DIR/base-browser.json" + test -s "$REPORT_DIR/head-browser.json" + pnpm --filter diagnostics-frontend run render-browser-html \ + "$REPORT_DIR/base-browser.json" "$REPORT_DIR/head-browser.json" \ "$REPORT_DIR/frontend-browser-detailed-html.html" - name: Upload browser detailed html @@ -164,54 +186,61 @@ jobs: uses: actions/upload-artifact@v7 with: name: frontend-browser-metrics-detailed-html - path: ${{ runner.temp }}/frontend-browser-diagnostics/frontend-browser-detailed-html.html + path: ${{ runner.temp }}/frontend-diagnostics/frontend-browser-detailed-html.html if-no-files-found: error archive: false retention-days: 7 - - name: Generate browser metrics report + - name: Generate frontend diagnostics report shell: bash - working-directory: after + working-directory: head env: BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} PR_NUMBER: ${{ github.event.pull_request.number }} + FRONTEND_BUNDLE_REPORT_ARTIFACT_URL: ${{ steps.upload-bundle-visualizer.outputs.artifact-url }} 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-diagnostics" - test -s "$REPORT_DIR/before-browser.json" - test -s "$REPORT_DIR/after-browser.json" - pnpm --filter diagnostics-frontend-browser run render-md \ - "$REPORT_DIR/before-browser.json" "$REPORT_DIR/after-browser.json" \ - "$REPORT_DIR/frontend-browser-diagnostics.md" + REPORT_DIR="$RUNNER_TEMP/frontend-diagnostics" + test -s "$REPORT_DIR/base-bundle-stats.json" + test -s "$REPORT_DIR/head-bundle-stats.json" + test -s "$REPORT_DIR/base-browser.json" + test -s "$REPORT_DIR/head-browser.json" + pnpm --filter diagnostics-frontend run render-md \ + "$GITHUB_WORKSPACE/base" "$GITHUB_WORKSPACE/head" \ + "$REPORT_DIR/base-bundle-stats.json" "$REPORT_DIR/head-bundle-stats.json" \ + "$REPORT_DIR/base-browser.json" "$REPORT_DIR/head-browser.json" \ + "$REPORT_DIR/frontend-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" printf '%s\n' "${{ github.event.pull_request.html_url }}" > "$REPORT_DIR/pr-url.txt" - - name: Check browser metrics report + - name: Check frontend diagnostics report shell: bash run: | - REPORT_DIR="$RUNNER_TEMP/frontend-browser-diagnostics" - test -s "$REPORT_DIR/frontend-browser-diagnostics.md" + REPORT_DIR="$RUNNER_TEMP/frontend-diagnostics" + test -s "$REPORT_DIR/frontend-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-diagnostics.md" >> "$GITHUB_STEP_SUMMARY" + cat "$REPORT_DIR/frontend-diagnostics.md" >> "$GITHUB_STEP_SUMMARY" - - name: Upload browser metrics report + - name: Upload frontend diagnostics report uses: actions/upload-artifact@v7 with: - name: frontend-browser-diagnostics + name: frontend-diagnostics path: | - ${{ 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 + ${{ runner.temp }}/frontend-diagnostics/base-bundle-stats.json + ${{ runner.temp }}/frontend-diagnostics/head-bundle-stats.json + ${{ runner.temp }}/frontend-diagnostics/base-browser.json + ${{ runner.temp }}/frontend-diagnostics/head-browser.json + ${{ runner.temp }}/frontend-diagnostics/frontend-diagnostics.md + ${{ runner.temp }}/frontend-diagnostics/pr-number.txt + ${{ runner.temp }}/frontend-diagnostics/base-sha.txt + ${{ runner.temp }}/frontend-diagnostics/head-sha.txt + ${{ runner.temp }}/frontend-diagnostics/pr-url.txt if-no-files-found: error retention-days: 7 diff --git a/.github/workflows/packages-private.yml b/.github/workflows/packages-private.yml index e6a72b68c9..cf7e50670a 100644 --- a/.github/workflows/packages-private.yml +++ b/.github/workflows/packages-private.yml @@ -33,8 +33,7 @@ jobs: workspace: - diagnostics-shared - diagnostics-backend - - diagnostics-frontend-browser - - diagnostics-frontend-bundle + - diagnostics-frontend - changelog-checker steps: - uses: actions/checkout@v6.0.3 diff --git a/packages-private/README.md b/packages-private/README.md index faba8fa046..0c14dffdf5 100644 --- a/packages-private/README.md +++ b/packages-private/README.md @@ -8,8 +8,7 @@ | --- | --- | | [`diagnostics-shared`](./diagnostics-shared) | 計測系パッケージが共有する統計・書式整形・V8 heap snapshot解析のユーティリティ | | [`diagnostics-backend`](./diagnostics-backend) | バックエンドのメモリ使用量をbase/headで比較し、PRコメント用のMarkdownを生成する | -| [`diagnostics-frontend-browser`](./diagnostics-frontend-browser) | ヘッドレスChromeでフロントエンドを操作し、メモリ・ネットワーク等の指標をbase/headで比較する | -| [`diagnostics-frontend-bundle`](./diagnostics-frontend-bundle) | フロントエンドのビルド成果物のchunkサイズをbase/headで比較する | +| [`diagnostics-frontend`](./diagnostics-frontend) | フロントエンドをブラウザで起動した際のメトリクスと生成されたchunk情報等をbase/headで比較する | | [`changelog-checker`](./changelog-checker) | `CHANGELOG.md` の追記内容を検証する | ## GitHub Actionsからの呼び出し方 @@ -20,11 +19,12 @@ CLIに渡すパスはすべて呼び出し側のカレントディレクトリ ```yaml - name: Generate report - working-directory: after + working-directory: head run: >- - pnpm --filter diagnostics-frontend-bundle run render-md - "$GITHUB_WORKSPACE/before" "$GITHUB_WORKSPACE/after" - "$REPORT_DIR/before-stats.json" "$REPORT_DIR/after-stats.json" + pnpm --filter diagnostics-frontend run render-md + "$GITHUB_WORKSPACE/base" "$GITHUB_WORKSPACE/head" + "$REPORT_DIR/base-bundle-stats.json" "$REPORT_DIR/head-bundle-stats.json" + "$REPORT_DIR/base-browser.json" "$REPORT_DIR/head-browser.json" "$REPORT_DIR/report.md" ``` diff --git a/packages-private/diagnostics-frontend-browser/src/render-md.ts b/packages-private/diagnostics-frontend-browser/src/render-md.ts deleted file mode 100644 index 15c4924808..0000000000 --- a/packages-private/diagnostics-frontend-browser/src/render-md.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { readFile, writeFile } from 'node:fs/promises'; -import { resolve } from 'node:path'; -import { readOptionalEnv, readRequiredEnv } from 'diagnostics-shared/env'; -import { renderMarkdown } from './report/markdown'; -import type { BrowserMetricsReport } from './types'; - -async function main() { - const [baseFileArg, headFileArg, outputFileArg] = process.argv.slice(2); - if (baseFileArg == null || headFileArg == null || outputFileArg == null) { - throw new Error('Usage: render-md '); - } - - const base = JSON.parse(await readFile(resolve(baseFileArg), 'utf8')) as BrowserMetricsReport; - const head = JSON.parse(await readFile(resolve(headFileArg), 'utf8')) as BrowserMetricsReport; - - await writeFile(resolve(outputFileArg), renderMarkdown(base, head, { - baseHeapSnapshotUrl: readRequiredEnv('FRONTEND_BROWSER_BASE_HEAP_SNAPSHOT_ARTIFACT_URL'), - headHeapSnapshotUrl: readRequiredEnv('FRONTEND_BROWSER_HEAD_HEAP_SNAPSHOT_ARTIFACT_URL'), - detailedHtmlUrl: readOptionalEnv('FRONTEND_BROWSER_DETAILED_HTML_ARTIFACT_URL'), - })); -} - -await main().catch(err => { - console.error(err); - process.exit(1); -}); diff --git a/packages-private/diagnostics-frontend-browser/test/render.test.ts b/packages-private/diagnostics-frontend-browser/test/render.test.ts deleted file mode 100644 index 29e38f7b3e..0000000000 --- a/packages-private/diagnostics-frontend-browser/test/render.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { readFile } from 'node:fs/promises'; -import { join } from 'node:path'; -import { afterEach, beforeEach, expect, test, vi } from 'vitest'; -import { renderHtml } from '../src/report/html'; -import { renderMarkdown } from '../src/report/markdown'; -import type { BrowserMetricsReport } from '../src/types'; - -const fixturesDir = join(import.meta.dirname, 'fixtures'); - -async function loadFixture(name: string) { - return JSON.parse(await readFile(join(fixturesDir, `${name}.json`), 'utf8')) as BrowserMetricsReport; -} - -beforeEach(() => { - // renderHtml() が生成時刻を埋め込むので、スナップショットが揺れないよう時刻を固定する - vi.useFakeTimers(); - vi.setSystemTime(new Date('2026-07-18T00:00:00.000Z')); -}); - -afterEach(() => { - vi.useRealTimers(); -}); - -/** - * 出力をゴールデンファイルで固定する。 - * 意図的に変更したときは `vitest -u` で更新し、__snapshots__ の差分もレビューすること。 - */ -test('renders the browser diagnostics markdown report', async () => { - const markdown = renderMarkdown(await loadFixture('base'), await loadFixture('head'), { - baseHeapSnapshotUrl: 'https://example.invalid/base', - headHeapSnapshotUrl: 'https://example.invalid/head', - detailedHtmlUrl: 'https://example.invalid/html', - }); - - await expect(markdown).toMatchFileSnapshot('./__snapshots__/render-md.md'); -}); - -test('omits the details link when no detailed html artifact was uploaded', async () => { - const markdown = renderMarkdown(await loadFixture('base'), await loadFixture('head'), { - baseHeapSnapshotUrl: 'https://example.invalid/base', - headHeapSnapshotUrl: 'https://example.invalid/head', - detailedHtmlUrl: null, - }); - - expect(markdown).not.toContain('View details'); -}); - -test('renders the network request diff html report', async () => { - const html = renderHtml(await loadFixture('base'), await loadFixture('head')); - - await expect(html).toMatchFileSnapshot('./__snapshots__/render-html.html'); -}); - -test('escapes html metacharacters coming from the browser session', async () => { - const base = await loadFixture('base'); - const head = await loadFixture('head'); - // CDP由来の値は原理的には任意の文字列になりうるので、生HTMLとして出ないことを確かめる - head.samples[0].networkRequests[0].url = 'http://127.0.0.1:61812/">'; - - const html = renderHtml(base, head); - - expect(html).not.toContain(''); - expect(html).toContain('<script>alert(1)</script>'); -}); diff --git a/packages-private/diagnostics-frontend-bundle/eslint.config.js b/packages-private/diagnostics-frontend-bundle/eslint.config.js deleted file mode 100644 index 11bc648243..0000000000 --- a/packages-private/diagnostics-frontend-bundle/eslint.config.js +++ /dev/null @@ -1,25 +0,0 @@ -import tsParser from '@typescript-eslint/parser'; -import sharedConfig from '../../packages/shared/eslint.config.js'; - -// eslint-disable-next-line import/no-default-export -export default [ - ...sharedConfig, - { - ignores: [ - '**/node_modules', - '**/__snapshots__', - 'test/fixtures', - ], - }, - { - files: ['**/*.ts', '**/*.tsx'], - languageOptions: { - parserOptions: { - parser: tsParser, - project: ['./tsconfig.json'], - sourceType: 'module', - tsconfigRootDir: import.meta.dirname, - }, - }, - }, -]; diff --git a/packages-private/diagnostics-frontend-bundle/package.json b/packages-private/diagnostics-frontend-bundle/package.json deleted file mode 100644 index b0271a8101..0000000000 --- a/packages-private/diagnostics-frontend-bundle/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "diagnostics-frontend-bundle", - "private": true, - "type": "module", - "scripts": { - "eslint": "eslint './**/*.{js,jsx,ts,tsx}'", - "lint": "pnpm typecheck && pnpm eslint", - "render-md": "tsx src/render-md.ts", - "test": "vitest run", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "diagnostics-shared": "workspace:*" - }, - "devDependencies": { - "@types/node": "26.1.1", - "tsx": "4.23.1", - "typescript": "5.9.3", - "vite": "8.1.4", - "vitest": "4.1.10" - } -} diff --git a/packages-private/diagnostics-frontend-bundle/src/render-md.ts b/packages-private/diagnostics-frontend-bundle/src/render-md.ts deleted file mode 100644 index d8fe0997dd..0000000000 --- a/packages-private/diagnostics-frontend-bundle/src/render-md.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { promises as fs } from 'node:fs'; -import path from 'node:path'; -import { readRequiredEnv } from 'diagnostics-shared/env'; -import { collectReport } from './manifest'; -import { renderBundleReportMarkdown } from './report'; -import type { VisualizerReport } from './visualizer'; - -async function main() { - const [beforeDir, afterDir, beforeStatsFile, afterStatsFile, outFile] = process.argv.slice(2).map(arg => path.resolve(arg)); - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (outFile == null) throw new Error('Usage: render-md '); - - // 未設定のまま `undefined` という文字列をコメントに埋め込まないよう、ここで落とす - const visualizerArtifactUrl = readRequiredEnv('FRONTEND_BUNDLE_REPORT_ARTIFACT_URL'); - - const before = await collectReport(beforeDir); - const after = await collectReport(afterDir); - const beforeStats = JSON.parse(await fs.readFile(beforeStatsFile, 'utf8')) as VisualizerReport; - const afterStats = JSON.parse(await fs.readFile(afterStatsFile, 'utf8')) as VisualizerReport; - - await fs.writeFile(outFile, renderBundleReportMarkdown(before, after, beforeStats, afterStats, { visualizerArtifactUrl })); -} - -await main().catch(err => { - console.error(err); - process.exit(1); -}); diff --git a/packages-private/diagnostics-frontend-bundle/src/report.ts b/packages-private/diagnostics-frontend-bundle/src/report.ts deleted file mode 100644 index db2e1cf6e3..0000000000 --- a/packages-private/diagnostics-frontend-bundle/src/report.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { renderFrontendChunkReport } from './chunk-report'; -import { collectVisualizerReport, renderVisualizerSummaryTable, type VisualizerReport } from './visualizer'; -import type { CollectedReport } from './manifest'; - -export type RenderBundleReportOptions = { - /** rollup-plugin-visualizer が出力したtreemap HTMLのartifact URL */ - visualizerArtifactUrl: string; -}; - -export function renderBundleReportMarkdown( - before: CollectedReport, - after: CollectedReport, - beforeStats: VisualizerReport, - afterStats: VisualizerReport, - options: RenderBundleReportOptions, -) { - return [ - '## 📦 Frontend Bundle Report', - '', - renderFrontendChunkReport(before, after), - '', - '## Bundle Stats', - '', - renderVisualizerSummaryTable(collectVisualizerReport(beforeStats), collectVisualizerReport(afterStats)), - '', - `[Open treemap HTML](${options.visualizerArtifactUrl})`, - ].join('\n'); -} diff --git a/packages-private/diagnostics-frontend-bundle/test/__snapshots__/render-md.md b/packages-private/diagnostics-frontend-bundle/test/__snapshots__/render-md.md deleted file mode 100644 index 867cbf3b75..0000000000 --- a/packages-private/diagnostics-frontend-bundle/test/__snapshots__/render-md.md +++ /dev/null @@ -1,100 +0,0 @@ -## 📦 Frontend Bundle Report - -
    -Chunk size diff (2 updated, 0 added, 0 removed) - -| Chunk | Before | After | Δ | Δ (%) | -| --- | ---: | ---: | ---: | ---: | -| (total) | 120 KB | 127 KB | $\color{orange}{\text{+6.3 KB}}$ | $\color{orange}{\text{+5.2\\%}}$ | -| | | | | | -|
    `vue` `assets/vue-b2.js`
    | 90 KB | 96 KB | $\color{orange}{\text{+6 KB}}$ | $\color{orange}{\text{+6.7\\%}}$ | -| (other generated chunks) | 1.2 KB | 1.5 KB | $\text{+300 B}$ | $\color{orange}{\text{+25\\%}}$ | -| (other) | 20 KB | 20 KB | $\text{+3 B}$ | $\text{+0\\%}$ | - -
    - -
    -Startup chunk size (2 updated, 0 added, 0 removed) - -| Chunk | Before | After | Δ | Δ (%) | -| --- | ---: | ---: | ---: | ---: | -| (total) | 114 KB | 120 KB | $\color{orange}{\text{+6 KB}}$ | $\color{orange}{\text{+5.3\\%}}$ | -| | | | | | -|
    `vue` `assets/vue-b2.js`
    | 90 KB | 96 KB | $\color{orange}{\text{+6 KB}}$ | $\color{orange}{\text{+6.7\\%}}$ | -| (other) | 24 KB | 24 KB | $\text{+3 B}$ | $\text{+0\\%}$ | - -_Startup chunks are the Vite entry for `src/_boot_.ts` and its static imports._ - -
    - - -## Bundle Stats - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    BundlesModulesEntriesImportsSize
    StaticDynamicRenderedGzipBrotli
    Before2612321 KB6.3 KB5.3 KB
    After2713331 KB8.4 KB7 KB
    Δ0$\color{orange}{\text{+1}}$0$\color{orange}{\text{+1}}$0$\color{orange}{\text{+9.8 KB}}$$\color{orange}{\text{+2.1 KB}}$$\color{orange}{\text{+1.8 KB}}$
    Δ (%)0%$\color{orange}{\text{+16.7\%}}$0%$\color{orange}{\text{+50\%}}$0%$\color{orange}{\text{+46.7\%}}$$\color{orange}{\text{+33.3\%}}$$\color{orange}{\text{+33.3\%}}$
    - -[Open treemap HTML](https://example.invalid/treemap) \ No newline at end of file diff --git a/packages-private/diagnostics-frontend-bundle/tsconfig.json b/packages-private/diagnostics-frontend-bundle/tsconfig.json deleted file mode 100644 index 6672c40acb..0000000000 --- a/packages-private/diagnostics-frontend-bundle/tsconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "target": "ESNext", - "module": "ESNext", - "moduleResolution": "bundler", - "strict": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "esModuleInterop": true, - "skipLibCheck": true, - "noEmit": true, - "types": ["node"] - }, - "include": [ - "src/**/*.ts", - "test/**/*.ts" - ], - "exclude": [] -} diff --git a/packages-private/diagnostics-frontend-browser/eslint.config.js b/packages-private/diagnostics-frontend/eslint.config.js similarity index 100% rename from packages-private/diagnostics-frontend-browser/eslint.config.js rename to packages-private/diagnostics-frontend/eslint.config.js diff --git a/packages-private/diagnostics-frontend-browser/package.json b/packages-private/diagnostics-frontend/package.json similarity index 76% rename from packages-private/diagnostics-frontend-browser/package.json rename to packages-private/diagnostics-frontend/package.json index 9264dc49f9..8d3d2d248c 100644 --- a/packages-private/diagnostics-frontend-browser/package.json +++ b/packages-private/diagnostics-frontend/package.json @@ -1,12 +1,12 @@ { - "name": "diagnostics-frontend-browser", + "name": "diagnostics-frontend", "private": true, "type": "module", "scripts": { "eslint": "eslint './**/*.{js,jsx,ts,tsx}'", - "inspect": "tsx src/inspect.ts", + "inspect-browser": "tsx src/inspect-browser.ts", "lint": "pnpm typecheck && pnpm eslint", - "render-html": "tsx src/render-html.ts", + "render-browser-html": "tsx src/render-browser-html.ts", "render-md": "tsx src/render-md.ts", "test": "vitest run", "typecheck": "tsc --noEmit" diff --git a/packages-private/diagnostics-frontend-browser/src/browser/controller.ts b/packages-private/diagnostics-frontend/src/browser/controller.ts similarity index 99% rename from packages-private/diagnostics-frontend-browser/src/browser/controller.ts rename to packages-private/diagnostics-frontend/src/browser/controller.ts index 756245c636..dd17be2a62 100644 --- a/packages-private/diagnostics-frontend-browser/src/browser/controller.ts +++ b/packages-private/diagnostics-frontend/src/browser/controller.ts @@ -5,9 +5,9 @@ import { writeFile } from 'node:fs/promises'; import { chromium } from 'playwright'; -import type { Browser, BrowserContext, CDPSession, Page } from 'playwright'; import { enableNetworkTracking, type NetworkTracker } from './network'; -import type { BrowserDiagnostics, BrowserMeasurement, NetworkRequest, TabMemory, WebSocketConnection } from '../types'; +import type { Browser, BrowserContext, CDPSession, Page } from 'playwright'; +import type { BrowserDiagnostics, BrowserMeasurement, NetworkRequest, TabMemory, WebSocketConnection } from './types'; export type HeadlessChromeOptions = { scenarioTimeoutMs: number; diff --git a/packages-private/diagnostics-frontend-browser/src/browser/diagnostics.ts b/packages-private/diagnostics-frontend/src/browser/diagnostics.ts similarity index 92% rename from packages-private/diagnostics-frontend-browser/src/browser/diagnostics.ts rename to packages-private/diagnostics-frontend/src/browser/diagnostics.ts index 8365038bb2..a4c95bf842 100644 --- a/packages-private/diagnostics-frontend-browser/src/browser/diagnostics.ts +++ b/packages-private/diagnostics-frontend/src/browser/diagnostics.ts @@ -4,7 +4,7 @@ */ import { median } from 'diagnostics-shared/stats'; -import type { BrowserDiagnostics } from '../types'; +import type { BrowserDiagnostics } from './types'; export function summarizeBrowserDiagnostics(samples: BrowserDiagnostics[]): BrowserDiagnostics { const medianOf = (select: (sample: BrowserDiagnostics) => number) => median(samples.map(select)); diff --git a/packages-private/diagnostics-frontend-browser/src/browser/network.ts b/packages-private/diagnostics-frontend/src/browser/network.ts similarity index 99% rename from packages-private/diagnostics-frontend-browser/src/browser/network.ts rename to packages-private/diagnostics-frontend/src/browser/network.ts index 538294ec3d..77095ca689 100644 --- a/packages-private/diagnostics-frontend-browser/src/browser/network.ts +++ b/packages-private/diagnostics-frontend/src/browser/network.ts @@ -4,7 +4,7 @@ */ import type { CDPSession } from 'playwright'; -import type { NetworkRequest, NetworkSummary, WebSocketConnection } from '../types'; +import type { NetworkRequest, NetworkSummary, WebSocketConnection } from './types'; function normalizeHeaders(headers: Record | undefined) { if (headers == null) return undefined; diff --git a/packages-private/diagnostics-frontend-browser/src/report/html-styles.ts b/packages-private/diagnostics-frontend/src/browser/report/html-styles.ts similarity index 100% rename from packages-private/diagnostics-frontend-browser/src/report/html-styles.ts rename to packages-private/diagnostics-frontend/src/browser/report/html-styles.ts diff --git a/packages-private/diagnostics-frontend-browser/src/report/html.ts b/packages-private/diagnostics-frontend/src/browser/report/html.ts similarity index 98% rename from packages-private/diagnostics-frontend-browser/src/report/html.ts rename to packages-private/diagnostics-frontend/src/browser/report/html.ts index 7627f99729..ff1ca47839 100644 --- a/packages-private/diagnostics-frontend-browser/src/report/html.ts +++ b/packages-private/diagnostics-frontend/src/browser/report/html.ts @@ -224,7 +224,7 @@ function renderRound(round: number, diffs: RequestDiff[]): Raw { `; } -export function renderHtml(base: BrowserMetricsReport, head: BrowserMetricsReport) { +export function renderBrowserDiagnosticsHtml(base: BrowserMetricsReport, head: BrowserMetricsReport) { const diffs = diffReports(base, head); const rounds = [...new Set(diffs.map(diff => diff.round))].toSorted((a, b) => a - b); const generatedAt = new Date().toISOString(); diff --git a/packages-private/diagnostics-frontend-browser/src/scenario.ts b/packages-private/diagnostics-frontend/src/browser/scenario.ts similarity index 87% rename from packages-private/diagnostics-frontend-browser/src/scenario.ts rename to packages-private/diagnostics-frontend/src/browser/scenario.ts index 1ad513e93e..80df8532ec 100644 --- a/packages-private/diagnostics-frontend-browser/src/scenario.ts +++ b/packages-private/diagnostics-frontend/src/browser/scenario.ts @@ -3,9 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { closeUserSetupDialog, postNote, registerUser, resetState, signupThroughUi, visitHome } from '../../../packages/frontend/test/e2e/shared'; +import { closeUserSetupDialog, postNote, registerUser, resetState, signupThroughUi, visitHome } from '../../../../packages/frontend/test/e2e/shared'; import { sleep } from './server'; -import type { HeadlessChromeController } from './browser/controller'; +import type { HeadlessChromeController } from './controller'; export const scenarioDescription = 'fresh browser signup, first timeline note, after the note becomes visible'; diff --git a/packages-private/diagnostics-frontend-browser/src/server.ts b/packages-private/diagnostics-frontend/src/browser/server.ts similarity index 90% rename from packages-private/diagnostics-frontend-browser/src/server.ts rename to packages-private/diagnostics-frontend/src/browser/server.ts index c2690c5777..72f7a6f79e 100644 --- a/packages-private/diagnostics-frontend-browser/src/server.ts +++ b/packages-private/diagnostics-frontend/src/browser/server.ts @@ -34,10 +34,15 @@ export function startServer(label: string, repoDir: string) { const serverStartupTimeoutMs = 120_000; +function hasExited(child: ChildProcess) { + return child.exitCode != null || child.signalCode != null; +} + export async function waitForServer(baseUrl: string, child: ChildProcess) { const startedAt = Date.now(); while (Date.now() - startedAt < serverStartupTimeoutMs) { if (child.exitCode != null) throw new Error(`Misskey server exited early with code ${child.exitCode}`); + if (child.signalCode != null) throw new Error(`Misskey server exited early with signal ${child.signalCode}`); try { // 応答が返らないままだとfetchが待ち続け、外側の120秒の上限を超えてしまう const remainingMs = serverStartupTimeoutMs - (Date.now() - startedAt); @@ -55,7 +60,7 @@ export async function waitForServer(baseUrl: string, child: ChildProcess) { } export async function stopServer(child: ChildProcess) { - if (child.exitCode != null) return; + if (hasExited(child)) return; if (process.platform === 'win32') { spawnSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' }); @@ -69,14 +74,14 @@ export async function stopServer(child: ChildProcess) { } await new Promise(resolvePromise => { - if (child.exitCode != null) { + if (hasExited(child)) { resolvePromise(); return; } const forceKillTimer = setTimeout(() => { // 猶予の間に終了していれば、PIDが再利用されて無関係のプロセスを撃つ恐れがある - if (child.exitCode == null && child.pid != null) { + if (!hasExited(child) && child.pid != null) { try { if (process.platform === 'win32') { spawnSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' }); diff --git a/packages-private/diagnostics-frontend-browser/src/summarize.ts b/packages-private/diagnostics-frontend/src/browser/summarize.ts similarity index 98% rename from packages-private/diagnostics-frontend-browser/src/summarize.ts rename to packages-private/diagnostics-frontend/src/browser/summarize.ts index 825ae188f5..1b88002208 100644 --- a/packages-private/diagnostics-frontend-browser/src/summarize.ts +++ b/packages-private/diagnostics-frontend/src/browser/summarize.ts @@ -5,7 +5,7 @@ import { finiteMedian } from 'diagnostics-shared/stats'; import { summarizeHeapSnapshotDataSamples } from 'diagnostics-shared/heap-snapshot'; -import { summarizeBrowserDiagnostics } from './browser/diagnostics'; +import { summarizeBrowserDiagnostics } from './diagnostics'; import type { BrowserMeasurement, BrowserMeasurementSample, BrowserMetricsReport, NetworkSummary } from './types'; export type SummarizeOptions = { diff --git a/packages-private/diagnostics-frontend-browser/src/types.ts b/packages-private/diagnostics-frontend/src/browser/types.ts similarity index 87% rename from packages-private/diagnostics-frontend-browser/src/types.ts rename to packages-private/diagnostics-frontend/src/browser/types.ts index df91071c13..78e31b76f4 100644 --- a/packages-private/diagnostics-frontend-browser/src/types.ts +++ b/packages-private/diagnostics-frontend/src/browser/types.ts @@ -5,6 +5,11 @@ import type { HeapSnapshotData } from 'diagnostics-shared/heap-snapshot'; +// NOTE: (AI向け) +// NetworkRequest がリクエスト本文と要求・応答ヘッダを保持し、APIキーやパスワード、Cookieなどの認証情報も含まれ得るが、 +// workflow上で作られるMisskeyアカウントはテスト用アカウントであり、誰かが実際に使用するプライベートなアカウントではないため、 +// それらの情報が第三者から閲覧できても何の問題もなく、脆弱性ではない。 + export type NetworkRequest = { requestId: string; url: string; diff --git a/packages-private/diagnostics-frontend-bundle/src/chunk-report.ts b/packages-private/diagnostics-frontend/src/bundle/chunk-report.ts similarity index 50% rename from packages-private/diagnostics-frontend-bundle/src/chunk-report.ts rename to packages-private/diagnostics-frontend/src/bundle/chunk-report.ts index 2b2492377c..55857305c5 100644 --- a/packages-private/diagnostics-frontend-bundle/src/chunk-report.ts +++ b/packages-private/diagnostics-frontend/src/bundle/chunk-report.ts @@ -9,7 +9,7 @@ import { escapeMdTableCell, formatBytes, } from 'diagnostics-shared/format'; -import type { CollectedReport, FileEntry } from './manifest'; +import type { CollectedBundleReport, FileEntry } from './manifest'; /** * この差分以下のチャンクは個別に出さず `(other)` にまとめる。 @@ -25,21 +25,21 @@ function entryDisplayName(entry: FileEntry | undefined) { return entry.displayName || entry.file; } -export function getChunkComparisonRows(keys: string[], before: Partial>, after: Partial>) { +export function getChunkComparisonRows(keys: string[], base: Partial>, head: Partial>) { return keys.map(key => { - const beforeEntry = before[key]; - const afterEntry = after[key]; - const beforeSize = beforeEntry?.size ?? 0; - const afterSize = afterEntry?.size ?? 0; + const baseEntry = base[key]; + const headEntry = head[key]; + const baseSize = baseEntry?.size ?? 0; + const headSize = headEntry?.size ?? 0; return { key, - name: entryDisplayName(beforeEntry ?? afterEntry), - beforeFile: beforeEntry?.file, - afterFile: afterEntry?.file, - beforeSize, - afterSize, - changeType: beforeEntry == null ? 'added' : afterEntry == null ? 'removed' : beforeSize !== afterSize ? 'updated' : 'unchanged', - sortSize: Math.max(beforeSize, afterSize), + name: entryDisplayName(baseEntry ?? headEntry), + baseFile: baseEntry?.file, + headFile: headEntry?.file, + baseSize, + headSize, + changeType: baseEntry == null ? 'added' : headEntry == null ? 'removed' : baseSize !== headSize ? 'updated' : 'unchanged', + sortSize: Math.max(baseSize, headSize), }; }); } @@ -47,10 +47,10 @@ export function getChunkComparisonRows(keys: string[], before: Partial[number]; export type ChunkAggregate = { - beforeSize: number; - afterSize: number; - beforeCount: number; - afterCount: number; + baseSize: number; + headSize: number; + baseCount: number; + headCount: number; }; export function sumChunkSizes(chunks: FileEntry[]) { @@ -58,29 +58,29 @@ export function sumChunkSizes(chunks: FileEntry[]) { } /** - * 比較キーを持たない (= before/after で対応付けできない) チャンクの合計。 + * 比較キーを持たない (= base/head で対応付けできない) チャンクの合計。 */ -export function generatedAggregate(before: FileEntry[], after: FileEntry[]): ChunkAggregate { - const beforeGenerated = before.filter(chunk => chunk.comparisonKey == null); - const afterGenerated = after.filter(chunk => chunk.comparisonKey == null); +export function generatedAggregate(base: FileEntry[], head: FileEntry[]): ChunkAggregate { + const baseGenerated = base.filter(chunk => chunk.comparisonKey == null); + const headGenerated = head.filter(chunk => chunk.comparisonKey == null); return { - beforeSize: sumChunkSizes(beforeGenerated), - afterSize: sumChunkSizes(afterGenerated), - beforeCount: beforeGenerated.length, - afterCount: afterGenerated.length, + baseSize: sumChunkSizes(baseGenerated), + headSize: sumChunkSizes(headGenerated), + baseCount: baseGenerated.length, + headCount: headGenerated.length, }; } export function hasSmallDelta(row: ChunkComparisonRow) { - return Math.abs(row.afterSize - row.beforeSize) <= smallDeltaThreshold; + return Math.abs(row.headSize - row.baseSize) <= smallDeltaThreshold; } export function comparisonRowsAggregate(rows: ChunkComparisonRow[]): ChunkAggregate { return { - beforeSize: rows.reduce((sum, row) => sum + row.beforeSize, 0), - afterSize: rows.reduce((sum, row) => sum + row.afterSize, 0), - beforeCount: rows.filter(row => row.beforeFile != null).length, - afterCount: rows.filter(row => row.afterFile != null).length, + baseSize: rows.reduce((sum, row) => sum + row.baseSize, 0), + headSize: rows.reduce((sum, row) => sum + row.headSize, 0), + baseCount: rows.filter(row => row.baseFile != null).length, + headCount: rows.filter(row => row.headFile != null).length, }; } @@ -108,69 +108,69 @@ export function formatChunkChangeSummary(label: string, summary: ReturnType 0 || generated.afterCount > 0); - const hasOther = other != null && (other.beforeCount > 0 || other.afterCount > 0); + const hasGenerated = generated != null && (generated.baseCount > 0 || generated.headCount > 0); + const hasOther = other != null && (other.baseCount > 0 || other.headCount > 0); if (rows.length === 0 && total == null && !hasGenerated && !hasOther) return '_No data_'; const lines = [ - '| Chunk | Before | After | Δ | Δ (%) |', + '| Chunk | Base | Head | Δ | Δ (%) |', '| --- | ---: | ---: | ---: | ---: |', ]; if (total != null) { - lines.push(`| (total) | ${formatBytes(total.beforeSize)} | ${formatBytes(total.afterSize)} | ${calcAndFormatDeltaBytes(total.beforeSize, total.afterSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(total.beforeSize, total.afterSize, 0.1)} |`); + lines.push(`| (total) | ${formatBytes(total.baseSize)} | ${formatBytes(total.headSize)} | ${calcAndFormatDeltaBytes(total.baseSize, total.headSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(total.baseSize, total.headSize, 0.1)} |`); lines.push('| | | | | |'); } for (const row of rows) { const chunkFile = chunkFileDisplay(row); if (row.changeType === 'added') { - lines.push(`|
    \`${escapeMdTableCell(row.name)}\` \`${escapeMdTableCell(chunkFile)}\`
    | ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{orange}{\\text{( + )}}$ |`); + lines.push(`|
    \`${escapeMdTableCell(row.name)}\` \`${escapeMdTableCell(chunkFile)}\`
    | ${formatBytes(row.baseSize)} | ${formatBytes(row.headSize)} | ${calcAndFormatDeltaBytes(row.baseSize, row.headSize, 1000)} | $\\color{orange}{\\text{( + )}}$ |`); } else if (row.changeType === 'removed') { - lines.push(`|
    \`${escapeMdTableCell(row.name)}\` \`${escapeMdTableCell(chunkFile)}\`
    | ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{green}{\\text{( - )}}$ |`); + lines.push(`|
    \`${escapeMdTableCell(row.name)}\` \`${escapeMdTableCell(chunkFile)}\`
    | ${formatBytes(row.baseSize)} | ${formatBytes(row.headSize)} | ${calcAndFormatDeltaBytes(row.baseSize, row.headSize, 1000)} | $\\color{green}{\\text{( - )}}$ |`); } else { - lines.push(`|
    \`${escapeMdTableCell(row.name)}\` \`${escapeMdTableCell(chunkFile)}\`
    | ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(row.beforeSize, row.afterSize, 0.1)} |`); + lines.push(`|
    \`${escapeMdTableCell(row.name)}\` \`${escapeMdTableCell(chunkFile)}\`
    | ${formatBytes(row.baseSize)} | ${formatBytes(row.headSize)} | ${calcAndFormatDeltaBytes(row.baseSize, row.headSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(row.baseSize, row.headSize, 0.1)} |`); } } if (hasGenerated) { - lines.push(`| (other generated chunks) | ${formatBytes(generated.beforeSize)} | ${formatBytes(generated.afterSize)} | ${calcAndFormatDeltaBytes(generated.beforeSize, generated.afterSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(generated.beforeSize, generated.afterSize, 0.1)} |`); + lines.push(`| (other generated chunks) | ${formatBytes(generated.baseSize)} | ${formatBytes(generated.headSize)} | ${calcAndFormatDeltaBytes(generated.baseSize, generated.headSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(generated.baseSize, generated.headSize, 0.1)} |`); } if (hasOther) { - lines.push(`| (other) | ${formatBytes(other.beforeSize)} | ${formatBytes(other.afterSize)} | ${calcAndFormatDeltaBytes(other.beforeSize, other.afterSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(other.beforeSize, other.afterSize, 0.1)} |`); + lines.push(`| (other) | ${formatBytes(other.baseSize)} | ${formatBytes(other.headSize)} | ${calcAndFormatDeltaBytes(other.baseSize, other.headSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(other.baseSize, other.headSize, 0.1)} |`); } return lines.join('\n'); } -export function renderFrontendChunkReport(before: CollectedReport, after: CollectedReport) { - const beforeComparable = before.comparableChunks; - const afterComparable = after.comparableChunks; - const allChunkKeys = [...new Set([...Object.keys(beforeComparable), ...Object.keys(afterComparable)])]; - const allComparisonRows = getChunkComparisonRows(allChunkKeys, beforeComparable, afterComparable); +export function renderFrontendChunkReport(base: CollectedBundleReport, head: CollectedBundleReport) { + const baseComparable = base.comparableChunks; + const headComparable = head.comparableChunks; + const allChunkKeys = [...new Set([...Object.keys(baseComparable), ...Object.keys(headComparable)])]; + const allComparisonRows = getChunkComparisonRows(allChunkKeys, baseComparable, headComparable); const changedRows = allComparisonRows.filter((row) => row.changeType !== 'unchanged'); const diffSummary = summarizeChunkChanges(changedRows); const diffTotal = { - beforeSize: sumChunkSizes(before.chunks), - afterSize: sumChunkSizes(after.chunks), + baseSize: sumChunkSizes(base.chunks), + headSize: sumChunkSizes(head.chunks), }; - const diffGenerated = generatedAggregate(before.chunks, after.chunks); + const diffGenerated = generatedAggregate(base.chunks, head.chunks); const largeDeltaRows = changedRows.filter(row => !hasSmallDelta(row)).sort(compareChunkComparisonRows); const diffRows = largeDeltaRows.slice(0, diffRowLimit); // 表示上限で切り捨てた行も `(other)` に含める。落とすと合計が実際の変化量と合わなくなる @@ -179,22 +179,22 @@ export function renderFrontendChunkReport(before: CollectedReport, after: Collec ...largeDeltaRows.slice(diffRowLimit), ]); - const beforeStartupFiles = new Set(before.startupFiles); - const afterStartupFiles = new Set(after.startupFiles); - const beforeStartupChunks = before.chunks.filter(chunk => beforeStartupFiles.has(chunk.file)); - const afterStartupChunks = after.chunks.filter(chunk => afterStartupFiles.has(chunk.file)); - const beforeStartupComparable = comparableMap(beforeStartupChunks); - const afterStartupComparable = comparableMap(afterStartupChunks); - const startupKeys = [...new Set([...Object.keys(beforeStartupComparable), ...Object.keys(afterStartupComparable)])]; - const startupComparisonRows = getChunkComparisonRows(startupKeys, beforeStartupComparable, afterStartupComparable); + const baseStartupFiles = new Set(base.startupFiles); + const headStartupFiles = new Set(head.startupFiles); + const baseStartupChunks = base.chunks.filter(chunk => baseStartupFiles.has(chunk.file)); + const headStartupChunks = head.chunks.filter(chunk => headStartupFiles.has(chunk.file)); + const baseStartupComparable = comparableMap(baseStartupChunks); + const headStartupComparable = comparableMap(headStartupChunks); + const startupKeys = [...new Set([...Object.keys(baseStartupComparable), ...Object.keys(headStartupComparable)])]; + const startupComparisonRows = getChunkComparisonRows(startupKeys, baseStartupComparable, headStartupComparable); const startupSummary = summarizeChunkChanges(startupComparisonRows); const startupOther = comparisonRowsAggregate(startupComparisonRows.filter(hasSmallDelta)); const startupRows = startupComparisonRows.filter(row => !hasSmallDelta(row)).sort(compareChunkComparisonRows); const startupTotal = { - beforeSize: sumChunkSizes(beforeStartupChunks), - afterSize: sumChunkSizes(afterStartupChunks), + baseSize: sumChunkSizes(baseStartupChunks), + headSize: sumChunkSizes(headStartupChunks), }; - const startupGenerated = generatedAggregate(beforeStartupChunks, afterStartupChunks); + const startupGenerated = generatedAggregate(baseStartupChunks, headStartupChunks); return [ '
    ', diff --git a/packages-private/diagnostics-frontend-bundle/src/fs-utils.ts b/packages-private/diagnostics-frontend/src/bundle/fs-utils.ts similarity index 90% rename from packages-private/diagnostics-frontend-bundle/src/fs-utils.ts rename to packages-private/diagnostics-frontend/src/bundle/fs-utils.ts index 74ed757b53..c6b1d3d2e6 100644 --- a/packages-private/diagnostics-frontend-bundle/src/fs-utils.ts +++ b/packages-private/diagnostics-frontend/src/bundle/fs-utils.ts @@ -18,8 +18,9 @@ export async function fileExists(filePath: string) { try { await fs.access(filePath); return true; - } catch { - return false; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; + throw error; } } diff --git a/packages-private/diagnostics-frontend-bundle/src/manifest.ts b/packages-private/diagnostics-frontend/src/bundle/manifest.ts similarity index 90% rename from packages-private/diagnostics-frontend-bundle/src/manifest.ts rename to packages-private/diagnostics-frontend/src/bundle/manifest.ts index d35a5cfbfb..05b00c2a09 100644 --- a/packages-private/diagnostics-frontend-bundle/src/manifest.ts +++ b/packages-private/diagnostics-frontend/src/bundle/manifest.ts @@ -6,7 +6,16 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; import { fileExists, fileSize, normalizePath, traverseDirectory } from './fs-utils'; -import type { Manifest, ManifestChunk } from 'vite'; + +export type BundleManifestChunk = { + file: string; + src?: string; + name?: string; + isEntry?: boolean; + imports?: string[]; +}; + +export type BundleManifest = Record; /** * 比較対象とするロケール。ロケール別チャンクは全ロケール分だと数が多すぎるため、 @@ -27,15 +36,15 @@ export type FileEntry = { size: number; }; -export type CollectedReport = { - manifest: Manifest; +export type CollectedBundleReport = { + manifest: BundleManifest; chunks: FileEntry[]; comparableChunks: Record; chunksByManifestKey: Record; startupFiles: string[]; }; -export function findEntryKey(manifest: Manifest) { +export function findEntryKey(manifest: BundleManifest) { const entries = Object.entries(manifest); return entries.find(([key, chunk]) => key === 'src/_boot_.ts' || chunk.src === 'src/_boot_.ts')?.[0] ?? entries.find(([, chunk]) => chunk.name === 'entry' && chunk.isEntry)?.[0] @@ -45,9 +54,9 @@ export function findEntryKey(manifest: Manifest) { /** * ビルド間で安定するチャンク識別子。出力ファイル名はハッシュ付きで毎回変わるため、 - * これが取れないチャンクは before/after の対応付けができない。 + * これが取れないチャンクは base/head の対応付けができない。 */ -export function stableChunkKey(chunk: ManifestChunk) { +export function stableChunkKey(chunk: BundleManifestChunk) { if (chunk.src != null) return `src:${normalizePath(chunk.src)}`; if (chunk.name != null && stableNamedChunks.has(chunk.name)) return `named:${chunk.name}`; return null; @@ -56,7 +65,7 @@ export function stableChunkKey(chunk: ManifestChunk) { /** * 起動時に必ず読み込まれるチャンク (entry とその静的 import) の manifest キーを集める。 */ -export function collectStartupManifestKeys(manifest: Manifest) { +export function collectStartupManifestKeys(manifest: BundleManifest) { const entryKey = findEntryKey(manifest); const keys = new Set(); if (entryKey == null) throw new Error('Unable to find frontend startup entry in Vite manifest.'); @@ -101,10 +110,10 @@ export async function resolveBuiltFile(outDir: string, file: string) { }; } -export async function collectReport(repoDir: string): Promise { +export async function collectBundleReport(repoDir: string): Promise { const outDir = path.join(repoDir, 'built/_frontend_vite_'); const manifestPath = path.join(outDir, 'manifest.json'); - const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as Manifest; + const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as BundleManifest; const chunksByFile = new Map(); const comparableChunks = new Map(); const chunksByManifestKey = new Map(); diff --git a/packages-private/diagnostics-frontend-bundle/src/visualizer.ts b/packages-private/diagnostics-frontend/src/bundle/visualizer.ts similarity index 83% rename from packages-private/diagnostics-frontend-bundle/src/visualizer.ts rename to packages-private/diagnostics-frontend/src/bundle/visualizer.ts index 17e4f190b6..199accf2d7 100644 --- a/packages-private/diagnostics-frontend-bundle/src/visualizer.ts +++ b/packages-private/diagnostics-frontend/src/bundle/visualizer.ts @@ -141,7 +141,7 @@ export function collectVisualizerReport(data: VisualizerReport) { * 要素として配列のまま埋め込まれていたため、テーブルがカンマ区切りの1行に潰れていた。 * 呼び出し側で意識しなくて済むよう、ここで文字列にして返す。 */ -export function renderVisualizerSummaryTable(before: ReturnType, after: ReturnType) { +export function renderVisualizerSummaryTable(base: ReturnType, head: ReturnType) { const summary = [ 'bundles', 'modules', @@ -178,25 +178,25 @@ export function renderVisualizerSummaryTable(before: ReturnType', '', '', - 'Before', - ...summary.map((key) => `${formatNumber(before.summary[key])}`), - ...metrics.map((key) => `${formatBytes(before.metrics[key])}`), + 'Base', + ...summary.map((key) => `${formatNumber(base.summary[key])}`), + ...metrics.map((key) => `${formatBytes(base.metrics[key])}`), '', '', - 'After', - ...summary.map((key) => `${formatNumber(after.summary[key])}`), - ...metrics.map((key) => `${formatBytes(after.metrics[key])}`), + 'Head', + ...summary.map((key) => `${formatNumber(head.summary[key])}`), + ...metrics.map((key) => `${formatBytes(head.metrics[key])}`), '', '', '', 'Δ', - ...summary.map((key) => `${calcAndFormatDeltaNumber(before.summary[key], after.summary[key], 0)}`), - ...metrics.map((key) => `${calcAndFormatDeltaBytes(before.metrics[key], after.metrics[key], 1000)}`), + ...summary.map((key) => `${calcAndFormatDeltaNumber(base.summary[key], head.summary[key], 0)}`), + ...metrics.map((key) => `${calcAndFormatDeltaBytes(base.metrics[key], head.metrics[key], 1000)}`), '', '', 'Δ (%)', - ...summary.map((key) => `${calcAndFormatDeltaPercent(before.summary[key], after.summary[key], 0.1)}`), - ...metrics.map((key) => `${calcAndFormatDeltaPercent(before.metrics[key], after.metrics[key], 0.1)}`), + ...summary.map((key) => `${calcAndFormatDeltaPercent(base.summary[key], head.summary[key], 0.1)}`), + ...metrics.map((key) => `${calcAndFormatDeltaPercent(base.metrics[key], head.metrics[key], 0.1)}`), '', '', '', diff --git a/packages-private/diagnostics-frontend-browser/src/inspect.ts b/packages-private/diagnostics-frontend/src/inspect-browser.ts similarity index 95% rename from packages-private/diagnostics-frontend-browser/src/inspect.ts rename to packages-private/diagnostics-frontend/src/inspect-browser.ts index 9cf08664e9..25d2703e99 100644 --- a/packages-private/diagnostics-frontend-browser/src/inspect.ts +++ b/packages-private/diagnostics-frontend/src/inspect-browser.ts @@ -9,10 +9,10 @@ import { readIntegerEnv, readOptionalEnv } from 'diagnostics-shared/env'; import { analyzeHeapSnapshot, defaultHeapSnapshotBreakdownTopN } from 'diagnostics-shared/heap-snapshot'; import { HeadlessChromeController } from './browser/controller'; import { summarizeNetwork } from './browser/network'; -import { prepareInstance, runSignupAndPostScenario, scenarioDescription } from './scenario'; -import { startServer, stopServer, waitForServer } from './server'; -import { selectRepresentativeSample, summarizeSamples } from './summarize'; -import type { BrowserMeasurementSample, BrowserMetricsReport } from './types'; +import { prepareInstance, runSignupAndPostScenario, scenarioDescription } from './browser/scenario'; +import { startServer, stopServer, waitForServer } from './browser/server'; +import { selectRepresentativeSample, summarizeSamples } from './browser/summarize'; +import type { BrowserMeasurementSample, BrowserMetricsReport } from './browser/types'; type Label = 'base' | 'head'; @@ -110,7 +110,7 @@ async function genReport(label: Label, repoDir: string, outputPath: string) { async function main() { const [baseDirArg, headDirArg, baseOutputArg, headOutputArg] = process.argv.slice(2); if (baseDirArg == null || headDirArg == null || baseOutputArg == null || headOutputArg == null) { - throw new Error('Usage: inspect '); + throw new Error('Usage: inspect-browser '); } for (const label of labels) { diff --git a/packages-private/diagnostics-frontend-browser/src/render-html.ts b/packages-private/diagnostics-frontend/src/render-browser-html.ts similarity index 68% rename from packages-private/diagnostics-frontend-browser/src/render-html.ts rename to packages-private/diagnostics-frontend/src/render-browser-html.ts index d3ed90927a..c6567e088e 100644 --- a/packages-private/diagnostics-frontend-browser/src/render-html.ts +++ b/packages-private/diagnostics-frontend/src/render-browser-html.ts @@ -5,19 +5,19 @@ import { readFile, writeFile } from 'node:fs/promises'; import { resolve } from 'node:path'; -import { renderHtml } from './report/html'; -import type { BrowserMetricsReport } from './types'; +import { renderBrowserDiagnosticsHtml } from './browser/report/html'; +import type { BrowserMetricsReport } from './browser/types'; async function main() { const [baseFileArg, headFileArg, outputFileArg] = process.argv.slice(2); if (baseFileArg == null || headFileArg == null || outputFileArg == null) { - throw new Error('Usage: render-html '); + throw new Error('Usage: render-browser-html '); } const base = JSON.parse(await readFile(resolve(baseFileArg), 'utf8')) as BrowserMetricsReport; const head = JSON.parse(await readFile(resolve(headFileArg), 'utf8')) as BrowserMetricsReport; - await writeFile(resolve(outputFileArg), renderHtml(base, head)); + await writeFile(resolve(outputFileArg), renderBrowserDiagnosticsHtml(base, head)); } await main().catch(err => { diff --git a/packages-private/diagnostics-frontend/src/render-md.ts b/packages-private/diagnostics-frontend/src/render-md.ts new file mode 100644 index 0000000000..a9cfbc1cd1 --- /dev/null +++ b/packages-private/diagnostics-frontend/src/render-md.ts @@ -0,0 +1,62 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { readOptionalEnv, readRequiredEnv } from 'diagnostics-shared/env'; +import { collectBundleReport } from './bundle/manifest'; +import { renderFrontendDiagnosticsMarkdown } from './report'; +import type { BrowserMetricsReport } from './browser/types'; +import type { VisualizerReport } from './bundle/visualizer'; + +const args = process.argv.slice(2); +if (args.length !== 7) { + throw new Error('Usage: render-md '); +} +const [ + baseDirArg, + headDirArg, + baseBundleStatsFileArg, + headBundleStatsFileArg, + baseBrowserFileArg, + headBrowserFileArg, + outputFileArg, +] = args as [string, string, string, string, string, string, string]; + +const [ + baseBundle, + headBundle, + baseBundleStatsJson, + headBundleStatsJson, + baseBrowserJson, + headBrowserJson, +] = await Promise.all([ + collectBundleReport(resolve(baseDirArg)), + collectBundleReport(resolve(headDirArg)), + readFile(resolve(baseBundleStatsFileArg), 'utf8'), + readFile(resolve(headBundleStatsFileArg), 'utf8'), + readFile(resolve(baseBrowserFileArg), 'utf8'), + readFile(resolve(headBrowserFileArg), 'utf8'), +]); + +await writeFile( + resolve(outputFileArg), + renderFrontendDiagnosticsMarkdown({ + bundle: { + base: baseBundle, + head: headBundle, + baseStats: JSON.parse(baseBundleStatsJson) as VisualizerReport, + headStats: JSON.parse(headBundleStatsJson) as VisualizerReport, + visualizerArtifactUrl: readRequiredEnv('FRONTEND_BUNDLE_REPORT_ARTIFACT_URL'), + }, + browser: { + base: JSON.parse(baseBrowserJson) as BrowserMetricsReport, + head: JSON.parse(headBrowserJson) as BrowserMetricsReport, + baseHeapSnapshotUrl: readRequiredEnv('FRONTEND_BROWSER_BASE_HEAP_SNAPSHOT_ARTIFACT_URL'), + headHeapSnapshotUrl: readRequiredEnv('FRONTEND_BROWSER_HEAD_HEAP_SNAPSHOT_ARTIFACT_URL'), + detailedHtmlUrl: readOptionalEnv('FRONTEND_BROWSER_DETAILED_HTML_ARTIFACT_URL'), + }, + }), +); diff --git a/packages-private/diagnostics-frontend-browser/src/report/markdown.ts b/packages-private/diagnostics-frontend/src/report.ts similarity index 86% rename from packages-private/diagnostics-frontend-browser/src/report/markdown.ts rename to packages-private/diagnostics-frontend/src/report.ts index 370cb86892..e605b0f931 100644 --- a/packages-private/diagnostics-frontend-browser/src/report/markdown.ts +++ b/packages-private/diagnostics-frontend/src/report.ts @@ -4,14 +4,29 @@ */ import { formatBytes, formatColoredDelta, formatNumber } from 'diagnostics-shared/format'; -import { pairedDeltaSummary, sampleSpread } from 'diagnostics-shared/stats'; import { renderHeapSnapshotTable, type HeapSnapshotReport } from 'diagnostics-shared/heap-snapshot'; -import type { BrowserMeasurement, BrowserMeasurementSample, BrowserMetricsReport } from '../types'; +import { pairedDeltaSummary } from 'diagnostics-shared/stats'; +import { renderFrontendChunkReport } from './bundle/chunk-report'; +import { collectVisualizerReport, renderVisualizerSummaryTable, type VisualizerReport } from './bundle/visualizer'; +import type { CollectedBundleReport } from './bundle/manifest'; +import type { BrowserMeasurement, BrowserMeasurementSample, BrowserMetricsReport } from './browser/types'; -export type RenderMarkdownOptions = { - baseHeapSnapshotUrl: string; - headHeapSnapshotUrl: string; - detailedHtmlUrl?: string | null; +export type FrontendDiagnosticsMarkdownInput = { + bundle: { + base: CollectedBundleReport; + head: CollectedBundleReport; + baseStats: VisualizerReport; + headStats: VisualizerReport; + /** rollup-plugin-visualizer が出力したtreemap HTMLのartifact URL */ + visualizerArtifactUrl: string; + }; + browser: { + base: BrowserMetricsReport; + head: BrowserMetricsReport; + baseHeapSnapshotUrl: string; + headHeapSnapshotUrl: string; + detailedHtmlUrl?: string | null; + }; }; function renderMetricRow( @@ -45,7 +60,7 @@ function resourceTypeSampleBytes(sample: BrowserMeasurementSample, resourceTypes return resourceTypeBytes(sample, resourceTypes); } -function renderSummaryTable(base: BrowserMetricsReport, head: BrowserMetricsReport, all = false) { +function renderBrowserSummaryTable(base: BrowserMetricsReport, head: BrowserMetricsReport, all = false) { //function getMetric(report: BrowserMeasurement, key: string) { // return report.performance.cdpMetrics[key]; //} @@ -154,13 +169,14 @@ function toHeapSnapshotReport(report: BrowserMetricsReport): HeapSnapshotReport }; } -export function renderMarkdown(base: BrowserMetricsReport, head: BrowserMetricsReport, options: RenderMarkdownOptions) { - const detailedHtmlUrl = options.detailedHtmlUrl; - const heapSnapshotTable = renderHeapSnapshotTable(toHeapSnapshotReport(base), toHeapSnapshotReport(head)); +export function renderFrontendDiagnosticsMarkdown(input: FrontendDiagnosticsMarkdownInput) { + const { bundle, browser } = input; + const detailedHtmlUrl = browser.detailedHtmlUrl; + const heapSnapshotTable = renderHeapSnapshotTable(toHeapSnapshotReport(browser.base), toHeapSnapshotReport(browser.head)); const lines = [ - '## 🖥 Frontend Browser Diagnostics Report', + '## 🖥 Frontend Diagnostics Report', '', - renderSummaryTable(base, head), + renderBrowserSummaryTable(browser.base, browser.head), '', 'Only metrics showing significant changes are displayed.', '', @@ -169,7 +185,7 @@ export function renderMarkdown(base: BrowserMetricsReport, head: BrowserMetricsR '
    ', 'Requests by resource type', '', - renderResourceTypeTable(base, head), + renderResourceTypeTable(browser.base, browser.head), '', '
    ', '', @@ -178,11 +194,19 @@ export function renderMarkdown(base: BrowserMetricsReport, head: BrowserMetricsR '', heapSnapshotTable ?? '_No V8 heap snapshot data._', '', - //renderHeapSnapshotSankey(toHeapSnapshotReport(head), 'Head'), + //renderHeapSnapshotSankey(toHeapSnapshotReport(browser.head), 'Head'), //'', - `Download representative heap snapshot: [base](${options.baseHeapSnapshotUrl}) / [head](${options.headHeapSnapshotUrl})`, + `Download representative heap snapshot: [base](${browser.baseHeapSnapshotUrl}) / [head](${browser.headHeapSnapshotUrl})`, '
    ', '', + '### 📦 Bundle Stats', + '', + renderFrontendChunkReport(bundle.base, bundle.head), + '', + renderVisualizerSummaryTable(collectVisualizerReport(bundle.baseStats), collectVisualizerReport(bundle.headStats)), + '', + `[Open treemap HTML](${bundle.visualizerArtifactUrl})`, + '', ]; return lines.filter(line => line != null).join('\n').trimEnd() + '\n'; diff --git a/packages-private/diagnostics-frontend-browser/test/__snapshots__/render-md.md b/packages-private/diagnostics-frontend/test/__snapshots__/report.md similarity index 62% rename from packages-private/diagnostics-frontend-browser/test/__snapshots__/render-md.md rename to packages-private/diagnostics-frontend/test/__snapshots__/report.md index 0f791057c2..41d3aae727 100644 --- a/packages-private/diagnostics-frontend-browser/test/__snapshots__/render-md.md +++ b/packages-private/diagnostics-frontend/test/__snapshots__/report.md @@ -1,4 +1,4 @@ -## 🖥 Frontend Browser Diagnostics Report +## 🖥 Frontend Diagnostics Report | Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max | | --- | ---: | ---: | ---: | ---: | ---: | ---: | @@ -81,3 +81,102 @@ Download representative heap snapshot: [base](https://example.invalid/base) / [head](https://example.invalid/head)
    + +### 📦 Bundle Stats + +
    +Chunk size diff (2 updated, 0 added, 0 removed) + +| Chunk | Base | Head | Δ | Δ (%) | +| --- | ---: | ---: | ---: | ---: | +| (total) | 120 KB | 127 KB | $\color{orange}{\text{+6.3 KB}}$ | $\color{orange}{\text{+5.2\\%}}$ | +| | | | | | +|
    `vue` `assets/vue-b2.js`
    | 90 KB | 96 KB | $\color{orange}{\text{+6 KB}}$ | $\color{orange}{\text{+6.7\\%}}$ | +| (other generated chunks) | 1.2 KB | 1.5 KB | $\text{+300 B}$ | $\color{orange}{\text{+25\\%}}$ | +| (other) | 20 KB | 20 KB | $\text{+3 B}$ | $\text{+0\\%}$ | + +
    + +
    +Startup chunk size (2 updated, 0 added, 0 removed) + +| Chunk | Base | Head | Δ | Δ (%) | +| --- | ---: | ---: | ---: | ---: | +| (total) | 114 KB | 120 KB | $\color{orange}{\text{+6 KB}}$ | $\color{orange}{\text{+5.3\\%}}$ | +| | | | | | +|
    `vue` `assets/vue-b2.js`
    | 90 KB | 96 KB | $\color{orange}{\text{+6 KB}}$ | $\color{orange}{\text{+6.7\\%}}$ | +| (other) | 24 KB | 24 KB | $\text{+3 B}$ | $\text{+0\\%}$ | + +_Startup chunks are the Vite entry for `src/_boot_.ts` and its static imports._ + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    BundlesModulesEntriesImportsSize
    StaticDynamicRenderedGzipBrotli
    Base2612321 KB6.3 KB5.3 KB
    Head2713331 KB8.4 KB7 KB
    Δ0$\color{orange}{\text{+1}}$0$\color{orange}{\text{+1}}$0$\color{orange}{\text{+9.8 KB}}$$\color{orange}{\text{+2.1 KB}}$$\color{orange}{\text{+1.8 KB}}$
    Δ (%)0%$\color{orange}{\text{+16.7\%}}$0%$\color{orange}{\text{+50\%}}$0%$\color{orange}{\text{+46.7\%}}$$\color{orange}{\text{+33.3\%}}$$\color{orange}{\text{+33.3\%}}$
    + +[Open treemap HTML](https://example.invalid/treemap) diff --git a/packages-private/diagnostics-frontend-browser/test/__snapshots__/render-html.html b/packages-private/diagnostics-frontend/test/browser/__snapshots__/render-html.html similarity index 100% rename from packages-private/diagnostics-frontend-browser/test/__snapshots__/render-html.html rename to packages-private/diagnostics-frontend/test/browser/__snapshots__/render-html.html diff --git a/packages-private/diagnostics-frontend-browser/test/fixtures/base.json b/packages-private/diagnostics-frontend/test/browser/fixtures/base.json similarity index 100% rename from packages-private/diagnostics-frontend-browser/test/fixtures/base.json rename to packages-private/diagnostics-frontend/test/browser/fixtures/base.json diff --git a/packages-private/diagnostics-frontend-browser/test/fixtures/head.json b/packages-private/diagnostics-frontend/test/browser/fixtures/head.json similarity index 100% rename from packages-private/diagnostics-frontend-browser/test/fixtures/head.json rename to packages-private/diagnostics-frontend/test/browser/fixtures/head.json diff --git a/packages-private/diagnostics-frontend/test/browser/render.test.ts b/packages-private/diagnostics-frontend/test/browser/render.test.ts new file mode 100644 index 0000000000..9def362ff6 --- /dev/null +++ b/packages-private/diagnostics-frontend/test/browser/render.test.ts @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; +import { renderBrowserDiagnosticsHtml } from '../../src/browser/report/html'; +import type { BrowserMetricsReport } from '../../src/browser/types'; + +const fixturesDir = join(import.meta.dirname, 'fixtures'); + +async function loadFixture(name: string) { + return JSON.parse(await readFile(join(fixturesDir, `${name}.json`), 'utf8')) as BrowserMetricsReport; +} + +beforeEach(() => { + // renderBrowserDiagnosticsHtml() が生成時刻を埋め込むので、スナップショットが揺れないよう時刻を固定する + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-18T00:00:00.000Z')); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +test('renders the network request diff html report', async () => { + const html = renderBrowserDiagnosticsHtml(await loadFixture('base'), await loadFixture('head')); + + await expect(html).toMatchFileSnapshot('./__snapshots__/render-html.html'); +}); + +test('escapes html metacharacters coming from the browser session', async () => { + const base = await loadFixture('base'); + const head = await loadFixture('head'); + // CDP由来の値は原理的には任意の文字列になりうるので、生HTMLとして出ないことを確かめる + head.samples[0].networkRequests[0].url = 'http://127.0.0.1:61812/">'; + + const html = renderBrowserDiagnosticsHtml(base, head); + + expect(html).not.toContain(''); + expect(html).toContain('<script>alert(1)</script>'); +}); diff --git a/packages-private/diagnostics-frontend/test/browser/server.test.ts b/packages-private/diagnostics-frontend/test/browser/server.test.ts new file mode 100644 index 0000000000..24f3e62444 --- /dev/null +++ b/packages-private/diagnostics-frontend/test/browser/server.test.ts @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { EventEmitter } from 'node:events'; +import { afterEach, expect, test, vi } from 'vitest'; +import { stopServer, waitForServer } from '../../src/browser/server'; +import type { ChildProcess } from 'node:child_process'; + +vi.mock('node:child_process', async () => { + const actual = await vi.importActual('node:child_process'); + return { + ...actual, + spawnSync: vi.fn(), + }; +}); + +function signaledChild() { + return Object.assign(new EventEmitter(), { + exitCode: null, + signalCode: 'SIGTERM' as NodeJS.Signals, + pid: undefined, + kill: vi.fn(), + }) as unknown as ChildProcess; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +test('waitForServer fails immediately when the server exited by signal', async () => { + const fetchMock = vi.fn().mockResolvedValue({ status: 200 }); + vi.stubGlobal('fetch', fetchMock); + + await expect(waitForServer('http://127.0.0.1:61812', signaledChild())) + .rejects.toThrow('Misskey server exited early with signal SIGTERM'); + expect(fetchMock).not.toHaveBeenCalled(); +}); + +test('stopServer returns immediately when the server already exited by signal', async () => { + const child = signaledChild(); + const stopPromise = stopServer(child); + + expect(child.listenerCount('exit')).toBe(0); + await stopPromise; +}); diff --git a/packages-private/diagnostics-frontend-bundle/test/fixtures/before-stats.json b/packages-private/diagnostics-frontend/test/bundle/fixtures/base-stats.json similarity index 99% rename from packages-private/diagnostics-frontend-bundle/test/fixtures/before-stats.json rename to packages-private/diagnostics-frontend/test/bundle/fixtures/base-stats.json index 0a54b93148..27f6b2d6b4 100644 --- a/packages-private/diagnostics-frontend-bundle/test/fixtures/before-stats.json +++ b/packages-private/diagnostics-frontend/test/bundle/fixtures/base-stats.json @@ -1 +1 @@ -{"nodeParts": {"p0": {"renderedLength": 1000, "gzipLength": 300, "brotliLength": 250}, "p1": {"renderedLength": 2000, "gzipLength": 600, "brotliLength": 500}, "p2": {"renderedLength": 3000, "gzipLength": 900, "brotliLength": 750}, "p3": {"renderedLength": 4000, "gzipLength": 1200, "brotliLength": 1000}, "p4": {"renderedLength": 5000, "gzipLength": 1500, "brotliLength": 1250}, "p5": {"renderedLength": 6000, "gzipLength": 1800, "brotliLength": 1500}}, "nodeMetas": {"m0": {"id": "/src/mod0.ts", "isEntry": true, "importedBy": [], "imported": [{"id": "m1", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p0"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m1": {"id": "/src/mod1.ts", "isEntry": false, "importedBy": ["m0"], "imported": [{"id": "m2", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p1"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m2": {"id": "/src/mod2.ts", "isEntry": false, "importedBy": ["m1"], "imported": [{"id": "m3", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p2"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m3": {"id": "/src/mod3.ts", "isEntry": false, "importedBy": ["m2"], "imported": [{"id": "m4", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p3"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m4": {"id": "/src/mod4.ts", "isEntry": false, "importedBy": ["m3"], "imported": [{"id": "m5", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p4"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m5": {"id": "/src/mod5.ts", "isEntry": false, "importedBy": ["m4"], "imported": [], "moduleParts": {"assets/vue-b2.js": "p5"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}}, "options": {}} \ No newline at end of file +{"nodeParts": {"p0": {"renderedLength": 1000, "gzipLength": 300, "brotliLength": 250}, "p1": {"renderedLength": 2000, "gzipLength": 600, "brotliLength": 500}, "p2": {"renderedLength": 3000, "gzipLength": 900, "brotliLength": 750}, "p3": {"renderedLength": 4000, "gzipLength": 1200, "brotliLength": 1000}, "p4": {"renderedLength": 5000, "gzipLength": 1500, "brotliLength": 1250}, "p5": {"renderedLength": 6000, "gzipLength": 1800, "brotliLength": 1500}}, "nodeMetas": {"m0": {"id": "/src/mod0.ts", "isEntry": true, "importedBy": [], "imported": [{"id": "m1", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p0"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m1": {"id": "/src/mod1.ts", "isEntry": false, "importedBy": ["m0"], "imported": [{"id": "m2", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p1"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m2": {"id": "/src/mod2.ts", "isEntry": false, "importedBy": ["m1"], "imported": [{"id": "m3", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p2"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m3": {"id": "/src/mod3.ts", "isEntry": false, "importedBy": ["m2"], "imported": [{"id": "m4", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p3"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m4": {"id": "/src/mod4.ts", "isEntry": false, "importedBy": ["m3"], "imported": [{"id": "m5", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p4"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m5": {"id": "/src/mod5.ts", "isEntry": false, "importedBy": ["m4"], "imported": [], "moduleParts": {"assets/vue-b2.js": "p5"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}}, "options": {}} diff --git a/packages-private/diagnostics-frontend-bundle/test/fixtures/after-stats.json b/packages-private/diagnostics-frontend/test/bundle/fixtures/head-stats.json similarity index 98% rename from packages-private/diagnostics-frontend-bundle/test/fixtures/after-stats.json rename to packages-private/diagnostics-frontend/test/bundle/fixtures/head-stats.json index b69e3f4cea..ecddcae812 100644 --- a/packages-private/diagnostics-frontend-bundle/test/fixtures/after-stats.json +++ b/packages-private/diagnostics-frontend/test/bundle/fixtures/head-stats.json @@ -1 +1 @@ -{"nodeParts": {"p0": {"renderedLength": 1100.0, "gzipLength": 300, "brotliLength": 250}, "p1": {"renderedLength": 2200.0, "gzipLength": 600, "brotliLength": 500}, "p2": {"renderedLength": 3300.0000000000005, "gzipLength": 900, "brotliLength": 750}, "p3": {"renderedLength": 4400.0, "gzipLength": 1200, "brotliLength": 1000}, "p4": {"renderedLength": 5500.0, "gzipLength": 1500, "brotliLength": 1250}, "p5": {"renderedLength": 6600.000000000001, "gzipLength": 1800, "brotliLength": 1500}, "p6": {"renderedLength": 7700.000000000001, "gzipLength": 2100, "brotliLength": 1750}}, "nodeMetas": {"m0": {"id": "/src/mod0.ts", "isEntry": true, "importedBy": [], "imported": [{"id": "m1", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p0"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m1": {"id": "/src/mod1.ts", "isEntry": false, "importedBy": ["m0"], "imported": [{"id": "m2", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p1"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m2": {"id": "/src/mod2.ts", "isEntry": false, "importedBy": ["m1"], "imported": [{"id": "m3", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p2"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m3": {"id": "/src/mod3.ts", "isEntry": false, "importedBy": ["m2"], "imported": [{"id": "m4", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p3"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m4": {"id": "/src/mod4.ts", "isEntry": false, "importedBy": ["m3"], "imported": [{"id": "m5", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p4"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m5": {"id": "/src/mod5.ts", "isEntry": false, "importedBy": ["m4"], "imported": [{"id": "m6", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p5"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m6": {"id": "/src/mod6.ts", "isEntry": false, "importedBy": ["m5"], "imported": [], "moduleParts": {"assets/boot-a1.js": "p6"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}}, "options": {}} \ No newline at end of file +{"nodeParts": {"p0": {"renderedLength": 1100.0, "gzipLength": 300, "brotliLength": 250}, "p1": {"renderedLength": 2200.0, "gzipLength": 600, "brotliLength": 500}, "p2": {"renderedLength": 3300.0000000000005, "gzipLength": 900, "brotliLength": 750}, "p3": {"renderedLength": 4400.0, "gzipLength": 1200, "brotliLength": 1000}, "p4": {"renderedLength": 5500.0, "gzipLength": 1500, "brotliLength": 1250}, "p5": {"renderedLength": 6600.000000000001, "gzipLength": 1800, "brotliLength": 1500}, "p6": {"renderedLength": 7700.000000000001, "gzipLength": 2100, "brotliLength": 1750}}, "nodeMetas": {"m0": {"id": "/src/mod0.ts", "isEntry": true, "importedBy": [], "imported": [{"id": "m1", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p0"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m1": {"id": "/src/mod1.ts", "isEntry": false, "importedBy": ["m0"], "imported": [{"id": "m2", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p1"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m2": {"id": "/src/mod2.ts", "isEntry": false, "importedBy": ["m1"], "imported": [{"id": "m3", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p2"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m3": {"id": "/src/mod3.ts", "isEntry": false, "importedBy": ["m2"], "imported": [{"id": "m4", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p3"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m4": {"id": "/src/mod4.ts", "isEntry": false, "importedBy": ["m3"], "imported": [{"id": "m5", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p4"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m5": {"id": "/src/mod5.ts", "isEntry": false, "importedBy": ["m4"], "imported": [{"id": "m6", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p5"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m6": {"id": "/src/mod6.ts", "isEntry": false, "importedBy": ["m5"], "imported": [], "moduleParts": {"assets/boot-a1.js": "p6"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}}, "options": {}} diff --git a/packages-private/diagnostics-frontend/test/bundle/fs-utils.test.ts b/packages-private/diagnostics-frontend/test/bundle/fs-utils.test.ts new file mode 100644 index 0000000000..12ec8d7bb1 --- /dev/null +++ b/packages-private/diagnostics-frontend/test/bundle/fs-utils.test.ts @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { promises as fs } from 'node:fs'; +import { afterEach, expect, test, vi } from 'vitest'; +import { fileExists } from '../../src/bundle/fs-utils'; + +function fsError(code: string) { + return Object.assign(new Error(`fs error: ${code}`), { code }) as NodeJS.ErrnoException; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +test('fileExists returns false for ENOENT', async () => { + vi.spyOn(fs, 'access').mockRejectedValueOnce(fsError('ENOENT')); + + await expect(fileExists('missing')).resolves.toBe(false); +}); + +test('fileExists rethrows errors other than ENOENT', async () => { + const error = fsError('EACCES'); + vi.spyOn(fs, 'access').mockRejectedValueOnce(error); + + await expect(fileExists('restricted')).rejects.toBe(error); +}); diff --git a/packages-private/diagnostics-frontend/test/bundle/manifest.test.ts b/packages-private/diagnostics-frontend/test/bundle/manifest.test.ts new file mode 100644 index 0000000000..ed91120f5f --- /dev/null +++ b/packages-private/diagnostics-frontend/test/bundle/manifest.test.ts @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, expect, test } from 'vitest'; +import { collectBundleReport } from '../../src/bundle/manifest'; + +let workDir: string; + +beforeAll(async () => { + workDir = await mkdtemp(join(tmpdir(), 'diagnostics-frontend-test-')); +}); + +afterAll(async () => { + await rm(workDir, { recursive: true, force: true }); +}); + +test('fails loudly when the built output is missing', async () => { + await expect(collectBundleReport(join(workDir, 'nonexistent'))).rejects.toThrow(); +}); diff --git a/packages-private/diagnostics-frontend-bundle/test/render-md.test.ts b/packages-private/diagnostics-frontend/test/report.test.ts similarity index 53% rename from packages-private/diagnostics-frontend-bundle/test/render-md.test.ts rename to packages-private/diagnostics-frontend/test/report.test.ts index 17adcef883..c45961ccb9 100644 --- a/packages-private/diagnostics-frontend-bundle/test/render-md.test.ts +++ b/packages-private/diagnostics-frontend/test/report.test.ts @@ -7,16 +7,18 @@ import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { afterAll, beforeAll, expect, test } from 'vitest'; -import { collectReport } from '../src/manifest'; -import { renderBundleReportMarkdown } from '../src/report'; -import type { VisualizerReport } from '../src/visualizer'; +import { collectBundleReport } from '../src/bundle/manifest'; +import { renderFrontendDiagnosticsMarkdown } from '../src/report'; +import type { BrowserMetricsReport } from '../src/browser/types'; +import type { VisualizerReport } from '../src/bundle/visualizer'; -const fixturesDir = join(import.meta.dirname, 'fixtures'); +const bundleFixturesDir = join(import.meta.dirname, 'bundle/fixtures'); +const browserFixturesDir = join(import.meta.dirname, 'browser/fixtures'); /** * ビルド成果物のfixture。 * - * `collectReport` はファイルの中身を見ずサイズしか使わないので、実体は指定バイト数の + * `collectBundleReport` はファイルの中身を見ずサイズしか使わないので、実体は指定バイト数の * 詰め物でよい。ディレクトリ名が `built` になるためリポジトリにはコミットできず * (ルートの .gitignore がビルド成果物として除外する)、テスト実行時に組み立てている。 */ @@ -31,7 +33,7 @@ const manifest = { }; const fileSizes = { - before: { + base: { 'assets/boot-a1.js': 20_000, 'assets/vue-b2.js': 90_000, 'assets/foo-d4.js': 5_000, @@ -39,7 +41,7 @@ const fileSizes = { 'ja-JP/i18n-c3.js': 4_000, 'ja-JP/orphan.js': 1_200, }, - after: { + head: { // 差が小さすぎる (閾値5バイト以下) ので「(other)」に集約される 'assets/boot-a1.js': 20_003, // 明確に増えるので diff表に行として出る @@ -50,15 +52,15 @@ const fileSizes = { // manifestに載らない出力なので「(other generated chunks)」に集約される 'ja-JP/orphan.js': 1_500, }, -} as const satisfies Record<'before' | 'after', Record>; +} as const satisfies Record<'base' | 'head', Record>; -let repoDirs: { before: string; after: string }; +let repoDirs: { base: string; head: string }; let workDir: string; beforeAll(async () => { - workDir = await mkdtemp(join(tmpdir(), 'diagnostics-frontend-bundle-')); + workDir = await mkdtemp(join(tmpdir(), 'diagnostics-frontend-report-test-')); - for (const label of ['before', 'after'] as const) { + for (const label of ['base', 'head'] as const) { const outDir = join(workDir, label, 'built/_frontend_vite_'); await mkdir(outDir, { recursive: true }); await writeFile(join(outDir, 'manifest.json'), JSON.stringify(manifest)); @@ -71,8 +73,8 @@ beforeAll(async () => { } repoDirs = { - before: join(workDir, 'before'), - after: join(workDir, 'after'), + base: join(workDir, 'base'), + head: join(workDir, 'head'), }; }); @@ -80,26 +82,45 @@ afterAll(async () => { await rm(workDir, { recursive: true, force: true }); }); -async function loadStats(name: string) { - return JSON.parse(await readFile(join(fixturesDir, `${name}-stats.json`), 'utf8')) as VisualizerReport; +async function loadBundleStats(name: 'base' | 'head') { + return JSON.parse(await readFile(join(bundleFixturesDir, `${name}-stats.json`), 'utf8')) as VisualizerReport; +} + +async function loadBrowserReport(name: 'base' | 'head') { + return JSON.parse(await readFile(join(browserFixturesDir, `${name}.json`), 'utf8')) as BrowserMetricsReport; +} + +async function renderReport(detailedHtmlUrl: string | null) { + return renderFrontendDiagnosticsMarkdown({ + bundle: { + base: await collectBundleReport(repoDirs.base), + head: await collectBundleReport(repoDirs.head), + baseStats: await loadBundleStats('base'), + headStats: await loadBundleStats('head'), + visualizerArtifactUrl: 'https://example.invalid/treemap', + }, + browser: { + base: await loadBrowserReport('base'), + head: await loadBrowserReport('head'), + baseHeapSnapshotUrl: 'https://example.invalid/base', + headHeapSnapshotUrl: 'https://example.invalid/head', + detailedHtmlUrl, + }, + }); } /** * 出力をゴールデンファイルで固定する。 * 意図的に変更したときは `vitest -u` で更新し、__snapshots__ の差分もレビューすること。 */ -test('renders the frontend bundle report', async () => { - const markdown = renderBundleReportMarkdown( - await collectReport(repoDirs.before), - await collectReport(repoDirs.after), - await loadStats('before'), - await loadStats('after'), - { visualizerArtifactUrl: 'https://example.invalid/treemap' }, - ); +test('renders one frontend diagnostics markdown report from bundle and browser data', async () => { + const markdown = await renderReport('https://example.invalid/html'); - await expect(markdown).toMatchFileSnapshot('./__snapshots__/render-md.md'); + await expect(markdown).toMatchFileSnapshot('./__snapshots__/report.md'); }); -test('fails loudly when the built output is missing', async () => { - await expect(collectReport(join(workDir, 'nonexistent'))).rejects.toThrow(); +test('omits the browser details link when no detailed html artifact was uploaded', async () => { + const markdown = await renderReport(null); + + expect(markdown).not.toContain('View details'); }); diff --git a/packages-private/diagnostics-frontend-browser/tsconfig.json b/packages-private/diagnostics-frontend/tsconfig.json similarity index 100% rename from packages-private/diagnostics-frontend-browser/tsconfig.json rename to packages-private/diagnostics-frontend/tsconfig.json diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 53a344d0e5..02293b4690 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -131,7 +131,7 @@ importers: specifier: 4.1.10 version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) - packages-private/diagnostics-frontend-browser: + packages-private/diagnostics-frontend: dependencies: diagnostics-shared: specifier: workspace:* @@ -156,28 +156,6 @@ importers: specifier: 4.1.10 version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) - packages-private/diagnostics-frontend-bundle: - dependencies: - diagnostics-shared: - specifier: workspace:* - version: link:../diagnostics-shared - devDependencies: - '@types/node': - specifier: 26.1.1 - version: 26.1.1 - tsx: - specifier: 4.23.1 - version: 4.23.1 - typescript: - specifier: 5.9.3 - version: 5.9.3 - vite: - specifier: 8.1.4 - version: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1) - vitest: - specifier: 4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) - packages-private/diagnostics-shared: devDependencies: '@types/node': From 41e730a763513b27aad055ca5c676bb7f0a756ee Mon Sep 17 00:00:00 2001 From: Soli Date: Tue, 21 Jul 2026 18:48:58 +0900 Subject: [PATCH 125/168] =?UTF-8?q?fix(backend):=20=E6=9C=AC=E7=95=AA?= =?UTF-8?q?=E3=83=93=E3=83=AB=E3=83=89=E3=81=A7=20@opentelemetry/instrumen?= =?UTF-8?q?tation=20=E3=81=8C=E8=A7=A3=E6=B1=BA=E3=81=A7=E3=81=8D=E3=81=AA?= =?UTF-8?q?=E3=81=84=E5=95=8F=E9=A1=8C=E3=82=92=E4=BF=AE=E6=AD=A3=20(#1775?= =?UTF-8?q?6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Soli0222 --- packages/backend/package.json | 1 + pnpm-lock.yaml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/packages/backend/package.json b/packages/backend/package.json index 26a43fb13c..ef142fb158 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -71,6 +71,7 @@ "@opentelemetry/api": "1.9.1", "@opentelemetry/core": "2.9.0", "@opentelemetry/exporter-trace-otlp-proto": "0.220.0", + "@opentelemetry/instrumentation": "0.219.0", "@opentelemetry/instrumentation-pg": "0.72.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/sdk-trace-base": "2.9.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 02293b4690..b4c5db1eae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -230,6 +230,9 @@ importers: '@opentelemetry/exporter-trace-otlp-proto': specifier: 0.220.0 version: 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': + specifier: 0.219.0 + version: 0.219.0(@opentelemetry/api@1.9.1) '@opentelemetry/instrumentation-pg': specifier: 0.72.0 version: 0.72.0(@opentelemetry/api@1.9.1) From 98ae62c432e22a006b8feea4f1e1a6841d950d53 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:43:22 +0900 Subject: [PATCH 126/168] =?UTF-8?q?enhance(gh):=20=E8=A4=87=E6=95=B0?= =?UTF-8?q?=E3=82=B5=E3=83=B3=E3=83=97=E3=83=AB=E3=81=AE=E5=80=A4=E3=81=AE?= =?UTF-8?q?=E8=A1=A8=E7=A4=BA=E3=82=92=E6=94=B9=E5=96=84=E3=81=99=E3=82=8B?= =?UTF-8?q?=E3=81=AA=E3=81=A9=20(#17759)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: design backend diagnostics memory noise handling * chore(gh): tweak workflow * docs: clarify shared heap snapshot reporting * docs: plan backend diagnostics memory noise handling * chore: ignore local worktrees * feat(diagnostics): add independent delta summary * feat(diagnostics): make heap snapshot deltas noise-aware * docs: correct heap snapshot threshold plan * fix(diagnostics): suppress backend memory noise * ci: refine backend diagnostics sampling * test(diagnostics): address final review * wip * Update heap-snapshot-sampling.ts * wip * wip * docs: design frontend metric noise reporting * docs: plan frontend metric noise reporting * fix(diagnostics): suppress frontend metric noise * docs: remove temporary frontend metric plans * test(diagnostics): cover frontend metric thresholds * docs: design shared diagnostics metric table * docs: plan shared diagnostics metric table * refactor(diagnostics): simplify independent delta statistics * feat(diagnostics): add shared metric table renderer * refactor(diagnostics): use shared heap snapshot table * refactor(diagnostics): use shared backend metric table * refactor(diagnostics): use shared frontend metric table * docs: remove temporary metric table plans * tweak * docs: design metric table no-data output * fix(diagnostics): show marker for empty metric tables * chore: remove temporary diagnostics design --- .../workflows/backend-diagnostics.inspect.yml | 3 +- .../diagnostics-backend/src/compare.ts | 58 ++--- .../src/heap-snapshot-sampling.ts | 37 ++++ .../src/report/markdown.ts | 140 ++++++------ .../diagnostics-backend/src/types.ts | 1 + .../test/__snapshots__/render-md.md | 36 +-- .../test/heap-snapshot-sampling.test.ts | 42 ++++ .../test/render-md.test.ts | 107 +++++++++ .../diagnostics-frontend/src/report.ts | 206 +++++++++++------- .../test/__snapshots__/report.md | 38 ++-- .../diagnostics-frontend/test/report.test.ts | 191 +++++++++++++++- .../diagnostics-shared/package.json | 1 + .../src/heap-snapshot/render.ts | 63 ++---- .../diagnostics-shared/src/metric-table.ts | 80 +++++++ .../diagnostics-shared/src/stats.ts | 43 ++++ .../test/heap-snapshot-render.test.ts | 103 +++++++++ .../test/metric-table.test.ts | 145 ++++++++++++ .../diagnostics-shared/test/stats.test.ts | 87 +++++++- 18 files changed, 1108 insertions(+), 273 deletions(-) create mode 100644 packages-private/diagnostics-backend/src/heap-snapshot-sampling.ts create mode 100644 packages-private/diagnostics-backend/test/heap-snapshot-sampling.test.ts create mode 100644 packages-private/diagnostics-shared/src/metric-table.ts create mode 100644 packages-private/diagnostics-shared/test/heap-snapshot-render.test.ts create mode 100644 packages-private/diagnostics-shared/test/metric-table.test.ts diff --git a/.github/workflows/backend-diagnostics.inspect.yml b/.github/workflows/backend-diagnostics.inspect.yml index 74469ded63..8040b13307 100644 --- a/.github/workflows/backend-diagnostics.inspect.yml +++ b/.github/workflows/backend-diagnostics.inspect.yml @@ -89,9 +89,10 @@ jobs: - name: Measure backend memory usage working-directory: head env: - MK_MEMORY_COMPARE_ROUNDS: 10 + MK_MEMORY_COMPARE_ROUNDS: 15 MK_MEMORY_COMPARE_WARMUP_ROUNDS: 1 MK_MEMORY_HEAP_SNAPSHOT: 1 + MK_MEMORY_HEAP_SNAPSHOT_ROUNDS: 3 # 計測ハーネスはhead側のものを両方に使うが、成果物はrunnerのworkspace直下に出す MK_MEMORY_HEAP_SNAPSHOT_OUTPUT_DIR: ${{ github.workspace }} run: >- diff --git a/packages-private/diagnostics-backend/src/compare.ts b/packages-private/diagnostics-backend/src/compare.ts index 8847b44d4a..2ae1694f82 100644 --- a/packages-private/diagnostics-backend/src/compare.ts +++ b/packages-private/diagnostics-backend/src/compare.ts @@ -6,10 +6,15 @@ import { copyFile, rm, writeFile } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import { execa } from 'execa'; -import { readIntegerEnv, readOptionalEnv } from 'diagnostics-shared/env'; +import { readBooleanEnv, readIntegerEnv, readOptionalEnv } from 'diagnostics-shared/env'; import { median } from 'diagnostics-shared/stats'; import { summarizeHeapSnapshotDataSamples, defaultHeapSnapshotBreakdownTopN } from 'diagnostics-shared/heap-snapshot'; import { resetState } from './db'; +import { + clampHeapSnapshotRounds, + selectRepresentativeHeapSnapshotRound, + shouldCollectHeapSnapshot, +} from './heap-snapshot-sampling'; import { measureBackendMemory } from './measure'; import { memoryPhases, type MemoryReport } from './types'; @@ -67,7 +72,12 @@ export function summarizeSamples(samples: MemoryReport['samples']) { return summary; } -async function genSample(label: string, repoDir: string, round: number, options: { heapSnapshotSavePath?: string } = {}) { +type GenSampleOptions = { + collectHeapSnapshot?: boolean; + heapSnapshotSavePath?: string; +}; + +async function genSample(label: string, repoDir: string, round: number, options: GenSampleOptions = {}) { process.stderr.write(`[${label}] Resetting database and Redis\n`); await resetState(); @@ -82,7 +92,7 @@ async function genSample(label: string, repoDir: string, round: number, options: process.stderr.write(`[${label}] Measuring memory\n`); return await measureBackendMemory(resolve(repoDir, 'packages/backend'), { // warmupラウンド (round <= 0) は捨てるので、重いheap snapshotは取らない - ...(round <= 0 ? { heapSnapshot: false } : {}), + ...(round <= 0 || options.collectHeapSnapshot === false ? { heapSnapshot: false } : {}), heapSnapshotSavePath: options.heapSnapshotSavePath ?? null, }); } @@ -91,32 +101,12 @@ function heapSnapshotPath(label: HeapSnapshotLabel, round: number) { return join(HEAP_SNAPSHOT_WORK_DIRS[label], `round-${round}.heapsnapshot`); } -/** - * 中央値に最も近いラウンドを代表として選ぶ。外れ値のスナップショットを成果物にしないため。 - */ -function selectRepresentativeHeapSnapshotRound(samples: MemoryReport['samples'], summary: MemoryReport['summary']) { - const medianTotal = summary.afterGc.heapSnapshot?.categories.total; - if (medianTotal == null || !Number.isFinite(medianTotal)) return null; - - let selected: { round: number; distance: number } | null = null; - for (const sample of samples) { - const total = sample.phases.afterGc.heapSnapshot?.categories.total; - if (total == null || !Number.isFinite(total)) continue; - - const distance = Math.abs(total - medianTotal); - if (selected == null || distance < selected.distance || (distance === selected.distance && sample.round < selected.round)) { - selected = { - round: sample.round, - distance, - }; - } - } - - return selected?.round ?? null; -} - async function saveRepresentativeHeapSnapshot(label: HeapSnapshotLabel, samples: MemoryReport['samples'], summary: MemoryReport['summary']) { - const round = selectRepresentativeHeapSnapshotRound(samples, summary); + const round = selectRepresentativeHeapSnapshotRound( + samples, + summary.afterGc.heapSnapshot?.categories.total, + sample => sample.phases.afterGc.heapSnapshot?.categories.total, + ); if (round == null) return; await copyFile(heapSnapshotPath(label, round), HEAP_SNAPSHOT_OUTPUT_PATHS[label]); @@ -131,6 +121,11 @@ async function saveRepresentativeHeapSnapshot(label: HeapSnapshotLabel, samples: export async function compareBackendMemory(options: CompareOptions) { const rounds = readIntegerEnv('MK_MEMORY_COMPARE_ROUNDS', 5, 1); const warmupRounds = readIntegerEnv('MK_MEMORY_COMPARE_WARMUP_ROUNDS', 1, 0); + const heapSnapshotsEnabled = readBooleanEnv('MK_MEMORY_HEAP_SNAPSHOT', false); + const requestedHeapSnapshotRounds = heapSnapshotsEnabled + ? readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_ROUNDS', rounds, 1) + : 0; + const heapSnapshotRounds = clampHeapSnapshotRounds(rounds, requestedHeapSnapshotRounds); const startedAt = new Date().toISOString(); for (const label of heapSnapshotLabels) { @@ -161,7 +156,11 @@ export async function compareBackendMemory(options: CompareOptions) { process.stderr.write(`Starting measurement round ${round}/${rounds}: ${order.join(' -> ')}\n`); for (const label of order) { - const sample = await genSample(label, reports[label].dir, round, { heapSnapshotSavePath: heapSnapshotPath(label, round) }); + const collectHeapSnapshot = shouldCollectHeapSnapshot(round, rounds, heapSnapshotRounds); + const sample = await genSample(label, reports[label].dir, round, { + collectHeapSnapshot, + ...(collectHeapSnapshot ? { heapSnapshotSavePath: heapSnapshotPath(label, round) } : {}), + }); reports[label].samples.push({ ...sample, round, @@ -186,6 +185,7 @@ export async function compareBackendMemory(options: CompareOptions) { strategy: 'interleaved-pairs', rounds, warmupRounds, + heapSnapshotRounds, startedAt, }, summary: summaries[label], diff --git a/packages-private/diagnostics-backend/src/heap-snapshot-sampling.ts b/packages-private/diagnostics-backend/src/heap-snapshot-sampling.ts new file mode 100644 index 0000000000..791840f9bd --- /dev/null +++ b/packages-private/diagnostics-backend/src/heap-snapshot-sampling.ts @@ -0,0 +1,37 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export function clampHeapSnapshotRounds(totalRounds: number, requestedRounds: number) { + return Math.min(totalRounds, requestedRounds); +} + +export function shouldCollectHeapSnapshot(round: number, totalRounds: number, requestedRounds: number) { + const snapshotRounds = clampHeapSnapshotRounds(totalRounds, requestedRounds); + return round > totalRounds - snapshotRounds; +} + +/** + * 中央値に最も近いラウンドを代表として選ぶ。外れ値のスナップショットを成果物にしないため。 + */ +export function selectRepresentativeHeapSnapshotRound( + samples: T[], + medianTotal: number | null | undefined, + getTotal: (sample: T) => number | null | undefined, +) { + if (medianTotal == null || !Number.isFinite(medianTotal)) return null; + + let selected: { round: number; distance: number } | null = null; + for (const sample of samples) { + const total = getTotal(sample); + if (total == null || !Number.isFinite(total)) continue; + + const distance = Math.abs(total - medianTotal); + if (selected == null || distance < selected.distance || (distance === selected.distance && sample.round < selected.round)) { + selected = { round: sample.round, distance }; + } + } + + return selected?.round ?? null; +} diff --git a/packages-private/diagnostics-backend/src/report/markdown.ts b/packages-private/diagnostics-backend/src/report/markdown.ts index bd4655dbe2..0fe16bf62f 100644 --- a/packages-private/diagnostics-backend/src/report/markdown.ts +++ b/packages-private/diagnostics-backend/src/report/markdown.ts @@ -3,9 +3,14 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { formatColoredDelta, formatDeltaPercentInMdTable, formatKiBAsMb } from 'diagnostics-shared/format'; -import { median, pairedDeltaSummary, sampleSpread } from 'diagnostics-shared/stats'; -import { renderHeapSnapshotTable } from 'diagnostics-shared/heap-snapshot'; +import { formatKiBAsMb } from 'diagnostics-shared/format'; +import { renderHeapSnapshotTable, type HeapSnapshotReport } from 'diagnostics-shared/heap-snapshot'; +import { renderMetricComparisonTable } from 'diagnostics-shared/metric-table'; +import { + independentDeltaSummary, + isOutsideObservedNoise, + type IndependentDeltaSummary, +} from 'diagnostics-shared/stats'; import type { MemoryPhase, MemoryReport } from '../types'; export type RenderMemoryReportOptions = { @@ -29,6 +34,8 @@ const memoryMetrics = [ type MemoryMetric = typeof memoryMetrics[number]; +const memoryColorThresholdKiB = 100; + function formatMemoryMetricName(metric: MemoryMetric) { return metric === 'Pss' ? 'PSS' : metric; } @@ -40,64 +47,51 @@ function getMemoryValueFromSample(sample: MemoryReport['samples'][number], phase return memoryUsage.Private_Clean + memoryUsage.Private_Dirty; } -function getSampleValues(report: MemoryReport, phase: MemoryPhase, metric: MemoryMetric) { - return report.samples.map(sample => getMemoryValueFromSample(sample, phase, metric)); +function summarizeMemoryMetric(base: MemoryReport, head: MemoryReport, phase: MemoryPhase, metric: MemoryMetric) { + return independentDeltaSummary( + base.samples, + head.samples, + sample => getMemoryValueFromSample(sample, phase, metric), + ); } -function getMemoryValue(report: MemoryReport, phase: MemoryPhase, metric: MemoryMetric) { - if (metric !== 'USS') return report.summary[phase].memoryUsage[metric]; - return median(getSampleValues(report, phase, metric)); -} - -function getSampleSpread(report: MemoryReport, phase: MemoryPhase, metric: MemoryMetric) { - return sampleSpread(getSampleValues(report, phase, metric)); +function getDeltaPercent(summary: IndependentDeltaSummary) { + if (summary.baseMedian === 0) return null; + return summary.delta * 100 / summary.baseMedian; } function renderMainTableForPhase(base: MemoryReport, head: MemoryReport, phase: MemoryPhase) { - const lines = [ - '| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |', - '| --- | ---: | ---: | ---: | ---: | ---: | ---: |', - ]; + return renderMetricComparisonTable( + base.samples, + head.samples, + memoryMetrics.map(metric => ({ + label: `**${formatMemoryMetricName(metric)}**`, + getValue: sample => getMemoryValueFromSample(sample, phase, metric), + formatValue: formatKiBAsMb, + absoluteThreshold: memoryColorThresholdKiB, + })), + ); +} - function formatDeltaMemory(deltaKiB: number) { - return formatColoredDelta(deltaKiB, v => formatKiBAsMb(v), 100); // 0.1 MB threshold - } +function toHeapSnapshotReport(report: MemoryReport): HeapSnapshotReport | null { + const summary = report.summary.afterGc.heapSnapshot; + if (summary == null) return null; - 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 = pairedDeltaSummary(base.samples, head.samples, (sample) => getMemoryValueFromSample(sample, phase, metric)); - const percent = summary.median * 100 / baseValue; - const deltaMedian = `${formatDeltaMemory(summary.median)}
    ${formatDeltaPercentInMdTable(percent, 0.1)}`; - - lines.push(`| **${formatMemoryMetricName(metric)}** | ${formatKiBAsMb(baseValue)}
    ± ${formatKiBAsMb(baseSpread)} | ${formatKiBAsMb(headValue)}
    ± ${formatKiBAsMb(headSpread)} | ${deltaMedian} | ${formatKiBAsMb(summary.mad)} | ${formatDeltaMemory(summary.min)} | ${formatDeltaMemory(summary.max)} |`); - } - - return lines.join('\n'); + return { + summary, + samples: report.samples.flatMap(sample => { + const data = sample.phases.afterGc.heapSnapshot; + return data == null ? [] : [{ round: sample.round, data }]; + }), + }; } 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 baseHeapSnapshotReport = toHeapSnapshotReport(base); + const headHeapSnapshotReport = toHeapSnapshotReport(head); + if (baseHeapSnapshotReport == null || headHeapSnapshotReport == null) return null; const table = renderHeapSnapshotTable(baseHeapSnapshotReport, headHeapSnapshotReport); - if (table == null) return null; const lines = [ '### V8 Heap Snapshot Statistics', @@ -119,29 +113,11 @@ function renderHeapSnapshotSection(base: MemoryReport, head: MemoryReport) { return lines.join('\n'); } -function getDiffPercent(base: MemoryReport, head: MemoryReport, phase: MemoryPhase, metric: MemoryMetric) { - const baseValue = getMemoryValue(base, phase, metric); - const headValue = getMemoryValue(head, phase, metric); - return ((headValue - baseValue) * 100) / baseValue; -} - -/** - * 増加分がサンプルのばらつきを明確に超えているかを見る。 - * 測定ノイズで警告が出続けるのを避けるため、合成ばらつきの3倍を閾値にする。 - */ -function isBeyondSampleNoise(base: MemoryReport, head: MemoryReport, phase: MemoryPhase, metric: MemoryMetric) { - 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; +function countNonConvergedMemorySamples(base: MemoryReport, head: MemoryReport) { + return [base, head] + .flatMap(report => report.samples) + .filter(sample => memoryReportPhases.some(phase => !sample.phases[phase.key].memoryStability.converged)) + .length; } export function renderMemoryReportMarkdown(base: MemoryReport, head: MemoryReport, options: RenderMemoryReportOptions) { @@ -162,6 +138,16 @@ export function renderMemoryReportMarkdown(base: MemoryReport, head: MemoryRepor lines.push(''); } + lines.push(`_Values are median ± MAD (${base.samples.length} base / ${head.samples.length} head samples). Delta is Head - Base. Deltas are highlighted when their absolute value reaches the metric threshold and exceeds 3 × MAD._`); + lines.push(''); + + const nonConvergedSamples = countNonConvergedMemorySamples(base, head); + if (nonConvergedSamples > 0) { + const noun = nonConvergedSamples === 1 ? 'sample' : 'samples'; + lines.push(`⚠️ **Measurement warning**: ${nonConvergedSamples} memory ${noun} did not converge.`); + lines.push(''); + } + const heapSnapshotSection = renderHeapSnapshotSection(base, head); if (heapSnapshotSection != null) { lines.push(heapSnapshotSection); @@ -172,8 +158,14 @@ export function renderMemoryReportMarkdown(base: MemoryReport, head: MemoryRepor lines.push(''); const warningMetric = 'Pss'; - const warningDiffPercent = getDiffPercent(base, head, 'afterGc', warningMetric); - if (warningDiffPercent > 5 && isBeyondSampleNoise(base, head, 'afterGc', warningMetric)) { + const warningSummary = summarizeMemoryMetric(base, head, 'afterGc', warningMetric); + const warningDiffPercent = getDeltaPercent(warningSummary); + if ( + warningSummary.delta > 0 && + warningDiffPercent != null && + warningDiffPercent > 5 && + isOutsideObservedNoise(warningSummary) + ) { 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(''); } diff --git a/packages-private/diagnostics-backend/src/types.ts b/packages-private/diagnostics-backend/src/types.ts index 2dff718a5c..e095572f29 100644 --- a/packages-private/diagnostics-backend/src/types.ts +++ b/packages-private/diagnostics-backend/src/types.ts @@ -37,6 +37,7 @@ export type MemoryReport = { strategy: string; rounds: number; warmupRounds: number; + heapSnapshotRounds?: number; startedAt: string; }; summary: Record ± 1 MB | 168 MB
    ± 1 MB | $\color{orange}{\text{+16 MB}}$
    $\color{orange}{\text{+10.5\\%}}$ | 0 MB | $\color{orange}{\text{+16 MB}}$ | $\color{orange}{\text{+16 MB}}$ | -| **PSS** | 202 MB
    ± 1 MB | 218 MB
    ± 1 MB | $\color{orange}{\text{+16 MB}}$
    $\color{orange}{\text{+7.9\\%}}$ | 0 MB | $\color{orange}{\text{+16 MB}}$ | $\color{orange}{\text{+16 MB}}$ | -| **USS** | 184 MB
    ± 1 MB | 200 MB
    ± 1 MB | $\color{orange}{\text{+16 MB}}$
    $\color{orange}{\text{+8.7\\%}}$ | 0 MB | $\color{orange}{\text{+16 MB}}$ | $\color{orange}{\text{+16 MB}}$ | -| **External** | 8.2 MB
    ± 0.1 MB | 8.2 MB
    ± 0.1 MB | 0 MB
    0% | 0 MB | 0 MB | 0 MB | +| Metric | @ Base | @ Head | Δ | MAD | +| --- | ---: | ---: | ---: | ---: | +| **HeapUsed** | 152 MB
    ± 1 MB | 168 MB
    ± 1 MB | $\color{orange}{\text{+16 MB}}$
    $\color{orange}{\text{+10.5\\%}}$ | 1.4 MB | +| **PSS** | 202 MB
    ± 1 MB | 218 MB
    ± 1 MB | $\color{orange}{\text{+16 MB}}$
    $\color{orange}{\text{+7.9\\%}}$ | 1.4 MB | +| **USS** | 184 MB
    ± 1 MB | 200 MB
    ± 1 MB | $\color{orange}{\text{+16 MB}}$
    $\color{orange}{\text{+8.7\\%}}$ | 1.4 MB | +| **External** | 8.2 MB
    ± 0.1 MB | 8.2 MB
    ± 0.1 MB | 0 MB
    0% | 0.1 MB | + +_Values are median ± MAD (3 base / 3 head samples). Delta is Head - Base. Deltas are highlighted when their absolute value reaches the metric threshold and exceeds 3 × MAD._ ### V8 Heap Snapshot Statistics -| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max | -| --- | ---: | ---: | ---: | ---: | ---: | ---: | -| $\color{gray}{\rule{8pt}{8pt}}$ **Total** | 40 MB
    ± 200 KB | 44 MB
    ± 200 KB | $\color{orange}{\text{+3.2 MB}}$
    $\color{orange}{\text{+7.9\\%}}$ | 0 B | $\color{orange}{\text{+3.2 MB}}$ | $\color{orange}{\text{+3.2 MB}}$ | -| | | | | | | | -|
    $\color{orange}{\rule{8pt}{8pt}}$ **Code**11.8% → 11.8%
    | 4.7 MB | 5.1 MB | $\color{orange}{\text{+376 KB}}$ | 0 B | $\color{orange}{\text{+376 KB}}$ | $\color{orange}{\text{+376 KB}}$ | -|
    $\color{red}{\rule{8pt}{8pt}}$ **Strings**11% → 11%
    | 4.4 MB | 4.8 MB | $\color{orange}{\text{+352 KB}}$ | 0 B | $\color{orange}{\text{+352 KB}}$ | $\color{orange}{\text{+352 KB}}$ | -|
    $\color{cyan}{\rule{8pt}{8pt}}$ **JS arrays**10.3% → 10.3%
    | 4.1 MB | 4.5 MB | $\color{orange}{\text{+328 KB}}$ | 0 B | $\color{orange}{\text{+328 KB}}$ | $\color{orange}{\text{+328 KB}}$ | -|
    $\color{green}{\rule{8pt}{8pt}}$ **Typed arrays**9.5% → 9.5%
    | 3.8 MB | 4.1 MB | $\color{orange}{\text{+304 KB}}$ | 0 B | $\color{orange}{\text{+304 KB}}$ | $\color{orange}{\text{+304 KB}}$ | -|
    $\color{yellow}{\rule{8pt}{8pt}}$ **System objects**8.8% → 8.8%
    | 3.5 MB | 3.8 MB | $\color{orange}{\text{+280 KB}}$ | 0 B | $\color{orange}{\text{+280 KB}}$ | $\color{orange}{\text{+280 KB}}$ | -|
    $\color{violet}{\rule{8pt}{8pt}}$ **Other JS objs**8% → 8%
    | 3.2 MB | 3.5 MB | $\color{orange}{\text{+256 KB}}$ | 0 B | $\color{orange}{\text{+256 KB}}$ | $\color{orange}{\text{+256 KB}}$ | -|
    $\color{pink}{\rule{8pt}{8pt}}$ **Other non-JS objs**7.3% → 7.3%
    | 2.9 MB | 3.2 MB | $\color{orange}{\text{+232 KB}}$ | 0 B | $\color{orange}{\text{+232 KB}}$ | $\color{orange}{\text{+232 KB}}$ | +| Metric | @ Base | @ Head | Δ | MAD | +| --- | ---: | ---: | ---: | ---: | +| $\color{gray}{\rule{8pt}{8pt}}$ **Total** | 40 MB
    ± 200 KB | 44 MB
    ± 200 KB | $\color{orange}{\text{+3.2 MB}}$
    $\color{orange}{\text{+7.9\\%}}$ | 283 KB | +| | | | | | +| $\color{orange}{\rule{8pt}{8pt}}$ **Code** | 4.7 MB | 5.1 MB | $\color{orange}{\text{+376 KB}}$ | 33 KB | +| $\color{red}{\rule{8pt}{8pt}}$ **Strings** | 4.4 MB | 4.8 MB | $\color{orange}{\text{+352 KB}}$ | 31 KB | +| $\color{cyan}{\rule{8pt}{8pt}}$ **JS arrays** | 4.1 MB | 4.5 MB | $\color{orange}{\text{+328 KB}}$ | 29 KB | +| $\color{green}{\rule{8pt}{8pt}}$ **Typed arrays** | 3.8 MB | 4.1 MB | $\color{orange}{\text{+304 KB}}$ | 27 KB | +| $\color{yellow}{\rule{8pt}{8pt}}$ **System objects** | 3.5 MB | 3.8 MB | $\color{orange}{\text{+280 KB}}$ | 25 KB | +| $\color{violet}{\rule{8pt}{8pt}}$ **Other JS objs** | 3.2 MB | 3.5 MB | $\color{orange}{\text{+256 KB}}$ | 23 KB | +| $\color{pink}{\rule{8pt}{8pt}}$ **Other non-JS objs** | 2.9 MB | 3.2 MB | $\color{orange}{\text{+232 KB}}$ | 21 KB | Download representative heap snapshot: [base](https://example.invalid/base) / [head](https://example.invalid/head) diff --git a/packages-private/diagnostics-backend/test/heap-snapshot-sampling.test.ts b/packages-private/diagnostics-backend/test/heap-snapshot-sampling.test.ts new file mode 100644 index 0000000000..8409fe166b --- /dev/null +++ b/packages-private/diagnostics-backend/test/heap-snapshot-sampling.test.ts @@ -0,0 +1,42 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, test } from 'vitest'; +import { + clampHeapSnapshotRounds, + selectRepresentativeHeapSnapshotRound, + shouldCollectHeapSnapshot, +} from '../src/heap-snapshot-sampling'; + +describe('heap snapshot sampling', () => { + test('collects only the final requested rounds', () => { + const selected = Array.from({ length: 15 }, (_, index) => index + 1) + .filter(round => shouldCollectHeapSnapshot(round, 15, 3)); + expect(selected).toStrictEqual([13, 14, 15]); + }); + + test('clamps the requested count to the measured round count', () => { + expect(clampHeapSnapshotRounds(5, 20)).toBe(5); + expect(Array.from({ length: 5 }, (_, index) => index + 1) + .filter(round => shouldCollectHeapSnapshot(round, 5, 20))) + .toStrictEqual([1, 2, 3, 4, 5]); + }); + + test('collects no snapshots when the effective count is zero', () => { + expect(Array.from({ length: 5 }, (_, index) => index + 1) + .filter(round => shouldCollectHeapSnapshot(round, 5, 0))) + .toStrictEqual([]); + }); + + test('selects the snapshot nearest the median and ignores missing rounds', () => { + const samples = [ + { round: 12, total: null }, + { round: 13, total: 100 }, + { round: 14, total: 110 }, + { round: 15, total: 130 }, + ]; + expect(selectRepresentativeHeapSnapshotRound(samples, 110, sample => sample.total)).toBe(14); + }); +}); diff --git a/packages-private/diagnostics-backend/test/render-md.test.ts b/packages-private/diagnostics-backend/test/render-md.test.ts index 468822014f..6dcf1979ca 100644 --- a/packages-private/diagnostics-backend/test/render-md.test.ts +++ b/packages-private/diagnostics-backend/test/render-md.test.ts @@ -6,6 +6,7 @@ import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { expect, test } from 'vitest'; +import { pairedDeltaSummary } from 'diagnostics-shared/stats'; import { renderMemoryReportMarkdown } from '../src/report/markdown'; import type { MemoryReport } from '../src/types'; @@ -15,6 +16,26 @@ async function loadFixture(name: string) { return JSON.parse(await readFile(join(fixturesDir, `${name}.json`), 'utf8')) as MemoryReport; } +function replacePssSamples(report: MemoryReport, values: number[]) { + const templates = structuredClone(report.samples); + report.samples = values.map((Pss, index) => { + const sample = structuredClone(templates[index % templates.length]); + sample.round = index + 1; + sample.phases.afterGc.memoryUsage.Pss = Pss; + const privateClean = sample.phases.afterGc.memoryUsage.Private_Clean; + sample.phases.afterGc.memoryUsage.Private_Dirty = Pss - privateClean; + return sample; + }); + report.sampleCount = report.samples.length; + return report; +} + +function findMetricRow(markdown: string, metric: string) { + const row = markdown.split('\n').find(line => line.startsWith(`| **${metric}**`)); + if (row === undefined) throw new Error(`expected memory report to contain a ${metric} row`); + return row; +} + /** * 出力をゴールデンファイルで固定する。 * 意図的に変更したときは `vitest -u` で更新し、__snapshots__ の差分もレビューすること。 @@ -27,3 +48,89 @@ test('renders the backend memory report', async () => { await expect(markdown).toMatchFileSnapshot('./__snapshots__/render-md.md'); }); + +test('throws when filtering leaves fewer than two heap snapshots per side', async () => { + const base = await loadFixture('base'); + const head = await loadFixture('head'); + for (const report of [base, head]) { + for (const sample of report.samples.slice(0, -1)) { + sample.phases.afterGc.heapSnapshot = null; + } + } + + expect(() => renderMemoryReportMarkdown(base, head, { + baseHeapSnapshotUrl: 'https://example.invalid/base', + headHeapSnapshotUrl: 'https://example.invalid/head', + })).toThrow('At least two samples per side are required'); +}); + +test('reports the difference of medians and leaves a paired-looking PSS delta uncoloured', async () => { + const base = replacePssSamples(await loadFixture('base'), [290_000, 292_900, 295_800, 298_700, 301_600]); + const head = replacePssSamples(await loadFixture('head'), [292_900, 296_300, 298_700, 301_600, 290_000]); + + expect(pairedDeltaSummary( + base.samples, + head.samples, + sample => sample.phases.afterGc.memoryUsage.Pss, + ).median).toBe(2_900); + + const markdown = renderMemoryReportMarkdown(base, head, { + baseHeapSnapshotUrl: 'https://example.invalid/base', + headHeapSnapshotUrl: 'https://example.invalid/head', + }); + const row = findMetricRow(markdown, 'PSS'); + + expect(row).toContain('295.8 MB
    ± 2.9 MB'); + expect(row).toContain('296.3 MB
    ± 3.4 MB'); + expect(row).toContain('$\\text{+0.5 MB}$'); + expect(row).toContain('4.5 MB'); + expect(row.split('|')).toHaveLength(7); + expect(row).not.toMatch(/within noise|increase|decrease|inconclusive/); + expect(row).not.toContain('\\color{orange}'); + expect(findMetricRow(markdown, 'USS')).not.toContain('\\color{orange}'); +}); + +test('keeps the convergence warning without suppressing table colour or the PSS warning', async () => { + const base = await loadFixture('base'); + const head = await loadFixture('head'); + base.samples[0].phases.afterGc.memoryStability.converged = false; + + const markdown = renderMemoryReportMarkdown(base, head, { + baseHeapSnapshotUrl: 'https://example.invalid/base', + headHeapSnapshotUrl: 'https://example.invalid/head', + }); + const memorySection = markdown.slice(0, markdown.indexOf('### V8 Heap Snapshot Statistics')); + + expect(memorySection).not.toContain('inconclusive'); + expect(findMetricRow(markdown, 'PSS')).toContain('\\color{orange}'); + expect(markdown).toContain('⚠️ **Measurement warning**: 1 memory sample did not converge.'); + expect(markdown).not.toContain('results are marked inconclusive'); + expect(markdown).toContain('⚠️ **Warning**: Memory usage (PSS)'); +}); + +test('throws for an undersampled memory comparison', async () => { + const base = await loadFixture('base'); + const head = await loadFixture('head'); + base.samples = base.samples.slice(0, 1); + head.samples = head.samples.slice(0, 1); + + expect(() => renderMemoryReportMarkdown(base, head, { + baseHeapSnapshotUrl: 'https://example.invalid/base', + headHeapSnapshotUrl: 'https://example.invalid/head', + })).toThrow('At least two samples per side are required'); +}); + +test('renders an unavailable percentage when the base median is zero', async () => { + const base = await loadFixture('base'); + const head = await loadFixture('head'); + for (const sample of base.samples) sample.phases.afterGc.memoryUsage.External = 0; + + const markdown = renderMemoryReportMarkdown(base, head, { + baseHeapSnapshotUrl: 'https://example.invalid/base', + headHeapSnapshotUrl: 'https://example.invalid/head', + }); + + expect(findMetricRow(markdown, 'External')).toContain('
    -'); + expect(markdown).toContain('| Metric | @ Base | @ Head | Δ | MAD |'); + expect(markdown).not.toContain('| Metric | @ Base | @ Head | Δ | MAD | Result |'); +}); diff --git a/packages-private/diagnostics-frontend/src/report.ts b/packages-private/diagnostics-frontend/src/report.ts index e605b0f931..ab85c625f8 100644 --- a/packages-private/diagnostics-frontend/src/report.ts +++ b/packages-private/diagnostics-frontend/src/report.ts @@ -5,11 +5,11 @@ import { formatBytes, formatColoredDelta, formatNumber } from 'diagnostics-shared/format'; import { renderHeapSnapshotTable, type HeapSnapshotReport } from 'diagnostics-shared/heap-snapshot'; -import { pairedDeltaSummary } from 'diagnostics-shared/stats'; +import { renderMetricComparisonTable } from 'diagnostics-shared/metric-table'; import { renderFrontendChunkReport } from './bundle/chunk-report'; import { collectVisualizerReport, renderVisualizerSummaryTable, type VisualizerReport } from './bundle/visualizer'; import type { CollectedBundleReport } from './bundle/manifest'; -import type { BrowserMeasurement, BrowserMeasurementSample, BrowserMetricsReport } from './browser/types'; +import type { BrowserMeasurementSample, BrowserMetricsReport } from './browser/types'; export type FrontendDiagnosticsMarkdownInput = { bundle: { @@ -29,86 +29,132 @@ export type FrontendDiagnosticsMarkdownInput = { }; }; -function renderMetricRow( - label: string, - base: BrowserMetricsReport, - head: BrowserMetricsReport, - getSummaryValue: (summary: BrowserMeasurement) => number, - getSampleValue: (sample: BrowserMeasurementSample) => number, - formatter: (value: number) => string, - significantThreshold = 0, - skipIfNotSignificant = true, -) { - const baseValue = getSummaryValue(base.summary); - const headValue = getSummaryValue(head.summary); - if (baseValue == null || headValue == null || !Number.isFinite(baseValue) || !Number.isFinite(headValue)) return null; - - const summary = pairedDeltaSummary(base.samples, head.samples, sample => getSampleValue(sample)); - // 有意な閾値に満たない場合はそもそもrowとして出力しない - if (skipIfNotSignificant && (Math.abs(summary.median) < significantThreshold)) return null; - - const deltaMedian = formatColoredDelta(summary.median, formatter, significantThreshold); - - return `| **${label}** | ${formatter(baseValue)} | ${formatter(headValue)} | ${deltaMedian} | ${formatter(summary.mad)} | ${formatColoredDelta(summary.min, formatter, significantThreshold)} | ${formatColoredDelta(summary.max, formatter, significantThreshold)} |`; -} - -function resourceTypeBytes(report: BrowserMeasurement, resourceTypes: string[]) { - return resourceTypes.reduce((sum, resourceType) => sum + (report.network.byResourceType[resourceType]?.encodedBytes ?? 0), 0); -} - function resourceTypeSampleBytes(sample: BrowserMeasurementSample, resourceTypes: string[]) { - return resourceTypeBytes(sample, resourceTypes); + return resourceTypes.reduce((sum, resourceType) => sum + (sample.network.byResourceType[resourceType]?.encodedBytes ?? 0), 0); } -function renderBrowserSummaryTable(base: BrowserMetricsReport, head: BrowserMetricsReport, all = false) { - //function getMetric(report: BrowserMeasurement, key: string) { - // return report.performance.cdpMetrics[key]; - //} - - const rows = [ - //renderMetricRow('Scenario duration', base, head, summary => summary.durationMs, sample => sample.durationMs, formatMs), - renderMetricRow('Requests', base, head, summary => summary.network.requestCount, sample => sample.network.requestCount, formatNumber, 1, !all), - //renderMetricRow('Failed requests', base, head, summary => summary.network.failedRequestCount, sample => sample.network.failedRequestCount, formatNumber), - renderMetricRow('Encoded network', base, head, summary => summary.network.totalEncodedBytes, sample => sample.network.totalEncodedBytes, formatBytes, 10000, !all), - renderMetricRow('Decoded body', base, head, summary => summary.network.totalDecodedBodyBytes, sample => sample.network.totalDecodedBodyBytes, formatBytes, 10000, !all), - renderMetricRow('Same-origin encoded', base, head, summary => summary.network.sameOriginEncodedBytes, sample => sample.network.sameOriginEncodedBytes, formatBytes, 10000, !all), - renderMetricRow('Third-party encoded', base, head, summary => summary.network.thirdPartyEncodedBytes, sample => sample.network.thirdPartyEncodedBytes, formatBytes, 10000, !all), - renderMetricRow('Script encoded', base, head, summary => resourceTypeBytes(summary, ['Script']), sample => resourceTypeSampleBytes(sample, ['Script']), formatBytes, 10000, !all), - renderMetricRow('Stylesheet encoded', base, head, summary => resourceTypeBytes(summary, ['Stylesheet']), sample => resourceTypeSampleBytes(sample, ['Stylesheet']), formatBytes, 10000, !all), - renderMetricRow('Fetch/XHR encoded', base, head, summary => resourceTypeBytes(summary, ['Fetch', 'XHR']), sample => resourceTypeSampleBytes(sample, ['Fetch', 'XHR']), formatBytes, 10000, !all), - renderMetricRow('Image encoded', base, head, summary => resourceTypeBytes(summary, ['Image']), sample => resourceTypeSampleBytes(sample, ['Image']), formatBytes, 10000, !all), - renderMetricRow('Font encoded', base, head, summary => resourceTypeBytes(summary, ['Font']), sample => resourceTypeSampleBytes(sample, ['Font']), formatBytes, 10000, !all), - //renderMetricRow('First contentful paint', base, head, summary => summary.performance.webVitals.firstContentfulPaintMs, sample => sample.performance.webVitals.firstContentfulPaintMs, formatMs), - //renderMetricRow('Load event end', base, head, summary => summary.performance.webVitals.loadEventEndMs, sample => sample.performance.webVitals.loadEventEndMs, formatMs), - //renderMetricRow('Long tasks', base, head, summary => summary.performance.webVitals.longTaskCount, sample => sample.performance.webVitals.longTaskCount, formatNumber), - //renderMetricRow('Long task duration', base, head, summary => summary.performance.webVitals.longTaskDurationMs, sample => sample.performance.webVitals.longTaskDurationMs, formatMs), - //renderMetricRow('Max long task', base, head, summary => summary.performance.webVitals.maxLongTaskDurationMs, sample => sample.performance.webVitals.maxLongTaskDurationMs, formatMs), - //renderMetricRow('JS heap used', base, head, summary => summary.performance.runtimeHeap?.usedSize ?? getMetric(summary, 'JSHeapUsedSize'), sample => sample.performance.runtimeHeap?.usedSize ?? getMetric(sample, 'JSHeapUsedSize'), formatBytes), - //renderMetricRow('JS heap total', base, head, summary => summary.performance.runtimeHeap?.totalSize ?? getMetric(summary, 'JSHeapTotalSize'), sample => sample.performance.runtimeHeap?.totalSize ?? getMetric(sample, 'JSHeapTotalSize'), formatBytes), - //renderMetricRow('V8 heap snapshot total', base, head, summary => summary.heapSnapshot.categories.total, sample => sample.heapSnapshot.categories.total, formatBytes, 10000), - //renderMetricRow('DOM elements', base, head, summary => summary.performance.webVitals.domElements, sample => sample.performance.webVitals.domElements, formatNumber), - //renderMetricRow('CDP nodes', base, head, summary => getMetric(summary, 'Nodes'), sample => getMetric(sample, 'Nodes'), formatNumber), - //renderMetricRow('JS event listeners', base, head, summary => getMetric(summary, 'JSEventListeners'), sample => getMetric(sample, 'JSEventListeners'), formatNumber), - //renderMetricRow('Layout count', base, head, summary => getMetric(summary, 'LayoutCount'), sample => getMetric(sample, 'LayoutCount'), formatNumber), - //renderMetricRow('Recalc style count', base, head, summary => getMetric(summary, 'RecalcStyleCount'), sample => getMetric(sample, 'RecalcStyleCount'), formatNumber), - //renderMetricRow('Script duration', base, head, summary => getMetric(summary, 'ScriptDuration'), sample => getMetric(sample, 'ScriptDuration'), formatSecondsAsMs), - //renderMetricRow('Task duration', base, head, summary => getMetric(summary, 'TaskDuration'), sample => getMetric(sample, 'TaskDuration'), formatSecondsAsMs), - renderMetricRow('WebSocket connections', base, head, summary => summary.network.webSocketConnectionCount, sample => sample.network.webSocketConnectionCount, formatNumber, 1, !all), - renderMetricRow('WebSocket sent', base, head, summary => summary.network.webSocketSentBytes, sample => sample.network.webSocketSentBytes, formatBytes, 10000, !all), - renderMetricRow('WebSocket received', base, head, summary => summary.network.webSocketReceivedBytes, sample => sample.network.webSocketReceivedBytes, formatBytes, 10000, !all), - renderMetricRow('Page errors', base, head, summary => summary.diagnostics.pageErrorCount, sample => sample.diagnostics.pageErrorCount, formatNumber, 1, !all), - renderMetricRow('Console log', base, head, summary => summary.diagnostics.console.log, sample => sample.diagnostics.console.log, formatNumber, 1, !all), - renderMetricRow('Console warnings', base, head, summary => summary.diagnostics.console.warning, sample => sample.diagnostics.console.warning, formatNumber, 1, !all), - renderMetricRow('Console errors', base, head, summary => summary.diagnostics.console.error, sample => sample.diagnostics.console.error, formatNumber, 1, !all), - renderMetricRow('Console info', base, head, summary => summary.diagnostics.console.info, sample => sample.diagnostics.console.info, formatNumber, 1, !all), - renderMetricRow('Page-attributed memory', base, head, summary => summary.performance.tabMemory.totalBytes, sample => sample.performance.tabMemory.totalBytes, formatBytes, 10000, !all), - ].filter(row => row != null); - - return [ - '| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |', - '| --- | ---: | ---: | ---: | ---: | ---: | ---: |', - ...rows, - ].join('\n'); +function renderBrowserSummaryTable(base: BrowserMetricsReport, head: BrowserMetricsReport) { + return renderMetricComparisonTable( + base.samples, + head.samples, + [ + { + label: '**Requests**', + getValue: sample => sample.network.requestCount, + formatValue: formatNumber, + absoluteThreshold: 1, + }, + { + label: '**Encoded network**', + getValue: sample => sample.network.totalEncodedBytes, + formatValue: formatBytes, + absoluteThreshold: 10_000, + }, + { + label: '**Decoded body**', + getValue: sample => sample.network.totalDecodedBodyBytes, + formatValue: formatBytes, + absoluteThreshold: 10_000, + }, + { + label: '**Same-origin encoded**', + getValue: sample => sample.network.sameOriginEncodedBytes, + formatValue: formatBytes, + absoluteThreshold: 10_000, + }, + { + label: '**Third-party encoded**', + getValue: sample => sample.network.thirdPartyEncodedBytes, + formatValue: formatBytes, + absoluteThreshold: 10_000, + }, + { + label: '**Script encoded**', + getValue: sample => resourceTypeSampleBytes(sample, ['Script']), + formatValue: formatBytes, + absoluteThreshold: 10_000, + }, + { + label: '**Stylesheet encoded**', + getValue: sample => resourceTypeSampleBytes(sample, ['Stylesheet']), + formatValue: formatBytes, + absoluteThreshold: 10_000, + }, + { + label: '**Fetch/XHR encoded**', + getValue: sample => resourceTypeSampleBytes(sample, ['Fetch', 'XHR']), + formatValue: formatBytes, + absoluteThreshold: 10_000, + }, + { + label: '**Image encoded**', + getValue: sample => resourceTypeSampleBytes(sample, ['Image']), + formatValue: formatBytes, + absoluteThreshold: 10_000, + }, + { + label: '**Font encoded**', + getValue: sample => resourceTypeSampleBytes(sample, ['Font']), + formatValue: formatBytes, + absoluteThreshold: 10_000, + }, + { + label: '**WebSocket connections**', + getValue: sample => sample.network.webSocketConnectionCount, + formatValue: formatNumber, + absoluteThreshold: 1, + }, + { + label: '**WebSocket sent**', + getValue: sample => sample.network.webSocketSentBytes, + formatValue: formatBytes, + absoluteThreshold: 10_000, + }, + { + label: '**WebSocket received**', + getValue: sample => sample.network.webSocketReceivedBytes, + formatValue: formatBytes, + absoluteThreshold: 10_000, + }, + { + label: '**Page errors**', + getValue: sample => sample.diagnostics.pageErrorCount, + formatValue: formatNumber, + absoluteThreshold: 1, + }, + { + label: '**Console log**', + getValue: sample => sample.diagnostics.console.log, + formatValue: formatNumber, + absoluteThreshold: 1, + }, + { + label: '**Console warnings**', + getValue: sample => sample.diagnostics.console.warning, + formatValue: formatNumber, + absoluteThreshold: 1, + }, + { + label: '**Console errors**', + getValue: sample => sample.diagnostics.console.error, + formatValue: formatNumber, + absoluteThreshold: 1, + }, + { + label: '**Console info**', + getValue: sample => sample.diagnostics.console.info, + formatValue: formatNumber, + absoluteThreshold: 1, + }, + { + label: '**Page-attributed memory**', + getValue: sample => sample.performance.tabMemory.totalBytes, + formatValue: formatBytes, + absoluteThreshold: 10_000, + }, + ], + { onlySignificantChanges: true }, + ); } function renderResourceTypeTable(base: BrowserMetricsReport, head: BrowserMetricsReport) { @@ -199,7 +245,7 @@ export function renderFrontendDiagnosticsMarkdown(input: FrontendDiagnosticsMark `Download representative heap snapshot: [base](${browser.baseHeapSnapshotUrl}) / [head](${browser.headHeapSnapshotUrl})`, '
    ', '', - '### 📦 Bundle Stats', + '## 📦 Bundle Stats', '', renderFrontendChunkReport(bundle.base, bundle.head), '', diff --git a/packages-private/diagnostics-frontend/test/__snapshots__/report.md b/packages-private/diagnostics-frontend/test/__snapshots__/report.md index 41d3aae727..144ff18305 100644 --- a/packages-private/diagnostics-frontend/test/__snapshots__/report.md +++ b/packages-private/diagnostics-frontend/test/__snapshots__/report.md @@ -1,12 +1,12 @@ ## 🖥 Frontend Diagnostics Report -| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max | -| --- | ---: | ---: | ---: | ---: | ---: | ---: | -| **Encoded network** | 1 MB | 1.1 MB | $\color{orange}{\text{+83 KB}}$ | 816 B | $\color{orange}{\text{+82 KB}}$ | $\color{orange}{\text{+84 KB}}$ | -| **Decoded body** | 3.5 MB | 3.7 MB | $\color{orange}{\text{+277 KB}}$ | 2.7 KB | $\color{orange}{\text{+274 KB}}$ | $\color{orange}{\text{+279 KB}}$ | -| **Same-origin encoded** | 1 MB | 1.1 MB | $\color{orange}{\text{+82 KB}}$ | 800 B | $\color{orange}{\text{+81 KB}}$ | $\color{orange}{\text{+82 KB}}$ | -| **Script encoded** | 918 KB | 991 KB | $\color{orange}{\text{+73 KB}}$ | 720 B | $\color{orange}{\text{+73 KB}}$ | $\color{orange}{\text{+74 KB}}$ | -| **Page-attributed memory** | 92 MB | 99 MB | $\color{orange}{\text{+7.3 MB}}$ | 72 KB | $\color{orange}{\text{+7.3 MB}}$ | $\color{orange}{\text{+7.4 MB}}$ | +| Metric | @ Base | @ Head | Δ | MAD | +| --- | ---: | ---: | ---: | ---: | +| **Encoded network** | 1 MB
    ± 10 KB | 1.1 MB
    ± 11 KB | $\color{orange}{\text{+83 KB}}$
    $\color{orange}{\text{+8\\%}}$ | 15 KB | +| **Decoded body** | 3.5 MB
    ± 34 KB | 3.7 MB
    ± 37 KB | $\color{orange}{\text{+277 KB}}$
    $\color{orange}{\text{+8\\%}}$ | 50 KB | +| **Same-origin encoded** | 1 MB
    ± 10 KB | 1.1 MB
    ± 11 KB | $\color{orange}{\text{+82 KB}}$
    $\color{orange}{\text{+8\\%}}$ | 15 KB | +| **Script encoded** | 918 KB
    ± 9 KB | 991 KB
    ± 9.7 KB | $\color{orange}{\text{+73 KB}}$
    $\color{orange}{\text{+8\\%}}$ | 13 KB | +| **Page-attributed memory** | 92 MB
    ± 900 KB | 99 MB
    ± 972 KB | $\color{orange}{\text{+7.3 MB}}$
    $\color{orange}{\text{+8\\%}}$ | 1.3 MB | Only metrics showing significant changes are displayed. @@ -67,22 +67,22 @@
    V8 heap snapshot statistics -| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max | -| --- | ---: | ---: | ---: | ---: | ---: | ---: | -| $\color{gray}{\rule{8pt}{8pt}}$ **Total** | 1 MB
    ± 10 KB | 1.1 MB
    ± 11 KB | $\text{+82 KB}$
    $\color{orange}{\text{+8\\%}}$ | 800 B | $\text{+81 KB}$ | $\text{+82 KB}$ | -| | | | | | | | -|
    $\color{orange}{\rule{8pt}{8pt}}$ **Code**200% → 200%
    | 2 MB | 2.2 MB | $\color{orange}{\text{+163 KB}}$ | 1.6 KB | $\color{orange}{\text{+162 KB}}$ | $\color{orange}{\text{+165 KB}}$ | -|
    $\color{red}{\rule{8pt}{8pt}}$ **Strings**300% → 300%
    | 3.1 MB | 3.3 MB | $\color{orange}{\text{+245 KB}}$ | 2.4 KB | $\color{orange}{\text{+242 KB}}$ | $\color{orange}{\text{+247 KB}}$ | -|
    $\color{cyan}{\rule{8pt}{8pt}}$ **JS arrays**400% → 400%
    | 4.1 MB | 4.4 MB | $\color{orange}{\text{+326 KB}}$ | 3.2 KB | $\color{orange}{\text{+323 KB}}$ | $\color{orange}{\text{+330 KB}}$ | -|
    $\color{green}{\rule{8pt}{8pt}}$ **Typed arrays**500% → 500%
    | 5.1 MB | 5.5 MB | $\color{orange}{\text{+408 KB}}$ | 4 KB | $\color{orange}{\text{+404 KB}}$ | $\color{orange}{\text{+412 KB}}$ | -|
    $\color{yellow}{\rule{8pt}{8pt}}$ **System objects**600% → 600%
    | 6.1 MB | 6.6 MB | $\color{orange}{\text{+490 KB}}$ | 4.8 KB | $\color{orange}{\text{+485 KB}}$ | $\color{orange}{\text{+494 KB}}$ | -|
    $\color{violet}{\rule{8pt}{8pt}}$ **Other JS objs**700% → 700%
    | 7.1 MB | 7.7 MB | $\color{orange}{\text{+571 KB}}$ | 5.6 KB | $\color{orange}{\text{+566 KB}}$ | $\color{orange}{\text{+577 KB}}$ | -|
    $\color{pink}{\rule{8pt}{8pt}}$ **Other non-JS objs**800% → 800%
    | 8.2 MB | 8.8 MB | $\color{orange}{\text{+653 KB}}$ | 6.4 KB | $\color{orange}{\text{+646 KB}}$ | $\color{orange}{\text{+659 KB}}$ | +| Metric | @ Base | @ Head | Δ | MAD | +| --- | ---: | ---: | ---: | ---: | +| $\color{gray}{\rule{8pt}{8pt}}$ **Total** | 1 MB
    ± 10 KB | 1.1 MB
    ± 11 KB | $\text{+82 KB}$
    $\text{+8\\%}$ | 15 KB | +| | | | | | +| $\color{orange}{\rule{8pt}{8pt}}$ **Code** | 2 MB | 2.2 MB | $\color{orange}{\text{+163 KB}}$ | 29 KB | +| $\color{red}{\rule{8pt}{8pt}}$ **Strings** | 3.1 MB | 3.3 MB | $\color{orange}{\text{+245 KB}}$ | 44 KB | +| $\color{cyan}{\rule{8pt}{8pt}}$ **JS arrays** | 4.1 MB | 4.4 MB | $\color{orange}{\text{+326 KB}}$ | 59 KB | +| $\color{green}{\rule{8pt}{8pt}}$ **Typed arrays** | 5.1 MB | 5.5 MB | $\color{orange}{\text{+408 KB}}$ | 74 KB | +| $\color{yellow}{\rule{8pt}{8pt}}$ **System objects** | 6.1 MB | 6.6 MB | $\color{orange}{\text{+490 KB}}$ | 88 KB | +| $\color{violet}{\rule{8pt}{8pt}}$ **Other JS objs** | 7.1 MB | 7.7 MB | $\color{orange}{\text{+571 KB}}$ | 103 KB | +| $\color{pink}{\rule{8pt}{8pt}}$ **Other non-JS objs** | 8.2 MB | 8.8 MB | $\color{orange}{\text{+653 KB}}$ | 118 KB | Download representative heap snapshot: [base](https://example.invalid/base) / [head](https://example.invalid/head)
    -### 📦 Bundle Stats +## 📦 Bundle Stats
    Chunk size diff (2 updated, 0 added, 0 removed) diff --git a/packages-private/diagnostics-frontend/test/report.test.ts b/packages-private/diagnostics-frontend/test/report.test.ts index c45961ccb9..463374aeec 100644 --- a/packages-private/diagnostics-frontend/test/report.test.ts +++ b/packages-private/diagnostics-frontend/test/report.test.ts @@ -9,7 +9,7 @@ import { dirname, join } from 'node:path'; import { afterAll, beforeAll, expect, test } from 'vitest'; import { collectBundleReport } from '../src/bundle/manifest'; import { renderFrontendDiagnosticsMarkdown } from '../src/report'; -import type { BrowserMetricsReport } from '../src/browser/types'; +import type { BrowserMeasurementSample, BrowserMetricsReport } from '../src/browser/types'; import type { VisualizerReport } from '../src/bundle/visualizer'; const bundleFixturesDir = join(import.meta.dirname, 'bundle/fixtures'); @@ -90,7 +90,15 @@ async function loadBrowserReport(name: 'base' | 'head') { return JSON.parse(await readFile(join(browserFixturesDir, `${name}.json`), 'utf8')) as BrowserMetricsReport; } -async function renderReport(detailedHtmlUrl: string | null) { +type BrowserReports = { + base: BrowserMetricsReport; + head: BrowserMetricsReport; +}; + +async function renderReport(detailedHtmlUrl: string | null, reports?: BrowserReports) { + const base = reports?.base ?? await loadBrowserReport('base'); + const head = reports?.head ?? await loadBrowserReport('head'); + return renderFrontendDiagnosticsMarkdown({ bundle: { base: await collectBundleReport(repoDirs.base), @@ -100,8 +108,8 @@ async function renderReport(detailedHtmlUrl: string | null) { visualizerArtifactUrl: 'https://example.invalid/treemap', }, browser: { - base: await loadBrowserReport('base'), - head: await loadBrowserReport('head'), + base, + head, baseHeapSnapshotUrl: 'https://example.invalid/base', headHeapSnapshotUrl: 'https://example.invalid/head', detailedHtmlUrl, @@ -109,6 +117,32 @@ async function renderReport(detailedHtmlUrl: string | null) { }); } +function withMetricSamples( + report: BrowserMetricsReport, + values: number[], + setValue: (sample: BrowserMeasurementSample, value: number) => void, +): BrowserMetricsReport { + const cloned = structuredClone(report); + const samples = values.map((value, index) => { + const sample = structuredClone(cloned.samples[index % cloned.samples.length]); + sample.round = index + 1; + setValue(sample, value); + return sample; + }); + + return { + ...cloned, + sampleCount: samples.length, + samples, + }; +} + +function requireMetricRow(markdown: string, label: string) { + const row = markdown.split('\n').find(line => line.startsWith(`| **${label}** |`)); + if (row == null) throw new Error(`Metric row not found: ${label}`); + return row; +} + /** * 出力をゴールデンファイルで固定する。 * 意図的に変更したときは `vitest -u` で更新し、__snapshots__ の差分もレビューすること。 @@ -117,6 +151,10 @@ test('renders one frontend diagnostics markdown report from bundle and browser d const markdown = await renderReport('https://example.invalid/html'); await expect(markdown).toMatchFileSnapshot('./__snapshots__/report.md'); + expect(markdown).toContain('| Metric | @ Base | @ Head | Δ | MAD |'); + expect(markdown).not.toContain('| Metric | @ Base | @ Head | Δ | MAD | Result |'); + expect(markdown).toContain('Requests by resource type'); + expect(markdown).toContain('## 📦 Bundle Stats'); }); test('omits the browser details link when no detailed html artifact was uploaded', async () => { @@ -124,3 +162,148 @@ test('omits the browser details link when no detailed html artifact was uploaded expect(markdown).not.toContain('View details'); }); + +test('renders the difference of independent medians instead of the paired median delta', async () => { + const base = withMetricSamples( + await loadBrowserReport('base'), + [100, 100, 100, 1_000, 1_000], + (sample, value) => { sample.network.requestCount = value; }, + ); + const head = withMetricSamples( + await loadBrowserReport('head'), + [0, 0, 200, 200, 200], + (sample, value) => { sample.network.requestCount = value; }, + ); + + const row = requireMetricRow(await renderReport(null, { base, head }), 'Requests'); + + expect(row).toContain('100
    ± 0'); + expect(row).toContain('200
    ± 0'); + expect(row).toContain('$\\color{orange}{\\text{+100}}$
    $\\color{orange}{\\text{+100\\\\%}}$'); + expect(row).toContain('| 0 |'); + expect(row.split('|')).toHaveLength(7); + expect(row).not.toMatch(/increase|decrease|within noise|inconclusive/); + expect(row).not.toContain('\\color{green}'); +}); + +test('hides a threshold-sized change that remains within observed noise', async () => { + const base = withMetricSamples( + await loadBrowserReport('base'), + [100, 110, 120], + (sample, value) => { sample.network.requestCount = value; }, + ); + const head = withMetricSamples( + await loadBrowserReport('head'), + [110, 120, 130], + (sample, value) => { sample.network.requestCount = value; }, + ); + + const markdown = await renderReport(null, { base, head }); + + expect(markdown).not.toContain('| **Requests** |'); +}); + +test('renders a directional request count delta at the absolute threshold', async () => { + const base = withMetricSamples( + await loadBrowserReport('base'), + [100, 100, 100], + (sample, value) => { sample.network.requestCount = value; }, + ); + const head = withMetricSamples( + await loadBrowserReport('head'), + [101, 101, 101], + (sample, value) => { sample.network.requestCount = value; }, + ); + + const row = requireMetricRow(await renderReport(null, { base, head }), 'Requests'); + + expect(row).toContain('$\\color{orange}{\\text{+1}}$'); +}); + +test('hides a directional byte change below the existing absolute threshold', async () => { + const base = withMetricSamples( + await loadBrowserReport('base'), + [1_000_000, 1_000_000, 1_000_000], + (sample, value) => { sample.network.totalEncodedBytes = value; }, + ); + const head = withMetricSamples( + await loadBrowserReport('head'), + [1_005_000, 1_005_000, 1_005_000], + (sample, value) => { sample.network.totalEncodedBytes = value; }, + ); + + const markdown = await renderReport(null, { base, head }); + + expect(markdown).not.toContain('| **Encoded network** |'); +}); + +test('renders a directional encoded byte delta at the absolute threshold', async () => { + const base = withMetricSamples( + await loadBrowserReport('base'), + [1_000_000, 1_000_000, 1_000_000], + (sample, value) => { sample.network.totalEncodedBytes = value; }, + ); + const head = withMetricSamples( + await loadBrowserReport('head'), + [1_010_000, 1_010_000, 1_010_000], + (sample, value) => { sample.network.totalEncodedBytes = value; }, + ); + + const row = requireMetricRow(await renderReport(null, { base, head }), 'Encoded network'); + + expect(row).toContain('$\\color{orange}{\\text{+10 KB}}$'); +}); + +test('colours absolute and relative deltas together when the row is significant', async () => { + const base = withMetricSamples( + await loadBrowserReport('base'), + [100_000_000, 100_000_000, 100_000_000], + (sample, value) => { sample.network.totalEncodedBytes = value; }, + ); + const head = withMetricSamples( + await loadBrowserReport('head'), + [100_020_000, 100_020_000, 100_020_000], + (sample, value) => { sample.network.totalEncodedBytes = value; }, + ); + + const row = requireMetricRow(await renderReport(null, { base, head }), 'Encoded network'); + + expect(row).toContain('100 MB
    ± 0 B'); + expect(row).toContain('$\\color{orange}{\\text{+20 KB}}$
    $\\color{orange}{\\text{+0\\\\%}}$'); + expect(row).toContain('| 0 B |'); + expect(row.split('|')).toHaveLength(7); +}); + +test('renders an unavailable relative delta when the base median is zero', async () => { + const base = withMetricSamples( + await loadBrowserReport('base'), + [0, 0, 0], + (sample, value) => { sample.network.requestCount = value; }, + ); + const head = withMetricSamples( + await loadBrowserReport('head'), + [2, 2, 2], + (sample, value) => { sample.network.requestCount = value; }, + ); + + const row = requireMetricRow(await renderReport(null, { base, head }), 'Requests'); + + expect(row).toContain('$\\color{orange}{\\text{+2}}$
    -'); + expect(row).not.toContain('increase'); +}); + +test('throws for a metric with fewer than two samples on one side', async () => { + const base = withMetricSamples( + await loadBrowserReport('base'), + [100], + (sample, value) => { sample.network.requestCount = value; }, + ); + const head = withMetricSamples( + await loadBrowserReport('head'), + [102, 102], + (sample, value) => { sample.network.requestCount = value; }, + ); + + await expect(renderReport(null, { base, head })) + .rejects.toThrow('At least two samples per side are required'); +}); diff --git a/packages-private/diagnostics-shared/package.json b/packages-private/diagnostics-shared/package.json index 1fd0a14682..7a0268b2df 100644 --- a/packages-private/diagnostics-shared/package.json +++ b/packages-private/diagnostics-shared/package.json @@ -7,6 +7,7 @@ "./format": "./src/format.ts", "./html": "./src/html.ts", "./stats": "./src/stats.ts", + "./metric-table": "./src/metric-table.ts", "./heap-snapshot": "./src/heap-snapshot/index.ts" }, "scripts": { diff --git a/packages-private/diagnostics-shared/src/heap-snapshot/render.ts b/packages-private/diagnostics-shared/src/heap-snapshot/render.ts index 7690c834c5..1f8631ff8b 100644 --- a/packages-private/diagnostics-shared/src/heap-snapshot/render.ts +++ b/packages-private/diagnostics-shared/src/heap-snapshot/render.ts @@ -3,13 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { - formatBytes, - formatDeltaBytes, - formatDeltaPercentInMdTable, - formatPercent, -} from '../format'; -import { mad, pairedDeltaSummary } from '../stats'; +import { formatBytes } from '../format'; +import { renderMetricComparisonTable } from '../metric-table'; import { heapSnapshotCategories, heapSnapshotCategory, @@ -24,15 +19,6 @@ function categoryValue(report: HeapSnapshotReport, category: HeapSnapshotCategor return report.summary.categories[category]; } -function categorySampleSpread(report: HeapSnapshotReport, category: HeapSnapshotCategory) { - const values = report.samples - .map(sample => sample.data.categories[category]) - .filter(value => Number.isFinite(value)); - if (values.length < 2) throw new Error(`Not enough samples for category ${category}`); - - return mad(values); -} - function swatch(category: HeapSnapshotCategory) { return `$\\color{${heapSnapshotCategory[category].color}}{\\rule{8pt}{8pt}}$ **${heapSnapshotCategory[category].label}**`; } @@ -41,38 +27,19 @@ function swatch(category: HeapSnapshotCategory) { * base / head のheap snapshotをカテゴリ別に比較するMarkdownテーブルを描画する。 */ export function renderHeapSnapshotTable(base: HeapSnapshotReport, head: HeapSnapshotReport) { - const lines = [ - '| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |', - '| --- | ---: | ---: | ---: | ---: | ---: | ---: |', - ]; - const baseTotal = categoryValue(base, 'total'); - const headTotal = categoryValue(head, 'total'); - - for (const category of heapSnapshotCategories) { - const baseValue = categoryValue(base, category); - const headValue = categoryValue(head, category); - const summary = pairedDeltaSummary(base.samples, head.samples, sample => sample.data.categories[category]); - const deltaColumns = `${formatBytes(summary.mad)} | ${formatDeltaBytes(summary.min, byteColorThreshold)} | ${formatDeltaBytes(summary.max, byteColorThreshold)}`; - - if (category === 'total') { - // Totalだけはばらつきと変化率も併記する - const percent = summary.median * 100 / baseValue; - const deltaMedian = `${formatDeltaBytes(summary.median, byteColorThreshold)}
    ${formatDeltaPercentInMdTable(percent, 0.1)}`; - const baseText = `${formatBytes(baseValue)}
    ± ${formatBytes(categorySampleSpread(base, category))}`; - const headText = `${formatBytes(headValue)}
    ± ${formatBytes(categorySampleSpread(head, category))}`; - lines.push(`| ${swatch(category)} | ${baseText} | ${headText} | ${deltaMedian} | ${deltaColumns} |`); - lines.push('| | | | | | | |'); - } else { - // 各カテゴリはTotalに占める割合の推移をdetailsで見せる - const basePercent = formatPercent((baseValue * 100) / baseTotal); - const headPercent = formatPercent((headValue * 100) / headTotal); - const metricText = `
    ${swatch(category)}${basePercent} → ${headPercent}
    `; - lines.push(`| ${metricText} | ${formatBytes(baseValue)} | ${formatBytes(headValue)} | ${formatDeltaBytes(summary.median, byteColorThreshold)} | ${deltaColumns} |`); - } - } - - if (lines.length === 2) return null; - return lines.join('\n'); + return renderMetricComparisonTable( + base.samples, + head.samples, + heapSnapshotCategories.map(category => ({ + label: swatch(category), + getValue: sample => sample.data.categories[category], + formatValue: formatBytes, + absoluteThreshold: byteColorThreshold, + showMedianMad: category === 'total', + showDeltaPercentage: category === 'total', + separatorAfter: category === 'total', + })), + ); } const sankeyChildMinRatio = 0.3; diff --git a/packages-private/diagnostics-shared/src/metric-table.ts b/packages-private/diagnostics-shared/src/metric-table.ts new file mode 100644 index 0000000000..62f57aa483 --- /dev/null +++ b/packages-private/diagnostics-shared/src/metric-table.ts @@ -0,0 +1,80 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { formatColoredDelta, formatDeltaPercentInMdTable } from './format'; +import { + independentDeltaSummary, + isOutsideObservedNoise, + type IndependentDeltaSummary, +} from './stats'; + +export type MetricComparisonRow = { + label: string; + getValue: (sample: T) => number; + formatValue: (value: number) => string; + absoluteThreshold: number; + showMedianMad?: boolean; + showDeltaPercentage?: boolean; + separatorAfter?: boolean; +}; + +export type MetricComparisonTableOptions = { + onlySignificantChanges?: boolean; +}; + +function isSignificant(summary: IndependentDeltaSummary, absoluteThreshold: number) { + return isOutsideObservedNoise(summary) && Math.abs(summary.delta) >= absoluteThreshold; +} + +function formatMedian( + value: number, + spread: number, + row: MetricComparisonRow, +) { + const formatted = row.formatValue(value); + if (row.showMedianMad === false) return formatted; + return `${formatted}
    ± ${row.formatValue(spread)}`; +} + +function formatDelta( + summary: IndependentDeltaSummary, + row: MetricComparisonRow, + significant: boolean, +) { + const colorThreshold = significant ? 0 : Number.POSITIVE_INFINITY; + const absolute = formatColoredDelta(summary.delta, row.formatValue, colorThreshold); + if (row.showDeltaPercentage === false) return absolute; + + const percentage = summary.baseMedian === 0 + ? '-' + : formatDeltaPercentInMdTable(summary.delta * 100 / summary.baseMedian, colorThreshold); + return `${absolute}
    ${percentage}`; +} + +export function renderMetricComparisonTable( + baseSamples: T[], + headSamples: T[], + rows: MetricComparisonRow[], + options: MetricComparisonTableOptions = {}, +): string { + const lines: string[] = []; + + for (const row of rows) { + const summary = independentDeltaSummary(baseSamples, headSamples, row.getValue); + const significant = isSignificant(summary, row.absoluteThreshold); + if (options.onlySignificantChanges === true && !significant) continue; + + lines.push(`| ${row.label} | ${formatMedian(summary.baseMedian, summary.baseMad, row)} | ${formatMedian(summary.headMedian, summary.headMad, row)} | ${formatDelta(summary, row, significant)} | ${row.formatValue(summary.combinedMad)} |`); + if (row.separatorAfter === true) lines.push('| | | | | |'); + } + + if (lines.length === 0) return '**(No data)**'; + + return [ + '| Metric | @ Base | @ Head | Δ | MAD |', + '| --- | ---: | ---: | ---: | ---: |', + ...lines, + ].join('\n'); +} diff --git a/packages-private/diagnostics-shared/src/stats.ts b/packages-private/diagnostics-shared/src/stats.ts index 7516242c32..20fd7b1347 100644 --- a/packages-private/diagnostics-shared/src/stats.ts +++ b/packages-private/diagnostics-shared/src/stats.ts @@ -37,6 +37,49 @@ export function sampleSpread(values: (number | null | undefined)[]) { return mad(finiteValues); } +export type IndependentDeltaSummary = { + baseMedian: number; + headMedian: number; + delta: number; + baseMad: number; + headMad: number; + combinedMad: number; + baseSamples: number; + headSamples: number; +}; + +export function independentDeltaSummary( + baseSamples: T[], + headSamples: T[], + getValue: (sample: T) => number, +): IndependentDeltaSummary { + const baseValues = baseSamples.map(getValue); + const headValues = headSamples.map(getValue); + if (baseValues.length < 2 || headValues.length < 2) { + throw new Error('At least two samples per side are required'); + } + + const baseMedian = median(baseValues); + const headMedian = median(headValues); + const baseMad = mad(baseValues); + const headMad = mad(headValues); + + return { + baseMedian, + headMedian, + delta: headMedian - baseMedian, + baseMad, + headMad, + combinedMad: Math.hypot(baseMad, headMad), + baseSamples: baseValues.length, + headSamples: headValues.length, + }; +} + +export function isOutsideObservedNoise(summary: IndependentDeltaSummary) { + return Math.abs(summary.delta) > summary.combinedMad * 3; +} + type RoundedSample = { round: number }; function indexByRound(samples: T[]) { diff --git a/packages-private/diagnostics-shared/test/heap-snapshot-render.test.ts b/packages-private/diagnostics-shared/test/heap-snapshot-render.test.ts new file mode 100644 index 0000000000..ba56dbb659 --- /dev/null +++ b/packages-private/diagnostics-shared/test/heap-snapshot-render.test.ts @@ -0,0 +1,103 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, test } from 'vitest'; +import { + createEmptyHeapSnapshotData, + renderHeapSnapshotTable, + summarizeHeapSnapshotDataSamples, + type HeapSnapshotData, + type HeapSnapshotReport, +} from '../src/heap-snapshot'; + +function snapshot(total: number): HeapSnapshotData { + const data = createEmptyHeapSnapshotData(); + data.categories.total = total; + return data; +} + +function report(totals: number[]): HeapSnapshotReport { + const samples = totals.map((total, index) => ({ + round: index + 1, + data: snapshot(total), + })); + const summary = summarizeHeapSnapshotDataSamples(samples, sample => sample.data); + if (summary == null) throw new Error('expected heap snapshot samples to produce a summary'); + + return { + summary, + samples, + }; +} + +function totalRow(markdown: string) { + const row = markdown.split('\n').find(line => line.includes('**Total**')); + if (row === undefined) throw new Error('expected heap snapshot table to contain a Total row'); + return row; +} + +describe('renderHeapSnapshotTable', () => { + test('leaves a threshold-sized delta uncoloured when it is within observed noise', () => { + const row = totalRow(renderHeapSnapshotTable( + report([1_000_000, 1_100_000, 1_200_000]), + report([1_100_000, 1_200_000, 1_300_000]), + )); + + expect(row).toContain('$\\text{+100 KB}$'); + expect(row).not.toContain('within noise'); + expect(row).not.toContain('\\color{orange}'); + }); + + test('colours both delta lines for a clear increase at or above the absolute threshold', () => { + const row = totalRow(renderHeapSnapshotTable( + report([1_000_000, 1_000_000, 1_000_000]), + report([1_200_000, 1_200_000, 1_200_000]), + )); + + expect(row).toContain('$\\color{orange}{\\text{+200 KB}}$'); + expect(row).toContain('$\\color{orange}{\\text{+20\\\\%}}$'); + expect(row).not.toContain('increase'); + }); + + test('leaves both delta lines uncoloured below the absolute threshold', () => { + const row = totalRow(renderHeapSnapshotTable( + report([1_000_000, 1_000_000, 1_000_000]), + report([1_050_000, 1_050_000, 1_050_000]), + )); + + expect(row).toContain('$\\text{+50 KB}$
    $\\text{+5\\\\%}$'); + expect(row.split('|')[4]).not.toContain('\\color{'); + }); + + test('throws when only one snapshot per side reaches the renderer', () => { + expect(() => renderHeapSnapshotTable( + report([1_000_000]), + report([1_200_000]), + )).toThrow('At least two samples per side are required'); + }); + + test('uses two-line formatting only for Total and puts a five-column separator after it', () => { + const table = renderHeapSnapshotTable( + report([1_000_000, 1_000_000, 1_000_000]), + report([1_200_000, 1_200_000, 1_200_000]), + ); + const lines = table.split('\n'); + const totalIndex = lines.findIndex(line => line.includes('**Total**')); + const categoryRow = lines.find(line => line.includes('**Code**')); + + expect(lines.slice(0, 2)).toStrictEqual([ + '| Metric | @ Base | @ Head | Δ | MAD |', + '| --- | ---: | ---: | ---: | ---: |', + ]); + expect(table).not.toContain('Result'); + expect(totalIndex).toBeGreaterThan(1); + expect(lines[totalIndex].match(/
    /g)).toHaveLength(3); + expect(lines[totalIndex + 1]).toBe('| | | | | |'); + expect(categoryRow).toBeDefined(); + expect(categoryRow).not.toContain('
    '); + expect(categoryRow).not.toContain('
    '); + expect(categoryRow).not.toContain('→'); + }); +}); diff --git a/packages-private/diagnostics-shared/test/metric-table.test.ts b/packages-private/diagnostics-shared/test/metric-table.test.ts new file mode 100644 index 0000000000..fb638acf48 --- /dev/null +++ b/packages-private/diagnostics-shared/test/metric-table.test.ts @@ -0,0 +1,145 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, test } from 'vitest'; +import { + renderMetricComparisonTable, + type MetricComparisonRow, +} from '../src/metric-table'; + +type Sample = { value: number }; + +const defaultRow: MetricComparisonRow = { + label: 'Metric', + getValue: sample => sample.value, + formatValue: value => `${value} units`, + absoluteThreshold: 10, +}; + +function samples(...values: number[]): Sample[] { + return values.map(value => ({ value })); +} + +describe('renderMetricComparisonTable', () => { + test('renders the fixed five-column layout, raw label, MAD, and percentage by default', () => { + const table = renderMetricComparisonTable( + samples(100, 100, 100), + samples(120, 120, 120), + [defaultRow], + ); + + expect(table.split('\n')).toStrictEqual([ + '| Metric | @ Base | @ Head | Δ | MAD |', + '| --- | ---: | ---: | ---: | ---: |', + '| Metric | 100 units
    ± 0 units | 120 units
    ± 0 units | $\\color{orange}{\\text{+20 units}}$
    $\\color{orange}{\\text{+20\\\\%}}$ | 0 units |', + ]); + }); + + test('can hide second lines and insert a five-column separator row', () => { + const table = renderMetricComparisonTable( + samples(100, 100, 100), + samples(120, 120, 120), + [{ + ...defaultRow, + showMedianMad: false, + showDeltaPercentage: false, + separatorAfter: true, + }], + ); + + expect(table.split('\n')).toStrictEqual([ + '| Metric | @ Base | @ Head | Δ | MAD |', + '| --- | ---: | ---: | ---: | ---: |', + '| Metric | 100 units | 120 units | $\\color{orange}{\\text{+20 units}}$ | 0 units |', + '| | | | | |', + ]); + }); + + test('leaves both delta lines uncolored below the absolute threshold and can filter the row', () => { + const base = samples(100, 100, 100); + const head = samples(109, 109, 109); + const table = renderMetricComparisonTable(base, head, [defaultRow]); + + expect(table).toContain('$\\text{+9 units}$
    $\\text{+9\\\\%}$'); + expect(table).not.toContain('\\color{'); + expect(renderMetricComparisonTable(base, head, [defaultRow], { + onlySignificantChanges: true, + })).toBe('**(No data)**'); + }); + + test('renders a no-data marker when no rows are configured', () => { + expect(renderMetricComparisonTable( + samples(100, 100, 100), + samples(120, 120, 120), + [], + )).toBe('**(No data)**'); + }); + + test('treats the absolute threshold itself as significant', () => { + const table = renderMetricComparisonTable( + samples(100, 100, 100), + samples(110, 110, 110), + [defaultRow], + { onlySignificantChanges: true }, + ); + + expect(table).toContain('$\\color{orange}{\\text{+10 units}}$'); + expect(table).toContain('$\\color{orange}{\\text{+10\\\\%}}$'); + }); + + test('requires the delta to be strictly outside three combined MADs', () => { + const inside = renderMetricComparisonTable( + samples(100, 110, 120), + samples(109, 119, 129), + [{ ...defaultRow, absoluteThreshold: 1 }], + ); + const boundary = renderMetricComparisonTable( + samples(100, 100, 100), + samples(120, 130, 140), + [defaultRow], + ); + const outside = renderMetricComparisonTable( + samples(100, 100, 100), + samples(121, 131, 141), + [defaultRow], + ); + + expect(inside).toContain('$\\text{+9 units}$'); + expect(inside).not.toContain('\\color{'); + expect(boundary).toContain('$\\text{+30 units}$'); + expect(boundary).not.toContain('\\color{'); + expect(outside).toContain('$\\color{orange}{\\text{+31 units}}$'); + expect(outside).toContain('$\\color{orange}{\\text{+31\\\\%}}$'); + }); + + test('colours a significant decrease green on both delta lines', () => { + const table = renderMetricComparisonTable( + samples(120, 120, 120), + samples(100, 100, 100), + [defaultRow], + ); + + expect(table).toContain('$\\color{green}{\\text{-20 units}}$'); + expect(table).toContain('$\\color{green}{\\text{-16.7\\\\%}}$'); + }); + + test('shows a dash for percentage when the base median is zero', () => { + const table = renderMetricComparisonTable( + samples(0, 0, 0), + samples(20, 20, 20), + [defaultRow], + ); + + expect(table).toContain('$\\color{orange}{\\text{+20 units}}$
    -'); + }); + + test('propagates the minimum sample contract', () => { + expect(() => renderMetricComparisonTable( + samples(100), + samples(120, 120), + [defaultRow], + )).toThrow('At least two samples per side are required'); + }); +}); diff --git a/packages-private/diagnostics-shared/test/stats.test.ts b/packages-private/diagnostics-shared/test/stats.test.ts index 1d57f0d67d..22da6a6531 100644 --- a/packages-private/diagnostics-shared/test/stats.test.ts +++ b/packages-private/diagnostics-shared/test/stats.test.ts @@ -4,7 +4,16 @@ */ import { describe, expect, test } from 'vitest'; -import { finiteMedian, mad, median, pairedDeltaSummary, sampleSpread } from '../src/stats'; +import { + finiteMedian, + independentDeltaSummary, + isOutsideObservedNoise, + mad, + median, + pairedDeltaSummary, + sampleSpread, + type IndependentDeltaSummary, +} from '../src/stats'; describe('median', () => { test('takes the middle value of an odd-length sample', () => { @@ -107,3 +116,79 @@ describe('pairedDeltaSummary', () => { expect(pairedDeltaSummary(warmupBase, warmupHead, sample => sample.value).samples).toBe(3); }); }); + +describe('independentDeltaSummary', () => { + const base = [290_000, 292_900, 295_800, 298_700, 301_600] + .map((value, index) => ({ round: index + 1, value })); + const head = [292_900, 296_300, 298_700, 301_600, 290_000] + .map((value, index) => ({ round: index + 1, value })); + + test('uses the difference of independent medians instead of the paired median', () => { + expect(pairedDeltaSummary(base, head, sample => sample.value).median).toBe(2_900); + + const summary = independentDeltaSummary(base, head, sample => sample.value); + expect(summary).toMatchObject({ + baseMedian: 295_800, + headMedian: 296_300, + delta: 500, + baseMad: 2_900, + headMad: 3_400, + baseSamples: 5, + headSamples: 5, + }); + expect(summary.combinedMad).toBeCloseTo(Math.hypot(2_900, 3_400)); + }); + + test('allows unequal sample counts', () => { + const summary = independentDeltaSummary( + [{ value: 10 }, { value: 20 }], + [{ value: 20 }, { value: 30 }, { value: 40 }], + sample => sample.value, + ); + + expect(summary).toStrictEqual({ + baseMedian: 15, + headMedian: 30, + delta: 15, + baseMad: 5, + headMad: 10, + combinedMad: Math.hypot(5, 10), + baseSamples: 2, + headSamples: 3, + }); + }); + + test('throws when either side has fewer than two samples', () => { + expect(() => independentDeltaSummary( + [{ value: 10 }], + [{ value: 20 }, { value: 20 }], + sample => sample.value, + )).toThrow('At least two samples per side are required'); + + expect(() => independentDeltaSummary( + [{ value: 10 }, { value: 10 }], + [{ value: 20 }], + sample => sample.value, + )).toThrow('At least two samples per side are required'); + }); + + test('treats exactly three combined MADs as noise and only larger deltas as outside noise', () => { + function summary(delta: number, combinedMad: number): IndependentDeltaSummary { + return { + baseMedian: 100, + headMedian: 100 + delta, + delta, + baseMad: 0, + headMad: combinedMad, + combinedMad, + baseSamples: 3, + headSamples: 3, + }; + } + + expect(isOutsideObservedNoise(summary(29, 10))).toBe(false); + expect(isOutsideObservedNoise(summary(30, 10))).toBe(false); + expect(isOutsideObservedNoise(summary(31, 10))).toBe(true); + expect(isOutsideObservedNoise(summary(-31, 10))).toBe(true); + }); +}); From 5e3977973f6e9b16957eba2572c3cde2e7b52ba0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=8B=E3=81=A3=E3=81=93=E3=81=8B=E3=82=8A?= <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:35:45 +0900 Subject: [PATCH 127/168] docs: update changelog (#17754) Update CHANGELOG.md --- CHANGELOG.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4787ea4efd..d667cde477 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,15 +49,16 @@ - 全ての受信HTTPリクエスト - 全ての送信HTTPリクエスト - ジョブキュー(エンキュー元のトレースを含む) +- Feat: ログ基盤の刷新(部分的に導入中) + - API内部エラーのログに構造化属性と正規化したエラー情報を付与し、認証情報を自動的に秘匿するように(従来形式の表示は維持) + - ログ全体の既定出力レベルとドメインごとの出力レベルを設定できるように + - バックエンドのログを1行JSON形式で出力できるように + - OpenTelemetryのTrace ContextをJSON形式のログへ関連付けられるように - Enhance: Sentry バックエンドの自動計装を `sentryForBackend.disabledIntegrations` で個別に無効化できるように - Enhance: センシティブメディアの判定を外部サービス ([sensitive-detector](https://github.com/misskey-dev/sensitive-detector)) に分離し、`nsfwjs` / `@tensorflow/tfjs(-node)` の同梱と NSFW 判定モデルを廃止 (#16804) - Enhance: Node.js 22.22.2以降、24.17.0以降、26.4.0以降をサポートするように - Enhance: Docker Image の Node.js を 26.4.0 に、Debian を trixie (v13) に更新 - Enhance: URLプレビューの結果を内部でキャッシュするように -- Enhance: API内部エラーのログに構造化属性と正規化したエラー情報を付与し、認証情報を自動的に秘匿するように(従来形式の表示は維持) -- Enhance: ログ全体の既定levelとlogger domainごとの出力levelを設定できるように -- Enhance: バックエンドのログを1行JSON形式で出力できるように -- Enhance: OpenTelemetryのTrace Contextを構造化ログへ関連付けられるように - Fix: `/stats` API のレスポンス型が正しくない問題を修正 - Fix: ハッシュタグに関連するデータを更新する際のエラーハンドリングを修正 - Fix: Sentry 使用環境下にて、Misskey が発行した SQL クエリが span に含まれない問題を修正 From 4c3f40a87debff1e7ea81afbd731ec553935180c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 10:12:34 +0900 Subject: [PATCH 128/168] fix(deps): update dependency typeorm to v1.1.0 [security] (#17767) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- packages/backend/package.json | 2 +- pnpm-lock.yaml | 29 +++++++++++++++++------------ pnpm-workspace.yaml | 2 ++ 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/packages/backend/package.json b/packages/backend/package.json index ef142fb158..a13ce02e8a 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -148,7 +148,7 @@ "tinycolor2": "1.6.0", "tmp": "0.2.7", "tsc-alias": "1.9.0", - "typeorm": "1.0.0", + "typeorm": "1.1.0", "ulid": "3.0.2", "vary": "1.1.2", "web-push": "3.6.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b4c5db1eae..a39081096a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -462,8 +462,8 @@ importers: specifier: 1.9.0 version: 1.9.0 typeorm: - specifier: 1.0.0 - version: 1.0.0(ioredis@5.11.1)(pg@8.22.0) + specifier: 1.1.0 + version: 1.1.0(ioredis@5.11.1)(pg@8.22.0) ulid: specifier: 3.0.2 version: 3.0.2 @@ -4566,8 +4566,8 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} - ansis@4.2.0: - resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} + ansis@4.3.1: + resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} engines: {node: '>=14'} append-field@1.0.0: @@ -5212,6 +5212,9 @@ packages: dayjs@1.11.20: resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} + dayjs@1.11.21: + resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} + debug@2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} peerDependencies: @@ -8856,8 +8859,8 @@ packages: typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} - typeorm@1.0.0: - resolution: {integrity: sha512-2mSKNqucP8vo+xQLP59xlHUcqLvG6qajxA7q7tnhJgeZjTrA6lK/Ar7LRyiAxdXhyXmGbIPsArPmcUB9Xg+M7w==} + typeorm@1.1.0: + resolution: {integrity: sha512-iX/kvsV42/htCNAQUyElGW87E4Z12neZc6YUFBTEu0BnLiREYDNT5Wfw3wRqZw9vyOEC36zUBAY6Qvywoig06Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=24.11.0} hasBin: true peerDependencies: @@ -8868,11 +8871,11 @@ packages: mongodb: ^7.0.0 mssql: ^12.0.0 mysql2: ^3.15.3 - oracledb: ^6.3.0 + oracledb: ^6.3.0 || ^7.0.0 pg: ^8.5.1 pg-native: ^3.0.0 pg-query-stream: ^4.0.0 - redis: ^5.0.0 + redis: ^5.0.0 || ^6.0.0 sql.js: ^1.4.0 ts-node: ^10.9.2 typeorm-aurora-data-api-driver: ^3.0.0 @@ -12817,7 +12820,7 @@ snapshots: ansi-styles@6.2.3: {} - ansis@4.2.0: {} + ansis@4.3.1: {} append-field@1.0.0: {} @@ -13470,6 +13473,8 @@ snapshots: dayjs@1.11.20: {} + dayjs@1.11.21: {} + debug@2.6.9: dependencies: ms: 2.0.0 @@ -17816,11 +17821,11 @@ snapshots: typedarray@0.0.6: {} - typeorm@1.0.0(ioredis@5.11.1)(pg@8.22.0): + typeorm@1.1.0(ioredis@5.11.1)(pg@8.22.0): dependencies: '@sqltools/formatter': 1.2.5 - ansis: 4.2.0 - dayjs: 1.11.20 + ansis: 4.3.1 + dayjs: 1.11.21 debug: 4.4.3 dedent: 1.7.2 reflect-metadata: 0.2.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c280df00f2..4d3caf525a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -56,6 +56,8 @@ minimumReleaseAgeExclude: - vite # そのうち消す # Renovate security update: @opentelemetry/core@2.8.0 - "@opentelemetry/core@2.8.0" + # Renovate security update: typeorm@1.1.0 + - typeorm@1.1.0 overrides: '@aiscript-dev/aiscript-languageserver': '-' 'bullmq>ioredis': 5.11.1 From 7725110e1c8fab9ba0b7011c16dc435e39e2a3ab Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Wed, 22 Jul 2026 10:15:19 +0900 Subject: [PATCH 129/168] chore(gh): tweak workflow --- .../src/report/markdown.ts | 2 +- .../test/__snapshots__/render-md.md | 4 +- .../diagnostics-frontend/src/report.ts | 214 ++++++++---------- .../test/__snapshots__/report.md | 2 +- .../diagnostics-shared/src/metric-table.ts | 9 +- .../test/metric-table.test.ts | 4 +- 6 files changed, 109 insertions(+), 126 deletions(-) diff --git a/packages-private/diagnostics-backend/src/report/markdown.ts b/packages-private/diagnostics-backend/src/report/markdown.ts index 0fe16bf62f..cf4acefae5 100644 --- a/packages-private/diagnostics-backend/src/report/markdown.ts +++ b/packages-private/diagnostics-backend/src/report/markdown.ts @@ -70,6 +70,7 @@ function renderMainTableForPhase(base: MemoryReport, head: MemoryReport, phase: formatValue: formatKiBAsMb, absoluteThreshold: memoryColorThresholdKiB, })), + { onlySignificantChanges: true }, ); } @@ -138,7 +139,6 @@ export function renderMemoryReportMarkdown(base: MemoryReport, head: MemoryRepor lines.push(''); } - lines.push(`_Values are median ± MAD (${base.samples.length} base / ${head.samples.length} head samples). Delta is Head - Base. Deltas are highlighted when their absolute value reaches the metric threshold and exceeds 3 × MAD._`); lines.push(''); const nonConvergedSamples = countNonConvergedMemorySamples(base, head); diff --git a/packages-private/diagnostics-backend/test/__snapshots__/render-md.md b/packages-private/diagnostics-backend/test/__snapshots__/render-md.md index 683e25b6a7..890eb2ba92 100644 --- a/packages-private/diagnostics-backend/test/__snapshots__/render-md.md +++ b/packages-private/diagnostics-backend/test/__snapshots__/render-md.md @@ -6,9 +6,9 @@ | **HeapUsed** | 152 MB
    ± 1 MB | 168 MB
    ± 1 MB | $\color{orange}{\text{+16 MB}}$
    $\color{orange}{\text{+10.5\\%}}$ | 1.4 MB | | **PSS** | 202 MB
    ± 1 MB | 218 MB
    ± 1 MB | $\color{orange}{\text{+16 MB}}$
    $\color{orange}{\text{+7.9\\%}}$ | 1.4 MB | | **USS** | 184 MB
    ± 1 MB | 200 MB
    ± 1 MB | $\color{orange}{\text{+16 MB}}$
    $\color{orange}{\text{+8.7\\%}}$ | 1.4 MB | -| **External** | 8.2 MB
    ± 0.1 MB | 8.2 MB
    ± 0.1 MB | 0 MB
    0% | 0.1 MB | -_Values are median ± MAD (3 base / 3 head samples). Delta is Head - Base. Deltas are highlighted when their absolute value reaches the metric threshold and exceeds 3 × MAD._ +Only metrics showing significant changes are displayed. + ### V8 Heap Snapshot Statistics diff --git a/packages-private/diagnostics-frontend/src/report.ts b/packages-private/diagnostics-frontend/src/report.ts index ab85c625f8..c61985f405 100644 --- a/packages-private/diagnostics-frontend/src/report.ts +++ b/packages-private/diagnostics-frontend/src/report.ts @@ -37,122 +37,102 @@ function renderBrowserSummaryTable(base: BrowserMetricsReport, head: BrowserMetr return renderMetricComparisonTable( base.samples, head.samples, - [ - { - label: '**Requests**', - getValue: sample => sample.network.requestCount, - formatValue: formatNumber, - absoluteThreshold: 1, - }, - { - label: '**Encoded network**', - getValue: sample => sample.network.totalEncodedBytes, - formatValue: formatBytes, - absoluteThreshold: 10_000, - }, - { - label: '**Decoded body**', - getValue: sample => sample.network.totalDecodedBodyBytes, - formatValue: formatBytes, - absoluteThreshold: 10_000, - }, - { - label: '**Same-origin encoded**', - getValue: sample => sample.network.sameOriginEncodedBytes, - formatValue: formatBytes, - absoluteThreshold: 10_000, - }, - { - label: '**Third-party encoded**', - getValue: sample => sample.network.thirdPartyEncodedBytes, - formatValue: formatBytes, - absoluteThreshold: 10_000, - }, - { - label: '**Script encoded**', - getValue: sample => resourceTypeSampleBytes(sample, ['Script']), - formatValue: formatBytes, - absoluteThreshold: 10_000, - }, - { - label: '**Stylesheet encoded**', - getValue: sample => resourceTypeSampleBytes(sample, ['Stylesheet']), - formatValue: formatBytes, - absoluteThreshold: 10_000, - }, - { - label: '**Fetch/XHR encoded**', - getValue: sample => resourceTypeSampleBytes(sample, ['Fetch', 'XHR']), - formatValue: formatBytes, - absoluteThreshold: 10_000, - }, - { - label: '**Image encoded**', - getValue: sample => resourceTypeSampleBytes(sample, ['Image']), - formatValue: formatBytes, - absoluteThreshold: 10_000, - }, - { - label: '**Font encoded**', - getValue: sample => resourceTypeSampleBytes(sample, ['Font']), - formatValue: formatBytes, - absoluteThreshold: 10_000, - }, - { - label: '**WebSocket connections**', - getValue: sample => sample.network.webSocketConnectionCount, - formatValue: formatNumber, - absoluteThreshold: 1, - }, - { - label: '**WebSocket sent**', - getValue: sample => sample.network.webSocketSentBytes, - formatValue: formatBytes, - absoluteThreshold: 10_000, - }, - { - label: '**WebSocket received**', - getValue: sample => sample.network.webSocketReceivedBytes, - formatValue: formatBytes, - absoluteThreshold: 10_000, - }, - { - label: '**Page errors**', - getValue: sample => sample.diagnostics.pageErrorCount, - formatValue: formatNumber, - absoluteThreshold: 1, - }, - { - label: '**Console log**', - getValue: sample => sample.diagnostics.console.log, - formatValue: formatNumber, - absoluteThreshold: 1, - }, - { - label: '**Console warnings**', - getValue: sample => sample.diagnostics.console.warning, - formatValue: formatNumber, - absoluteThreshold: 1, - }, - { - label: '**Console errors**', - getValue: sample => sample.diagnostics.console.error, - formatValue: formatNumber, - absoluteThreshold: 1, - }, - { - label: '**Console info**', - getValue: sample => sample.diagnostics.console.info, - formatValue: formatNumber, - absoluteThreshold: 1, - }, - { - label: '**Page-attributed memory**', - getValue: sample => sample.performance.tabMemory.totalBytes, - formatValue: formatBytes, - absoluteThreshold: 10_000, - }, - ], + [{ + label: '**Requests**', + getValue: sample => sample.network.requestCount, + formatValue: formatNumber, + absoluteThreshold: 1, + }, { + label: '**Encoded network**', + getValue: sample => sample.network.totalEncodedBytes, + formatValue: formatBytes, + absoluteThreshold: 10_000, + }, { + label: '**Decoded body**', + getValue: sample => sample.network.totalDecodedBodyBytes, + formatValue: formatBytes, + absoluteThreshold: 10_000, + }, { + label: '**Same-origin encoded**', + getValue: sample => sample.network.sameOriginEncodedBytes, + formatValue: formatBytes, + absoluteThreshold: 10_000, + }, { + label: '**Third-party encoded**', + getValue: sample => sample.network.thirdPartyEncodedBytes, + formatValue: formatBytes, + absoluteThreshold: 10_000, + }, { + label: '**Script encoded**', + getValue: sample => resourceTypeSampleBytes(sample, ['Script']), + formatValue: formatBytes, + absoluteThreshold: 10_000, + }, { + label: '**Stylesheet encoded**', + getValue: sample => resourceTypeSampleBytes(sample, ['Stylesheet']), + formatValue: formatBytes, + absoluteThreshold: 10_000, + }, { + label: '**Fetch/XHR encoded**', + getValue: sample => resourceTypeSampleBytes(sample, ['Fetch', 'XHR']), + formatValue: formatBytes, + absoluteThreshold: 10_000, + }, { + label: '**Image encoded**', + getValue: sample => resourceTypeSampleBytes(sample, ['Image']), + formatValue: formatBytes, + absoluteThreshold: 10_000, + }, { + label: '**Font encoded**', + getValue: sample => resourceTypeSampleBytes(sample, ['Font']), + formatValue: formatBytes, + absoluteThreshold: 10_000, + }, { + label: '**WebSocket connections**', + getValue: sample => sample.network.webSocketConnectionCount, + formatValue: formatNumber, + absoluteThreshold: 1, + }, { + label: '**WebSocket sent**', + getValue: sample => sample.network.webSocketSentBytes, + formatValue: formatBytes, + absoluteThreshold: 10_000, + }, { + label: '**WebSocket received**', + getValue: sample => sample.network.webSocketReceivedBytes, + formatValue: formatBytes, + absoluteThreshold: 10_000, + }, { + label: '**Page errors**', + getValue: sample => sample.diagnostics.pageErrorCount, + formatValue: formatNumber, + absoluteThreshold: 1, + }, { + label: '**Console log**', + getValue: sample => sample.diagnostics.console.log, + formatValue: formatNumber, + absoluteThreshold: 1, + }, { + label: '**Console warnings**', + getValue: sample => sample.diagnostics.console.warning, + formatValue: formatNumber, + absoluteThreshold: 1, + }, { + label: '**Console errors**', + getValue: sample => sample.diagnostics.console.error, + formatValue: formatNumber, + absoluteThreshold: 1, + }, { + label: '**Console info**', + getValue: sample => sample.diagnostics.console.info, + formatValue: formatNumber, + absoluteThreshold: 1, + }, { + label: '**Page-attributed memory**', + getValue: sample => sample.performance.tabMemory.totalBytes, + formatValue: formatBytes, + absoluteThreshold: 10_000, + }], { onlySignificantChanges: true }, ); } @@ -224,8 +204,6 @@ export function renderFrontendDiagnosticsMarkdown(input: FrontendDiagnosticsMark '', renderBrowserSummaryTable(browser.base, browser.head), '', - 'Only metrics showing significant changes are displayed.', - '', detailedHtmlUrl == null || detailedHtmlUrl === '' ? null : `[View details](${detailedHtmlUrl})`, detailedHtmlUrl == null || detailedHtmlUrl === '' ? null : '', '
    ', diff --git a/packages-private/diagnostics-frontend/test/__snapshots__/report.md b/packages-private/diagnostics-frontend/test/__snapshots__/report.md index 144ff18305..922c360807 100644 --- a/packages-private/diagnostics-frontend/test/__snapshots__/report.md +++ b/packages-private/diagnostics-frontend/test/__snapshots__/report.md @@ -8,7 +8,7 @@ | **Script encoded** | 918 KB
    ± 9 KB | 991 KB
    ± 9.7 KB | $\color{orange}{\text{+73 KB}}$
    $\color{orange}{\text{+8\\%}}$ | 13 KB | | **Page-attributed memory** | 92 MB
    ± 900 KB | 99 MB
    ± 972 KB | $\color{orange}{\text{+7.3 MB}}$
    $\color{orange}{\text{+8\\%}}$ | 1.3 MB | -Only metrics showing significant changes are displayed. +Only metrics showing significant changes are displayed. [View details](https://example.invalid/html) diff --git a/packages-private/diagnostics-shared/src/metric-table.ts b/packages-private/diagnostics-shared/src/metric-table.ts index 62f57aa483..00008fb61d 100644 --- a/packages-private/diagnostics-shared/src/metric-table.ts +++ b/packages-private/diagnostics-shared/src/metric-table.ts @@ -60,21 +60,26 @@ export function renderMetricComparisonTable( options: MetricComparisonTableOptions = {}, ): string { const lines: string[] = []; + let omitted = false; for (const row of rows) { const summary = independentDeltaSummary(baseSamples, headSamples, row.getValue); const significant = isSignificant(summary, row.absoluteThreshold); - if (options.onlySignificantChanges === true && !significant) continue; + if (options.onlySignificantChanges === true && !significant) { + omitted = true; + continue; + } lines.push(`| ${row.label} | ${formatMedian(summary.baseMedian, summary.baseMad, row)} | ${formatMedian(summary.headMedian, summary.headMad, row)} | ${formatDelta(summary, row, significant)} | ${row.formatValue(summary.combinedMad)} |`); if (row.separatorAfter === true) lines.push('| | | | | |'); } - if (lines.length === 0) return '**(No data)**'; + if (lines.length === 0) return '**(No significant changes)**'; return [ '| Metric | @ Base | @ Head | Δ | MAD |', '| --- | ---: | ---: | ---: | ---: |', ...lines, + ...(omitted ? ['', 'Only metrics showing significant changes are displayed.'] : []), ].join('\n'); } diff --git a/packages-private/diagnostics-shared/test/metric-table.test.ts b/packages-private/diagnostics-shared/test/metric-table.test.ts index fb638acf48..f1dcafb092 100644 --- a/packages-private/diagnostics-shared/test/metric-table.test.ts +++ b/packages-private/diagnostics-shared/test/metric-table.test.ts @@ -66,7 +66,7 @@ describe('renderMetricComparisonTable', () => { expect(table).not.toContain('\\color{'); expect(renderMetricComparisonTable(base, head, [defaultRow], { onlySignificantChanges: true, - })).toBe('**(No data)**'); + })).toBe('**(No significant changes)**'); }); test('renders a no-data marker when no rows are configured', () => { @@ -74,7 +74,7 @@ describe('renderMetricComparisonTable', () => { samples(100, 100, 100), samples(120, 120, 120), [], - )).toBe('**(No data)**'); + )).toBe('**(No significant changes)**'); }); test('treats the absolute threshold itself as significant', () => { From 2d2aa779ad01899b8e8a55c74799f3ab0dfda1b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=8B=E3=81=A3=E3=81=93=E3=81=8B=E3=82=8A?= <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Wed, 22 Jul 2026 10:16:15 +0900 Subject: [PATCH 130/168] =?UTF-8?q?fix(frontend):=20=E3=83=81=E3=83=A3?= =?UTF-8?q?=E3=83=BC=E3=83=88=E3=82=B3=E3=83=B3=E3=83=9D=E3=83=BC=E3=83=8D?= =?UTF-8?q?=E3=83=B3=E3=83=88=E3=81=AE=E3=83=9E=E3=82=A6=E3=83=B3=E3=83=88?= =?UTF-8?q?=E8=A7=A3=E9=99=A4=E6=99=82=E3=81=ABChart=E3=82=A4=E3=83=B3?= =?UTF-8?q?=E3=82=B9=E3=82=BF=E3=83=B3=E3=82=B9=E3=81=8C=E7=A0=B4=E6=A3=84?= =?UTF-8?q?=E3=81=95=E3=82=8C=E3=81=A6=E3=81=84=E3=81=AA=E3=81=84=E5=95=8F?= =?UTF-8?q?=E9=A1=8C=E3=82=92=E4=BF=AE=E6=AD=A3=20(#17763)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(frontend): Chartのマウント解除時にChartインスタンスが破棄されていない問題を修正 * Update Changelog * fix --- CHANGELOG.md | 1 + packages/frontend/src/components/MkChart.vue | 5 ++++- .../frontend/src/components/MkHeatmap.vue | 8 ++++++-- .../src/components/MkInstanceStats.vue | 19 ++++++++++++++++--- .../src/components/MkRetentionHeatmap.vue | 8 ++++++-- .../src/components/MkRetentionLineChart.vue | 6 +++++- .../MkVisitorDashboard.ActiveUsersChart.vue | 8 ++++++-- .../federation-job-queue.chart.chart.vue | 6 +++++- .../src/pages/admin/job-queue.chart.vue | 6 +++++- .../src/pages/admin/overview.active-users.vue | 12 ++++++++++-- .../src/pages/admin/overview.ap-requests.vue | 18 +++++++++++++++--- .../frontend/src/pages/admin/overview.pie.vue | 6 +++++- .../src/pages/admin/overview.queue.chart.vue | 6 +++++- .../src/pages/user/activity.following.vue | 12 ++++++++++-- .../src/pages/user/activity.notes.vue | 12 ++++++++++-- .../frontend/src/pages/user/activity.pv.vue | 12 ++++++++++-- 16 files changed, 119 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d667cde477..edcf197752 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ - Fix: いくつかのイベントリスナーが正しく解除されない問題を修正(メモリ使用量の改善) - Fix: 非ログイン時トップページをスクロール操作できないことがある問題を修正 - Fix: ローカルユーザーへのホスト付きメンションが本文に含まれる指名ノートの作成時、投稿フォームにて、当該ユーザーが宛先に含まれていても正しく認識されない問題を修正 +- Fix: チャートの描画終了後にリソースが解放されない問題を修正 - Fix: ドライブの「このファイルからノートを作成」やギャラリーの「ノートで共有」、誕生日ウィジェットからのノート作成において、通常投稿の下書きが表示される問題を修正 ### Server diff --git a/packages/frontend/src/components/MkChart.vue b/packages/frontend/src/components/MkChart.vue index e418e729ca..2d96cb7572 100644 --- a/packages/frontend/src/components/MkChart.vue +++ b/packages/frontend/src/components/MkChart.vue @@ -46,7 +46,7 @@ export type ChartSrc = diff --git a/packages/frontend/src/utility/paginator.ts b/packages/frontend/src/utility/paginator.ts index bd2e8ff80d..45054acfd0 100644 --- a/packages/frontend/src/utility/paginator.ts +++ b/packages/frontend/src/utility/paginator.ts @@ -17,8 +17,6 @@ export type MisskeyEntity = { id: string; createdAt: string; _shouldInsertAd_?: boolean; - _shouldAnimateIn_?: boolean; - _shouldAnimateOut_?: boolean; }; type AbsEndpointType = { @@ -109,8 +107,6 @@ export class Paginator< private canFetchDetection: 'safe' | 'limit' | null = null; private aheadQueue: T[] = []; private useShallowRef: SRef; - private itemRemovalDelay: number | false; - private removalTimers = new Map(); // 配列内の要素をどのような順序で並べるか // newest: 新しいものが先頭 (default) @@ -142,9 +138,6 @@ export class Paginator< useShallowRef?: SRef; - // アイテム削除時にアニメーションを待つ時間 (ms) - itemRemovalDelay?: number | false; - canSearch?: boolean; searchParamName?: keyof E['req']; }) { @@ -167,7 +160,6 @@ export class Paginator< this.noPaging = props.noPaging ?? false; this.offsetMode = props.offsetMode ?? false; this.canSearch = props.canSearch ?? false; - this.itemRemovalDelay = props.itemRemovalDelay ?? false; this.searchParamName = props.searchParamName ?? 'search'; this.getNewestId = this.getNewestId.bind(this); @@ -199,7 +191,6 @@ export class Paginator< } public async init(): Promise { - this.clearRemovalTimers(); this.items.value = []; this.aheadQueue = []; this.queuedAheadItemsCount.value = 0; @@ -393,8 +384,6 @@ export class Paginator< public prepend(item: T): void { if (this.items.value.some(x => x.id === item.id)) return; - item._shouldAnimateIn_ = true; - item._shouldAnimateOut_ = false; this.items.value.unshift(item); this.trim(false); if (this.useShallowRef) triggerRef(this.items); @@ -410,10 +399,6 @@ export class Paginator< public releaseQueue(): void { if (this.aheadQueue.length === 0) return; // これやらないと余計なre-renderが走る - for (const item of this.aheadQueue) { - item._shouldAnimateIn_ = false; // 一気に入るときは挿入アニメーションさせない - item._shouldAnimateOut_ = false; - } this.unshiftItems(this.aheadQueue); this.aheadQueue = []; this.queuedAheadItemsCount.value = 0; @@ -422,43 +407,13 @@ export class Paginator< public removeItem(id: string): void { // TODO: queueからも消す - if (this.itemRemovalDelay === false) { - const index = this.items.value.findIndex(x => x.id === id); - if (index !== -1) { - this.items.value.splice(index, 1); - if (this.useShallowRef) triggerRef(this.items); - } - return; - } - const index = this.items.value.findIndex(x => x.id === id); if (index !== -1) { + this.items.value.splice(index, 1); if (this.useShallowRef) triggerRef(this.items); - - const item = this.items.value[index]!; - item._shouldAnimateOut_ = true; - if (this.useShallowRef) triggerRef(this.items); - - if (this.removalTimers.has(id)) return; - - this.removalTimers.set(id, window.setTimeout(() => { - this.removalTimers.delete(id); - const currentIndex = this.items.value.findIndex(x => x.id === id); - if (currentIndex !== -1) { - this.items.value.splice(currentIndex, 1); - if (this.useShallowRef) triggerRef(this.items); - } - }, this.itemRemovalDelay + 20)); // アニメーション終了からやや余裕をもたせる } } - private clearRemovalTimers(): void { - for (const timer of this.removalTimers.values()) { - window.clearTimeout(timer); - } - this.removalTimers.clear(); - } - public updateItem(id: string, updater: (item: T) => T): void { // TODO: queueのも更新 From 521f6006fe4b68336c195a3b56215c4423c29d74 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Fri, 24 Jul 2026 10:06:56 +0900 Subject: [PATCH 154/168] add note --- packages/frontend/src/preferences/manager.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/frontend/src/preferences/manager.ts b/packages/frontend/src/preferences/manager.ts index 9a2056271f..05df4c70d9 100644 --- a/packages/frontend/src/preferences/manager.ts +++ b/packages/frontend/src/preferences/manager.ts @@ -548,6 +548,8 @@ export class PreferencesManager extends EventEmitter { public renameProfile(name: string) { this.profile.name = name; + // TODO: バックアップのキーが名前ベースであることを考えると名前=IDであるから、idも再生成する方が自然かもしれない + // (将来名前ではなくIDをキーにするようになったとした場合、名前を変えたのにバックアップのキーが変わらないことになってしまう) this.save(); } From 16ca5a72a6904183f8c8412e6ab3070d80abce24 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:26:05 +0900 Subject: [PATCH 155/168] New Crowdin updates (#17733) * New translations ja-jp.yml (Chinese Simplified) [ci skip] * New translations ja-jp.yml (English) [ci skip] * New translations ja-jp.yml (Korean) [ci skip] * New translations ja-jp.yml (Chinese Simplified) [ci skip] * New translations ja-jp.yml (Chinese Simplified) [ci skip] * New translations ja-jp.yml (Italian) [ci skip] * New translations ja-jp.yml (Italian) [ci skip] * New translations ja-jp.yml (Italian) [ci skip] * New translations ja-jp.yml (Catalan) [ci skip] * New translations ja-jp.yml (Chinese Simplified) [ci skip] * New translations ja-jp.yml (Spanish) [ci skip] --- locales/ca-ES.yml | 15 +++++++++++++++ locales/en-US.yml | 15 +++++++++++++++ locales/es-ES.yml | 15 +++++++++++++++ locales/it-IT.yml | 15 +++++++++++++++ locales/ko-KR.yml | 15 +++++++++++++++ locales/zh-CN.yml | 25 ++++++++++++++++++++----- 6 files changed, 95 insertions(+), 5 deletions(-) diff --git a/locales/ca-ES.yml b/locales/ca-ES.yml index ec49f966aa..a4e5d21cb0 100644 --- a/locales/ca-ES.yml +++ b/locales/ca-ES.yml @@ -619,6 +619,8 @@ output: "Sortida" script: "Script" disablePagesScript: "Desactivar AiScript a les pàgines " updateRemoteUser: "Actualitzar la informació de l'usuari remot" +unsetMfa: "Desactiva l'autenticació de dos factors" +unsetMfaConfirm: "Voldries desactivar l'autenticació de dos factors?" unsetUserAvatar: "Desactiva l'avatar " unsetUserAvatarConfirm: "Segur que vols desactivar l'avatar?" unsetUserBanner: "Desactiva el bàner " @@ -1417,6 +1419,8 @@ addToEmojiPalette: "Afegeix al calaix d'emojis" emojiPaletteAlreadyAddedConfirm: "Aquest emoji ja està inclòs en aquest calaix d'emojis. Vols afegir-lo de nou?" append: "Afegeix al final" prepend: "Afegeix al principi" +urlPreviewSensitiveList: "Llista d'URLs per restringir la visualització de miniatures" +urlPreviewSensitiveListDescription: "Si separeu els termes amb un espai, s'interpretarà com una condició 'AND'; si els separeu amb un salt de línia, s'interpretarà com una condició 'OR'. Si envolupeu els termes amb barres obliqües, s'interpretarà com una expressió regular. Si es troba una coincidència, la miniatura ja no es mostrarà." _imageEditing: _vars: caption: "Títol de l'arxiu" @@ -2149,6 +2153,15 @@ _sensitiveMediaDetection: setSensitiveFlagAutomaticallyDescription: "Els resultats de la detecció interna seran desats, inclòs si aquesta opció es troba desactivada." analyzeVideos: "Activar anàlisis de vídeos " analyzeVideosDescription: "Analitzar els vídeos a més de les imatges. Això incrementarà lleugerament la càrrega del servidor." + externalServiceInfo: "La detecció de mitjans sensibles s'ha delegat a un servei extern (sensitive-detector). Per utilitzar aquesta funció, cal configurar un servei sidecar separat i establir els detalls de connexió que es detallen a continuació. Si no es configuren els detalls de connexió, no es durà a terme cap detecció (el contingut es tractarà com a no sensible)." + apiUrl: "URL per connectar-se al servei de verificació" + apiUrlDescription: "L'URL base del servei de detector de sensibilitat (per exemple, http://localhost:3009). Si us connecteu a un servei en una xarxa privada, afegiu la xarxa de destinació a la configuració `allowedPrivateNetworks` del fitxer de configuració. Si utilitzeu un proxy, configureu també `proxyBypassHosts`. Si es deixa en blanc, no es realitzaran comprovacions de sensibilitat." + apiKey: "Clau de l'API" + apiKeyDescription: "Introduïu això si l'autenticació (token Bearer) està configurada al costat del servei d'autenticació. Si no està configurada, deixeu aquest camp en blanc." + timeout: "Temps d'espera (mil·lisegons)" + timeoutDescription: "1 Aquesta és la durada del temps mort per sol·licitud de validació." + maxImagesPerRequest: "1 Nombre màxim d'imatges per sol·licitud" + maxImagesPerRequestDescription: "1 Quan s'analitzen múltiples fotogrames, com en un vídeo, aquest és el nombre màxim d'imatges que es poden enviar en una única petició. Qualsevol imatge que superi aquest límit es dividirà en parts i s'enviarà seqüencialment. Assegureu-vos que aquest ajust no superi el valor de `maxParts` al costat del `sensitive-detector` (per defecte: 10). Si es supera aquest límit, totes les imatges d'aquest lot es consideraran no sensibles." _emailUnavailable: used: "Aquest correu electrònic ja s'està fent servir" format: "El format del correu electrònic és invàlid " @@ -2461,6 +2474,7 @@ _permissions: "read:admin:show-moderation-log": "Veure registre de moderació " "read:admin:show-user": "Veure informació privada de l'usuari " "write:admin:suspend-user": "Suspendre usuari" + "write:admin:unset-mfa": "Desactiva l'autenticació de dos factors per a un usuari" "write:admin:unset-user-avatar": "Esborrar avatar d'usuari " "write:admin:unset-user-banner": "Esborrar bàner de l'usuari " "write:admin:unsuspend-user": "Treure la suspensió d'un usuari" @@ -2976,6 +2990,7 @@ _moderationLogTypes: createAvatarDecoration: "Decoració de l'avatar creada" updateAvatarDecoration: "S'ha actualitzat la decoració de l'avatar " deleteAvatarDecoration: "S'ha esborrat la decoració de l'avatar " + unsetMfa: "Desactiva l'autenticació de dos factors per a l'usuari" unsetUserAvatar: "Esborrar l'avatar d'aquest usuari" unsetUserBanner: "Esborrar el bàner d'aquest usuari" createSystemWebhook: "Crear un SystemWebhook" diff --git a/locales/en-US.yml b/locales/en-US.yml index e998937dfb..813d7ccc9a 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -619,6 +619,8 @@ output: "Output" script: "Script" disablePagesScript: "Disable AiScript on Pages" updateRemoteUser: "Update remote user information" +unsetMfa: "Reset two-factor authentication" +unsetMfaConfirm: "Are you sure you want to reset two-factor authentication?" unsetUserAvatar: "Unset avatar" unsetUserAvatarConfirm: "Are you sure you want to unset the avatar?" unsetUserBanner: "Unset banner" @@ -1417,6 +1419,8 @@ addToEmojiPalette: "Add to emoji palette" emojiPaletteAlreadyAddedConfirm: "This emoji is already included in this emoji palette. Do you want to add it again?" append: "Append to end" prepend: "Append to beginning" +urlPreviewSensitiveList: "URL to restrict thumbnail display" +urlPreviewSensitiveListDescription: "Use spaces to specify AND conditions, and line breaks to specify OR conditions. Enclose text in slashes to use regular expressions. If a match is found, the thumbnail will be hidden." _imageEditing: _vars: caption: "File caption" @@ -2149,6 +2153,15 @@ _sensitiveMediaDetection: setSensitiveFlagAutomaticallyDescription: "The results of the internal detection will be retained even if this option is turned off." analyzeVideos: "Enable analysis of videos" analyzeVideosDescription: "Analyzes videos in addition to images. This will slightly increase the load on the server." + externalServiceInfo: "The detection of sensitive media has been offloaded to an external service (sensitive-detector). To use this feature, you must set up a separate service and configure the connection details provided below. If no connection details are configured, no detection will be performed (it will be treated as non-sensitive)." + apiUrl: "Detection service endpoint URL" + apiUrlDescription: "The base URL for the sensitive-detector service (e.g., http://localhost:3009). If you are connecting to a service on a private network, please allow the target network in the allowedPrivateNetworks setting in the configuration file. If you are using a proxy, please also configure proxyBypassHosts. If left blank, sensitive media detection will not be performed." + apiKey: "API key" + apiKeyDescription: "Enter this if authentication (Bearer token) is configured on the detector service. If it is not configured, please leave it blank." + timeout: "Timeout (Milliseconds)" + timeoutDescription: "Timeout duration for each judgment request." + maxImagesPerRequest: "Max images per request" + maxImagesPerRequestDescription: "Maximum number of images that can be sent in a single request when processing multi-frame at once, such as videos. Any images exceeding this limit will be split and sent sequentially. Please ensure this is set to not exceed the maxParts setting (default: 10) on the detector service. If it exceeds this limit, all items in that chunk will be treated as non-sensitive." _emailUnavailable: used: "This email address is already being used" format: "The format of this email address is invalid" @@ -2461,6 +2474,7 @@ _permissions: "read:admin:show-moderation-log": "View moderation log" "read:admin:show-user": "View private user info" "write:admin:suspend-user": "Suspend user" + "write:admin:unset-mfa": "Reset two-factor authentication for the user" "write:admin:unset-user-avatar": "Remove user avatar" "write:admin:unset-user-banner": "Remove user banner" "write:admin:unsuspend-user": "Unsuspend user" @@ -2976,6 +2990,7 @@ _moderationLogTypes: createAvatarDecoration: "Avatar decoration created" updateAvatarDecoration: "Avatar decoration updated" deleteAvatarDecoration: "Avatar decoration deleted" + unsetMfa: "Reset two-factor authentication for the user" unsetUserAvatar: "User avatar unset" unsetUserBanner: "User banner unset" createSystemWebhook: "System Webhook created" diff --git a/locales/es-ES.yml b/locales/es-ES.yml index 6d0508f989..4dee653286 100644 --- a/locales/es-ES.yml +++ b/locales/es-ES.yml @@ -619,6 +619,8 @@ output: "Salida" script: "Script" disablePagesScript: "Deshabilitar AiScript en Páginas" updateRemoteUser: "Actualizar información de usuario remoto" +unsetMfa: "Desactivar la autenticación de dos factores" +unsetMfaConfirm: "¿Desea desactivar la autenticación de dos factores?" unsetUserAvatar: "Quitar avatar" unsetUserAvatarConfirm: "¿Confirmas que quieres quitar tu avatar?" unsetUserBanner: "Quitar banner" @@ -1417,6 +1419,8 @@ addToEmojiPalette: "Añadir a la paleta de emojis" emojiPaletteAlreadyAddedConfirm: "Este emoji ya está incluido en esta paleta de emojis. ¿Quieres volver a añadirlo?" append: "Añadir al final" prepend: "Añadir al principio" +urlPreviewSensitiveList: "URL para restringir la visualización de miniaturas" +urlPreviewSensitiveListDescription: "Si se separan con un espacio, se interpretará como una condición «AND»; si se separan con un salto de línea, se interpretará como una condición «OR». Si se escriben entre barras, se interpretarán como expresiones regulares. Si se encuentra una coincidencia, no se mostrará la miniatura." _imageEditing: _vars: caption: "Título del archivo" @@ -2149,6 +2153,15 @@ _sensitiveMediaDetection: setSensitiveFlagAutomaticallyDescription: "Los resultados de la detección interna pueden ser retenidos incluso si la opción está desactivada." analyzeVideos: "Habilitar el análisis de videos" analyzeVideosDescription: "Analizar videos en adición a las imágenes. Esto puede incrementar ligeramente la carga del servidor." + externalServiceInfo: "La detección de contenidos sensibles se ha externalizado a un servicio externo (sensitive-detector). Para utilizar esta función, es necesario configurar por separado un servicio «sidecar» y establecer los datos de conexión que se indican a continuación. Si no se configuran los datos de conexión, no se realizará la detección (se tratará como contenido no sensible)." + apiUrl: "URL de conexión al servicio de verificación" + apiUrlDescription: "URL base del servicio «sensitive-detector» (por ejemplo: http://localhost:3009). Si te conectas a un servicio situado en una red privada, debes permitir la red de destino en el parámetro «allowedPrivateNetworks» del archivo de configuración. Si utilizas un proxy, configura también el parámetro «proxyBypassHosts». Si se deja en blanco, no se realizará la evaluación de datos sensibles." + apiKey: "Clave API" + apiKeyDescription: "Introduce este dato si se ha configurado la autenticación (token Bearer) en el servicio de validación. Si no se ha configurado, déjalo en blanco." + timeout: "Tiempo de espera (milisegundos)" + timeoutDescription: "Duración del tiempo de espera para cada solicitud de resolución." + maxImagesPerRequest: "Número máximo de imágenes por solicitud" + maxImagesPerRequestDescription: "Es el número máximo de imágenes que se pueden enviar en una sola solicitud al analizar varios fotogramas, como en un vídeo. Las imágenes que superen este límite se dividirán y se enviarán de forma secuencial. Configúralo de manera que no se supere el valor de «maxParts» de sensitive-detector (por defecto: 10). Si se supera este límite, todas las imágenes de ese fragmento se considerarán no sensibles." _emailUnavailable: used: "Ya fue usado" format: "Formato no válido." @@ -2461,6 +2474,7 @@ _permissions: "read:admin:show-moderation-log": "Ver log de moderación" "read:admin:show-user": "Ver información privada de usuario" "write:admin:suspend-user": "Suspender cuentas de usuario" + "write:admin:unset-mfa": "Desactivar la autenticación de dos factores del usuario" "write:admin:unset-user-avatar": "Quitar avatares de usuario" "write:admin:unset-user-banner": "Quitar banner de usuarios" "write:admin:unsuspend-user": "Quitar suspensión de cuentas de usuario" @@ -2976,6 +2990,7 @@ _moderationLogTypes: createAvatarDecoration: "Decoración de avatar creada" updateAvatarDecoration: "Decoración de avatar actualizada" deleteAvatarDecoration: "Decoración de avatar eliminada" + unsetMfa: "Desactivar la autenticación de dos factores del usuario" unsetUserAvatar: "Quitar decoración de avatar de este usuario" unsetUserBanner: "Quitar banner de este usuario" createSystemWebhook: "Crear un SystemWebhook" diff --git a/locales/it-IT.yml b/locales/it-IT.yml index a7712ae5ca..564dd458ce 100644 --- a/locales/it-IT.yml +++ b/locales/it-IT.yml @@ -619,6 +619,8 @@ output: "Output" script: "Script" disablePagesScript: "Disabilitare AiScript nelle pagine" updateRemoteUser: "Aggiorna dati dal profilo remoto" +unsetMfa: "Rimuovere l'autenticazione a due fattori (2FA/MFA)" +unsetMfaConfirm: "Vuoi davvero rimuovere l'autenticazione a due fattori?" unsetUserAvatar: "Rimozione foto profilo" unsetUserAvatarConfirm: "Vuoi davvero rimuovere la foto profilo?" unsetUserBanner: "Rimuovi intestazione profilo" @@ -1417,6 +1419,8 @@ addToEmojiPalette: "Aggiungi alla tavolozza emoji" emojiPaletteAlreadyAddedConfirm: "Questa emoji è già inclusa in nella tavolozza. Vuoi davvero aggiungerla?" append: "Accodare" prepend: "Anteporre" +urlPreviewSensitiveList: "URL da impedire alla vista delle anteprime" +urlPreviewSensitiveListDescription: "Separando con uno spazio si indica E, separando con una linea si indica O. Circondando con barre / si indica una Espressione Regolare.\nLe URL che coincidono con le indicazioni non verranno visualizzate." _imageEditing: _vars: caption: "Didascalia dell'immagine" @@ -2149,6 +2153,15 @@ _sensitiveMediaDetection: setSensitiveFlagAutomaticallyDescription: "Anche se questa impostazione è disattivata, il risultato della decisione viene conservato internamente." analyzeVideos: "Abilitazione dell'analisi video." analyzeVideosDescription: "Assicuratevi che vengano analizzati anche i video oltre alle immagini fisse. Il carico del server aumenterà leggermente." + externalServiceInfo: "Abbiamo spostato esternamente il riconoscimento di media espliciti (sensitive-detector). Per usufruirne devi impostare un servizio separato e indicare di seguito la destinazione. Se non è impostata, non viene emesso alcun giudizio (contenuto NON esplicito)." + apiUrl: "URL di connessione a Sensitive-Detector" + apiUrlDescription: "L'URL di base del servizio (ad esempio http://localhost:3009). Collegandosi a una rete privata, autorizzare la rete nel parametro allowedPrivateNetworks nel file di configurazione.\nCollegandosi con un proxy, è anche necessario impostare proxyBypassHosts. Nel caso il campo sia vuoto, non vengono emessi giudizi di sensibilità." + apiKey: "Chiave API" + apiKeyDescription: "Indicare il token di autenticazione (Bearer token), soltanto se occorre, altrimenti lasciare vuoto." + timeout: "Timeout (ms)" + timeoutDescription: "Tempo limite per ogni richiesta" + maxImagesPerRequest: "Numero massimo di media per ogni richiesta" + maxImagesPerRequestDescription: "In caso ci siano più fotogrammi, come nei video, questo è il numero massimo di immagini da inviare in una richiesta. Oltre questo numero, il totale sarà diviso e inviato in sequenza.\nImpostare in modo che non superi il valore maxParts (default: 10) in Sensitive-Detector. Se viene superato, il media sarà considerato non esplicito." _emailUnavailable: used: "Email già in uso" format: "Formato email non valido" @@ -2461,6 +2474,7 @@ _permissions: "read:admin:show-moderation-log": "Vedere lo storico di moderazione" "read:admin:show-user": "Vedere le informazioni private dei profili" "write:admin:suspend-user": "Sospendere i profili" + "write:admin:unset-mfa": "Può rimuovere l'autenticazione a due fattori (2FA/MFA)" "write:admin:unset-user-avatar": "Rimuovere la foto profilo dai profili" "write:admin:unset-user-banner": "Rimuovere l'immagine testata dai profili" "write:admin:unsuspend-user": "Rimuovere la sospensione ai profili" @@ -2976,6 +2990,7 @@ _moderationLogTypes: createAvatarDecoration: "Crea una decorazione della foto profilo" updateAvatarDecoration: "Modifica una decorazione della foto profilo" deleteAvatarDecoration: "Elimina una decorazione della foto profilo" + unsetMfa: "Rimossa l'autenticazione a due fattori (2FA7MFA)" unsetUserAvatar: "Toglie una foto profilo" unsetUserBanner: "Toglie una immagine di intestazione profilo" createSystemWebhook: "Aggiunge un System Webhook" diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index 06c7b27c1e..6cb6d46a89 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -619,6 +619,8 @@ output: "출력" script: "스크립트" disablePagesScript: "Pages 에서 AiScript 를 사용하지 않음" updateRemoteUser: "리모트 유저 정보 갱신" +unsetMfa: "2단계 인증 해제" +unsetMfaConfirm: "2단계 인증을 해제하시겠습니까?" unsetUserAvatar: "아바타 제거" unsetUserAvatarConfirm: "아바타를 제거할까요?" unsetUserBanner: "배너 제거" @@ -1417,6 +1419,8 @@ addToEmojiPalette: "이모지 팔레트에 추가" emojiPaletteAlreadyAddedConfirm: "이 이모지는 이미 이 이모지 팔레트에 포함돼있습니다. 다시 추가하시겠습니까?" append: "맨뒤에 추가" prepend: "맨앞에 추가" +urlPreviewSensitiveList: "썸네일 표시 제한 URL" +urlPreviewSensitiveListDescription: "공백으로 구분하면 AND 지정으로 되고, 줄내림으로 구분하면 OR 지정으로 됩니다. 슬래시로 감싸면 정규 표현으로 됩니다. 일치한 경우에 썸네일이 표시되지 않게 됩니다." _imageEditing: _vars: caption: "파일 설명" @@ -2149,6 +2153,15 @@ _sensitiveMediaDetection: setSensitiveFlagAutomaticallyDescription: "이 설정을 해제해도 탐지 결과는 유지됩니다." analyzeVideos: "동영상도 같이 확인하기" analyzeVideosDescription: "사진 뿐만 아니라 동영상의 NSFW 여부도 탐지합니다. 서버의 부하를 약간 증가시킵니다." + externalServiceInfo: "민감한 미디어 판정은 외부 서비스(sensitive-detector)로 분리됐습니다. 이 기능을 이용하려면 별도 사이드카 서비스를 설정하고, 아래의 접속 위치를 설정해야 합니다. 접속 위치가 설정되지 않은 경우에는 판정이 이루어지지 않습니다. (민감하지 않음 처리)" + apiUrl: "판정 서비스의 접속 위치 URL" + apiUrlDescription: "sensitive-detector 서비스의 베이스 URL(예시: http://localhost:3009). 프라이빗 네트워크상의 서비스에 접속하는 경우에는 설정 파일의 allowedPrivateNetworks로 접속 위치 네트워크를 허가해 주십시오. 프록시를 사용하고 있는 경우에는 proxyBypassHosts도 설정해 주십시오. 비어있으면 민감함 판정은 이루어지지 않습니다." + apiKey: "API 키" + apiKeyDescription: "판정 서비스 측에서 인증(Bearer 토큰)을 설정하고 있는 경우에 입력합니다. 설정하고 있지 않은 경우에는 빈칸으로 둬주십시오." + timeout: "타임아웃 (밀리초)" + timeoutDescription: "판정 요청 1회당 타임아웃 시간입니다." + maxImagesPerRequest: "한 요청당 최대 이미지 수" + maxImagesPerRequestDescription: "동영상 등 여러 프레임을 판정할 때, 한 번의 요청에 모아서 보내는 이미지의 최대 장수입니다. 이를 넘으면 분할해 순차적으로 송신됩니다. sensitive-detector 측의 maxParts 설정(기본: 10)을 남지 않도록 설정해 주십시오. 넘은 경우에는 그 청크는 전부 민감하지 않음 처리로 됩니다." _emailUnavailable: used: "이 메일 주소는 사용중입니다" format: "형식이 올바르지 않습니다" @@ -2461,6 +2474,7 @@ _permissions: "read:admin:show-moderation-log": "조정 기록 보기" "read:admin:show-user": "유저 개인정보 보기" "write:admin:suspend-user": "유저 정지하기" + "write:admin:unset-mfa": "사용자의 2단계 인증 해제" "write:admin:unset-user-avatar": "유저 아바타 삭제하기" "write:admin:unset-user-banner": "유저 배너 삭제하기" "write:admin:unsuspend-user": "유저 정지 해제하기" @@ -2976,6 +2990,7 @@ _moderationLogTypes: createAvatarDecoration: "아바타 장식 만들기" updateAvatarDecoration: "아바타 장식 수정" deleteAvatarDecoration: "아바타 장식 삭제" + unsetMfa: "사용자의 2단계 인증 해제" unsetUserAvatar: "유저 아바타 제거" unsetUserBanner: "유저 배너 제거" createSystemWebhook: "SystemWebhook을 생성" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index f826b716c7..53f7e865ad 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -57,7 +57,7 @@ deleteAndEditConfirm: "要删除该帖并重新编辑吗?该帖下的所有回 addToList: "添加至列表" addToAntenna: "添加到天线" sendMessage: "发送消息" -copyRSS: "复制RSS" +copyRSS: "复制 RSS" copyUsername: "复制用户名" copyUserId: "复制用户 ID" copyNoteId: "复制帖子 ID" @@ -456,8 +456,8 @@ about: "关于" aboutMisskey: "关于 Misskey" administrator: "管理员" token: "Token (令牌)" -2fa: "双因素认证" -setupOf2fa: "设置双因素认证" +2fa: "双重验证" +setupOf2fa: "设置双重验证" totp: "验证器" totpDescription: "使用验证器输入一次性密码" moderator: "监察员" @@ -485,7 +485,7 @@ markAsReadAllNotifications: "将所有通知标为已读" markAsReadAllUnreadNotes: "将所有帖子标记为已读" markAsReadAllTalkMessages: "将所有私信标记为已读" help: "帮助" -inputMessageHere: "在此键入信息" +inputMessageHere: "在此输入信息" close: "关闭" invites: "邀请" members: "成员" @@ -619,6 +619,8 @@ output: "输出" script: "脚本" disablePagesScript: "禁用页面脚本" updateRemoteUser: "更新远程用户信息" +unsetMfa: "解除双重验证" +unsetMfaConfirm: "确认解除双重验证吗?" unsetUserAvatar: "清除头像" unsetUserAvatarConfirm: "要清除头像吗?" unsetUserBanner: "清除横幅" @@ -1417,6 +1419,8 @@ addToEmojiPalette: "添加至表情符号选择器" emojiPaletteAlreadyAddedConfirm: "此表情符号已存在于此表情符号选择器中。要再次添加吗?" append: "加到最后" prepend: "加到最前" +urlPreviewSensitiveList: "限制显示缩略图的 URL" +urlPreviewSensitiveListDescription: "AND 条件用空格分隔,OR 条件用换行符分隔,正则表达式用斜线包裹。成功匹配则不再显示缩略图。" _imageEditing: _vars: caption: "文件标题" @@ -2149,6 +2153,15 @@ _sensitiveMediaDetection: setSensitiveFlagAutomaticallyDescription: "即使关闭此配置,识别结果也会在内部保存。" analyzeVideos: "启用对视频的检测" analyzeVideosDescription: "除了静止图像之外,还对视频进行分析。服务器负载会略微增加。" + externalServiceInfo: "检测敏感媒体已分离至外部服务 (sensitive-detector)。若要使用,需额外部署 Sidecar 服务,并设置下方的连接 URL。未设定时将不会进行检测(视为非敏感媒体)。" + apiUrl: "检测服务的连接 URL" + apiUrlDescription: "sensitive-detector 服务的 base URL(如:http://localhost:3009)。若是连接至部署在专用网络上的服务,请在配置文件中的 allowedPrivateNetworks 里允许目标网络。若是使用了代理,请一并设置 proxyBypassHosts。留空则不进行敏感媒体检测。" + apiKey: "API 密钥" + apiKeyDescription: "若服务端有设置验证(Bearer token)则填写,未设置则留空。" + timeout: "超时(毫秒)" + timeoutDescription: "此为单次检测请求的超时时长。" + maxImagesPerRequest: "单次检测请求最大图像数量" + maxImagesPerRequestDescription: "此为在检测动画等多帧图像时,单次请求中可发送的图像数量上限。超出此值时动画将被拆分并按序发送。请勿将此值设为超出 sensitive-detector 侧的 maxParts 的值(默认:10),否则对应的分块将全被视为非敏感媒体。" _emailUnavailable: used: "已经被使用过" format: "无效的格式" @@ -2461,6 +2474,7 @@ _permissions: "read:admin:show-moderation-log": "查看管理日志" "read:admin:show-user": "查看用户的非公开信息" "write:admin:suspend-user": "冻结用户" + "write:admin:unset-mfa": "解除用户的双重验证" "write:admin:unset-user-avatar": "删除用户头像" "write:admin:unset-user-banner": "删除用户横幅" "write:admin:unsuspend-user": "解除用户冻结" @@ -2586,7 +2600,7 @@ _widgetOptions: _jobQueue: sound: "播放音效" _rss: - url: "RSS feed 的 URL" + url: "RSS 订阅源网址" refreshIntervalSec: "更新间隔(秒)" maxEntries: "最大显示个数" _rssTicker: @@ -2976,6 +2990,7 @@ _moderationLogTypes: createAvatarDecoration: "新建头像挂件" updateAvatarDecoration: "更新头像挂件" deleteAvatarDecoration: "删除头像挂件" + unsetMfa: "解除用户的双重验证" unsetUserAvatar: "清除用户头像" unsetUserBanner: "清除用户横幅" createSystemWebhook: "新建了 SystemWebhook" From 7c29a18d61ab66eb59fc259c3f766f61a3e47ee3 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Fri, 24 Jul 2026 21:39:28 +0900 Subject: [PATCH 156/168] =?UTF-8?q?enhance(frontend):=20=E3=82=A2=E3=83=83?= =?UTF-8?q?=E3=83=97=E3=83=AD=E3=83=BC=E3=83=89=E5=89=8D=E3=81=AE=E3=83=95?= =?UTF-8?q?=E3=82=A1=E3=82=A4=E3=83=AB=E3=81=AE=E3=83=97=E3=83=AC=E3=83=93?= =?UTF-8?q?=E3=83=A5=E3=83=BC=E3=81=AB=E5=AF=BE=E5=BF=9C&=E3=82=A2?= =?UTF-8?q?=E3=83=83=E3=83=97=E3=83=AD=E3=83=BC=E3=83=89=E5=BE=8C=E3=81=AE?= =?UTF-8?q?=E3=83=97=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC=E3=81=ABMkLightbo?= =?UTF-8?q?x=E3=82=92=E4=BD=BF=E7=94=A8=20(#17794)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * wip * [ci skip] Update CHANGELOG.md * Update MkUploaderItems.vue * Update MkUploaderItems.vue * Update MkUploaderItems.vue --- CHANGELOG.md | 3 +- packages/frontend/.storybook/generate.tsx | 1 - .../MkImgPreviewDialog.stories.impl.ts | 40 ------------ .../src/components/MkImgPreviewDialog.vue | 63 ------------------- .../frontend/src/components/MkLightbox.vue | 19 +++++- .../frontend/src/components/MkMediaList.vue | 4 +- .../src/components/MkPostFormAttaches.vue | 19 +++++- .../src/components/MkUploaderItems.vue | 50 +++++++++++++-- 8 files changed, 84 insertions(+), 115 deletions(-) delete mode 100644 packages/frontend/src/components/MkImgPreviewDialog.stories.impl.ts delete mode 100644 packages/frontend/src/components/MkImgPreviewDialog.vue diff --git a/CHANGELOG.md b/CHANGELOG.md index 61acf1fa7b..4bcf30b603 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ ### Client - 2025.4.0 以前の設定情報の移行処理が削除されました - 2025.4.0 から直接 2026.6.0 以上にアップデートする場合は設定が移行されませんので注意してください。移行したい場合は一度 2026.5.1 を経由してください。 -- Enhance: 画像ビューワーを独自実装に変更・動画プレイヤーを統合 +- Enhance: 画像ビューワーを刷新・動画プレイヤーを統合 - 操作性の改善 - ビューワーとMisskeyの各種機能との統合を強化 - パフォーマンスの向上 @@ -29,6 +29,7 @@ - Fix: 幅が狭い画面で動画の再生が困難な問題を修正 - Fix: 一部の画像のみセンシティブなとき、ビューワー内で画像を切り替えるとセンシティブな画像がそのまま表示される問題を修正 - Fix: 一部の画像をビューワーで読み込んだ際に正しく表示されない問題を修正 +- Enhance: ファイルアップロード前にプレビューできるように - Enhance: タブがバックグラウンドの間は必要ない定期更新処理を停止するように - Fix: 「画像を新しいタブで開く」が機能しなくなっていた問題を修正 - Fix: デバイスタイプをスマートフォンに固定している状態で画面幅が広いとき、画面左上のアイコンが表示されない問題を修正 diff --git a/packages/frontend/.storybook/generate.tsx b/packages/frontend/.storybook/generate.tsx index 6005049dde..01ddefa544 100644 --- a/packages/frontend/.storybook/generate.tsx +++ b/packages/frontend/.storybook/generate.tsx @@ -458,7 +458,6 @@ function toStories(component: string): Promise { globSync('src/components/MkSignupServerRules.vue'), globSync('src/components/MkUserSetupDialog.vue'), globSync('src/components/MkUserSetupDialog.*.vue'), - globSync('src/components/MkImgPreviewDialog.vue'), globSync('src/components/MkInstanceCardMini.vue'), globSync('src/components/MkInviteCode.vue'), globSync('src/components/MkTagItem.vue'), diff --git a/packages/frontend/src/components/MkImgPreviewDialog.stories.impl.ts b/packages/frontend/src/components/MkImgPreviewDialog.stories.impl.ts deleted file mode 100644 index 7da705a23f..0000000000 --- a/packages/frontend/src/components/MkImgPreviewDialog.stories.impl.ts +++ /dev/null @@ -1,40 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import type { StoryObj } from '@storybook/vue3'; -import { file } from '../../.storybook/fakes.js'; -import MkImgPreviewDialog from './MkImgPreviewDialog.vue'; -export const Default = { - render(args) { - return { - components: { - MkImgPreviewDialog, - }, - setup() { - return { - args, - }; - }, - computed: { - props() { - return { - ...this.args, - }; - }, - }, - template: '', - }; - }, - args: { - file: file(), - }, - parameters: { - chromatic: { - // NOTE: ロードが終わるまで待つ - delay: 3000, - }, - layout: 'centered', - }, -} satisfies StoryObj; diff --git a/packages/frontend/src/components/MkImgPreviewDialog.vue b/packages/frontend/src/components/MkImgPreviewDialog.vue deleted file mode 100644 index 530b6c45db..0000000000 --- a/packages/frontend/src/components/MkImgPreviewDialog.vue +++ /dev/null @@ -1,63 +0,0 @@ - - - - - diff --git a/packages/frontend/src/components/MkLightbox.vue b/packages/frontend/src/components/MkLightbox.vue index d999723c4c..ae40c7a612 100644 --- a/packages/frontend/src/components/MkLightbox.vue +++ b/packages/frontend/src/components/MkLightbox.vue @@ -48,13 +48,14 @@ SPDX-License-Identifier: AGPL-3.0-only From cf7a6b6efc05a6a016d5c60e2e00d7210d7ffa81 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 21:40:01 +0900 Subject: [PATCH 157/168] chore(deps): update [github actions] update dependencies (#17791) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/api-misskey-js.yml | 2 +- .github/workflows/backend-diagnostics.comment.yml | 2 +- .github/workflows/backend-diagnostics.inspect.yml | 2 +- .github/workflows/changelog-check.yml | 2 +- .github/workflows/check-misskey-js-autogen.yml | 2 +- .github/workflows/frontend-diagnostics.inspect.yml | 2 +- .github/workflows/get-api-diff.yml | 2 +- .github/workflows/lint.yml | 10 +++++----- .github/workflows/locale.yml | 2 +- .github/workflows/on-release-created.yml | 2 +- .github/workflows/packages-private.yml | 2 +- .github/workflows/storybook.yml | 2 +- .github/workflows/test-backend.yml | 8 ++++---- .github/workflows/test-federation.yml | 2 +- .github/workflows/test-frontend.yml | 4 ++-- .github/workflows/test-misskey-js.yml | 2 +- .github/workflows/test-production.yml | 2 +- .github/workflows/validate-api-json.yml | 2 +- 18 files changed, 26 insertions(+), 26 deletions(-) diff --git a/.github/workflows/api-misskey-js.yml b/.github/workflows/api-misskey-js.yml index 19987d2af4..073ba89ac4 100644 --- a/.github/workflows/api-misskey-js.yml +++ b/.github/workflows/api-misskey-js.yml @@ -22,7 +22,7 @@ jobs: uses: pnpm/action-setup@v6.0.9 - name: Setup Node.js - uses: actions/setup-node@v6.4.0 + uses: actions/setup-node@v6.5.0 with: node-version-file: '.node-version' cache: 'pnpm' diff --git a/.github/workflows/backend-diagnostics.comment.yml b/.github/workflows/backend-diagnostics.comment.yml index a6d914441c..b9f72ad88d 100644 --- a/.github/workflows/backend-diagnostics.comment.yml +++ b/.github/workflows/backend-diagnostics.comment.yml @@ -22,7 +22,7 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v6.0.9 - name: Use Node.js - uses: actions/setup-node@v6.4.0 + uses: actions/setup-node@v6.5.0 with: node-version-file: '.node-version' cache: 'pnpm' diff --git a/.github/workflows/backend-diagnostics.inspect.yml b/.github/workflows/backend-diagnostics.inspect.yml index 8040b13307..f01a74375a 100644 --- a/.github/workflows/backend-diagnostics.inspect.yml +++ b/.github/workflows/backend-diagnostics.inspect.yml @@ -51,7 +51,7 @@ jobs: with: package_json_file: head/package.json - name: Use Node.js - uses: actions/setup-node@v6.4.0 + uses: actions/setup-node@v6.5.0 with: node-version-file: 'head/.node-version' cache: 'pnpm' diff --git a/.github/workflows/changelog-check.yml b/.github/workflows/changelog-check.yml index a6d222b6bd..a54efff4b6 100644 --- a/.github/workflows/changelog-check.yml +++ b/.github/workflows/changelog-check.yml @@ -18,7 +18,7 @@ jobs: uses: pnpm/action-setup@v6 - name: setup node - uses: actions/setup-node@v6.4.0 + uses: actions/setup-node@v6.5.0 with: node-version-file: '.node-version' cache: pnpm diff --git a/.github/workflows/check-misskey-js-autogen.yml b/.github/workflows/check-misskey-js-autogen.yml index c5350b62d2..254c4bf80e 100644 --- a/.github/workflows/check-misskey-js-autogen.yml +++ b/.github/workflows/check-misskey-js-autogen.yml @@ -29,7 +29,7 @@ jobs: - name: setup node id: setup-node - uses: actions/setup-node@v6.4.0 + uses: actions/setup-node@v6.5.0 with: node-version-file: '.node-version' cache: pnpm diff --git a/.github/workflows/frontend-diagnostics.inspect.yml b/.github/workflows/frontend-diagnostics.inspect.yml index ecf06c3092..ba7fc8ebfd 100644 --- a/.github/workflows/frontend-diagnostics.inspect.yml +++ b/.github/workflows/frontend-diagnostics.inspect.yml @@ -79,7 +79,7 @@ jobs: package_json_file: head/package.json - name: Setup Node.js - uses: actions/setup-node@v6.4.0 + uses: actions/setup-node@v6.5.0 with: node-version-file: head/.node-version cache: pnpm diff --git a/.github/workflows/get-api-diff.yml b/.github/workflows/get-api-diff.yml index ef34d19e68..82ce6873c8 100644 --- a/.github/workflows/get-api-diff.yml +++ b/.github/workflows/get-api-diff.yml @@ -32,7 +32,7 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v6.0.9 - name: Use Node.js - uses: actions/setup-node@v6.4.0 + uses: actions/setup-node@v6.5.0 with: node-version-file: '.node-version' cache: 'pnpm' diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 0e77f896a2..ebc88a74c4 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -46,7 +46,7 @@ jobs: submodules: true - name: Setup pnpm uses: pnpm/action-setup@v6.0.9 - - uses: actions/setup-node@v6.4.0 + - uses: actions/setup-node@v6.5.0 with: node-version-file: '.node-version' cache: 'pnpm' @@ -79,13 +79,13 @@ jobs: submodules: true - name: Setup pnpm uses: pnpm/action-setup@v6.0.9 - - uses: actions/setup-node@v6.4.0 + - uses: actions/setup-node@v6.5.0 with: node-version-file: '.node-version' cache: 'pnpm' - run: pnpm i --frozen-lockfile - name: Restore eslint cache - uses: actions/cache@v5.0.5 + uses: actions/cache@v5.1.0 with: path: ${{ env.eslint-cache-path }} key: eslint-${{ env.eslint-cache-version }}-${{ matrix.workspace }}-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ github.ref_name }}-${{ github.sha }} @@ -110,7 +110,7 @@ jobs: submodules: true - name: Setup pnpm uses: pnpm/action-setup@v6.0.9 - - uses: actions/setup-node@v6.4.0 + - uses: actions/setup-node@v6.5.0 with: node-version-file: '.node-version' cache: 'pnpm' @@ -129,7 +129,7 @@ jobs: submodules: true - name: Setup pnpm uses: pnpm/action-setup@v6.0.9 - - uses: actions/setup-node@v6.4.0 + - uses: actions/setup-node@v6.5.0 with: node-version-file: '.node-version' cache: 'pnpm' diff --git a/.github/workflows/locale.yml b/.github/workflows/locale.yml index d87f18827e..a0f26f4596 100644 --- a/.github/workflows/locale.yml +++ b/.github/workflows/locale.yml @@ -22,7 +22,7 @@ jobs: submodules: true - name: Setup pnpm uses: pnpm/action-setup@v6.0.9 - - uses: actions/setup-node@v6.4.0 + - uses: actions/setup-node@v6.5.0 with: node-version-file: ".node-version" cache: "pnpm" diff --git a/.github/workflows/on-release-created.yml b/.github/workflows/on-release-created.yml index 76fd5548f2..48a20c360e 100644 --- a/.github/workflows/on-release-created.yml +++ b/.github/workflows/on-release-created.yml @@ -22,7 +22,7 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v6.0.9 - name: Use Node.js - uses: actions/setup-node@v6.4.0 + uses: actions/setup-node@v6.5.0 with: node-version-file: '.node-version' cache: 'pnpm' diff --git a/.github/workflows/packages-private.yml b/.github/workflows/packages-private.yml index cf7e50670a..b3f69e43ae 100644 --- a/.github/workflows/packages-private.yml +++ b/.github/workflows/packages-private.yml @@ -41,7 +41,7 @@ jobs: persist-credentials: false - name: Setup pnpm uses: pnpm/action-setup@v6.0.9 - - uses: actions/setup-node@v6.4.0 + - uses: actions/setup-node@v6.5.0 with: node-version-file: '.node-version' cache: 'pnpm' diff --git a/.github/workflows/storybook.yml b/.github/workflows/storybook.yml index 70bfa329a1..03e19f4780 100644 --- a/.github/workflows/storybook.yml +++ b/.github/workflows/storybook.yml @@ -39,7 +39,7 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v6.0.9 - name: Use Node.js - uses: actions/setup-node@v6.4.0 + uses: actions/setup-node@v6.5.0 with: node-version-file: '.node-version' cache: 'pnpm' diff --git a/.github/workflows/test-backend.yml b/.github/workflows/test-backend.yml index 3bf3b9eff5..4818fe051d 100644 --- a/.github/workflows/test-backend.yml +++ b/.github/workflows/test-backend.yml @@ -43,7 +43,7 @@ jobs: ports: - 56312:6379 meilisearch: - image: getmeili/meilisearch:v1.48.1 + image: getmeili/meilisearch:v1.49.0 ports: - 57712:7700 env: @@ -60,7 +60,7 @@ jobs: run: | sudo apt install -y ffmpeg - name: Use Node.js - uses: actions/setup-node@v6.4.0 + uses: actions/setup-node@v6.5.0 with: node-version-file: ${{ matrix.node-version-file }} cache: 'pnpm' @@ -109,7 +109,7 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v6.0.9 - name: Use Node.js - uses: actions/setup-node@v6.4.0 + uses: actions/setup-node@v6.5.0 with: node-version-file: ${{ matrix.node-version-file }} cache: 'pnpm' @@ -156,7 +156,7 @@ jobs: id: current-date run: echo "today=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT - name: Use Node.js - uses: actions/setup-node@v6.4.0 + uses: actions/setup-node@v6.5.0 with: node-version-file: ${{ matrix.node-version-file }} cache: 'pnpm' diff --git a/.github/workflows/test-federation.yml b/.github/workflows/test-federation.yml index 14dc33f626..0b2aa1bd4b 100644 --- a/.github/workflows/test-federation.yml +++ b/.github/workflows/test-federation.yml @@ -35,7 +35,7 @@ jobs: run: | sudo apt install -y ffmpeg - name: Use Node.js - uses: actions/setup-node@v6.4.0 + uses: actions/setup-node@v6.5.0 with: node-version-file: ${{ matrix.node-version-file }} cache: 'pnpm' diff --git a/.github/workflows/test-frontend.yml b/.github/workflows/test-frontend.yml index e8a98b2b78..abf3332d17 100644 --- a/.github/workflows/test-frontend.yml +++ b/.github/workflows/test-frontend.yml @@ -34,7 +34,7 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v6.0.9 - name: Use Node.js - uses: actions/setup-node@v6.4.0 + uses: actions/setup-node@v6.5.0 with: node-version-file: '.node-version' cache: 'pnpm' @@ -77,7 +77,7 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v6.0.9 - name: Use Node.js - uses: actions/setup-node@v6.4.0 + uses: actions/setup-node@v6.5.0 with: node-version-file: '.node-version' cache: 'pnpm' diff --git a/.github/workflows/test-misskey-js.yml b/.github/workflows/test-misskey-js.yml index f3c6f0a516..82e04f8c79 100644 --- a/.github/workflows/test-misskey-js.yml +++ b/.github/workflows/test-misskey-js.yml @@ -28,7 +28,7 @@ jobs: uses: pnpm/action-setup@v6.0.9 - name: Setup Node.js - uses: actions/setup-node@v6.4.0 + uses: actions/setup-node@v6.5.0 with: node-version-file: '.node-version' cache: 'pnpm' diff --git a/.github/workflows/test-production.yml b/.github/workflows/test-production.yml index 0d9b2293dc..527b9f4c35 100644 --- a/.github/workflows/test-production.yml +++ b/.github/workflows/test-production.yml @@ -22,7 +22,7 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v6.0.9 - name: Use Node.js - uses: actions/setup-node@v6.4.0 + uses: actions/setup-node@v6.5.0 with: node-version-file: '.node-version' cache: 'pnpm' diff --git a/.github/workflows/validate-api-json.yml b/.github/workflows/validate-api-json.yml index ada1b3b2d8..e59b39472f 100644 --- a/.github/workflows/validate-api-json.yml +++ b/.github/workflows/validate-api-json.yml @@ -23,7 +23,7 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v6.0.9 - name: Use Node.js - uses: actions/setup-node@v6.4.0 + uses: actions/setup-node@v6.5.0 with: node-version-file: '.node-version' cache: 'pnpm' From d2973ecdcacc9276648c658bf7464bcbc6ddd931 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:46:54 +0900 Subject: [PATCH 158/168] chore(deps): update [github actions] update dependencies (major) (#17792) chore(deps): update [github actions] update dependencies Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/api-misskey-js.yml | 4 ++-- .../workflows/backend-diagnostics.comment.yml | 4 ++-- .../workflows/backend-diagnostics.inspect.yml | 6 +++--- .github/workflows/changelog-check.yml | 4 ++-- .github/workflows/check-misskey-js-autogen.yml | 6 +++--- .github/workflows/check-misskey-js-version.yml | 2 +- .github/workflows/check-spdx-license-id.yml | 2 +- .github/workflows/check_copyright_year.yml | 2 +- .github/workflows/docker-develop.yml | 2 +- .github/workflows/docker.yml | 2 +- .github/workflows/dockle.yml | 2 +- .../workflows/frontend-diagnostics.inspect.yml | 6 +++--- .github/workflows/get-api-diff.yml | 4 ++-- .github/workflows/labeler.yml | 2 +- .github/workflows/lint.yml | 18 +++++++++--------- .github/workflows/locale.yml | 4 ++-- .github/workflows/on-release-created.yml | 4 ++-- .github/workflows/packages-private.yml | 4 ++-- .github/workflows/release-edit-with-push.yml | 2 +- .github/workflows/release-with-dispatch.yml | 2 +- .github/workflows/storybook.yml | 6 +++--- .github/workflows/test-backend.yml | 16 ++++++++-------- .github/workflows/test-federation.yml | 4 ++-- .github/workflows/test-frontend.yml | 10 +++++----- .github/workflows/test-misskey-js.yml | 6 +++--- .github/workflows/test-production.yml | 4 ++-- .github/workflows/validate-api-json.yml | 4 ++-- 27 files changed, 66 insertions(+), 66 deletions(-) diff --git a/.github/workflows/api-misskey-js.yml b/.github/workflows/api-misskey-js.yml index 073ba89ac4..09e9c8ab6e 100644 --- a/.github/workflows/api-misskey-js.yml +++ b/.github/workflows/api-misskey-js.yml @@ -16,13 +16,13 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6.0.3 + uses: actions/checkout@v7.0.0 - name: Setup pnpm uses: pnpm/action-setup@v6.0.9 - name: Setup Node.js - uses: actions/setup-node@v6.5.0 + uses: actions/setup-node@v7.0.0 with: node-version-file: '.node-version' cache: 'pnpm' diff --git a/.github/workflows/backend-diagnostics.comment.yml b/.github/workflows/backend-diagnostics.comment.yml index b9f72ad88d..51ac04e86a 100644 --- a/.github/workflows/backend-diagnostics.comment.yml +++ b/.github/workflows/backend-diagnostics.comment.yml @@ -17,12 +17,12 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6.0.3 + uses: actions/checkout@v7.0.0 - name: Setup pnpm uses: pnpm/action-setup@v6.0.9 - name: Use Node.js - uses: actions/setup-node@v6.5.0 + uses: actions/setup-node@v7.0.0 with: node-version-file: '.node-version' cache: 'pnpm' diff --git a/.github/workflows/backend-diagnostics.inspect.yml b/.github/workflows/backend-diagnostics.inspect.yml index f01a74375a..c3b2e9e2b7 100644 --- a/.github/workflows/backend-diagnostics.inspect.yml +++ b/.github/workflows/backend-diagnostics.inspect.yml @@ -35,13 +35,13 @@ jobs: steps: - name: Checkout base - uses: actions/checkout@v6.0.3 + uses: actions/checkout@v7.0.0 with: ref: ${{ github.base_ref }} path: base submodules: true - name: Checkout head - uses: actions/checkout@v6.0.3 + uses: actions/checkout@v7.0.0 with: ref: refs/pull/${{ github.event.number }}/merge path: head @@ -51,7 +51,7 @@ jobs: with: package_json_file: head/package.json - name: Use Node.js - uses: actions/setup-node@v6.5.0 + uses: actions/setup-node@v7.0.0 with: node-version-file: 'head/.node-version' cache: 'pnpm' diff --git a/.github/workflows/changelog-check.yml b/.github/workflows/changelog-check.yml index a54efff4b6..95fe48ab4c 100644 --- a/.github/workflows/changelog-check.yml +++ b/.github/workflows/changelog-check.yml @@ -12,13 +12,13 @@ jobs: steps: - name: Checkout head - uses: actions/checkout@v6.0.3 + uses: actions/checkout@v7.0.0 - name: setup pnpm uses: pnpm/action-setup@v6 - name: setup node - uses: actions/setup-node@v6.5.0 + uses: actions/setup-node@v7.0.0 with: node-version-file: '.node-version' cache: pnpm diff --git a/.github/workflows/check-misskey-js-autogen.yml b/.github/workflows/check-misskey-js-autogen.yml index 254c4bf80e..631daa79c8 100644 --- a/.github/workflows/check-misskey-js-autogen.yml +++ b/.github/workflows/check-misskey-js-autogen.yml @@ -18,7 +18,7 @@ jobs: if: ${{ github.event.pull_request.mergeable == null || github.event.pull_request.mergeable == true }} steps: - name: checkout - uses: actions/checkout@v6.0.3 + uses: actions/checkout@v7.0.0 with: submodules: true persist-credentials: false @@ -29,7 +29,7 @@ jobs: - name: setup node id: setup-node - uses: actions/setup-node@v6.5.0 + uses: actions/setup-node@v7.0.0 with: node-version-file: '.node-version' cache: pnpm @@ -66,7 +66,7 @@ jobs: if: ${{ github.event.pull_request.mergeable == null || github.event.pull_request.mergeable == true }} steps: - name: checkout - uses: actions/checkout@v6.0.3 + uses: actions/checkout@v7.0.0 with: submodules: true persist-credentials: false diff --git a/.github/workflows/check-misskey-js-version.yml b/.github/workflows/check-misskey-js-version.yml index 71351972f4..1d703ad210 100644 --- a/.github/workflows/check-misskey-js-version.yml +++ b/.github/workflows/check-misskey-js-version.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6.0.3 + uses: actions/checkout@v7.0.0 - name: Check version run: | if [ "$(jq -r '.version' package.json)" != "$(jq -r '.version' packages/misskey-js/package.json)" ]; then diff --git a/.github/workflows/check-spdx-license-id.yml b/.github/workflows/check-spdx-license-id.yml index d0cf6e3cf6..ae0102b92c 100644 --- a/.github/workflows/check-spdx-license-id.yml +++ b/.github/workflows/check-spdx-license-id.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6.0.3 + uses: actions/checkout@v7.0.0 - name: Check run: | counter=0 diff --git a/.github/workflows/check_copyright_year.yml b/.github/workflows/check_copyright_year.yml index 4b5e71c43a..a6ed53a0f8 100644 --- a/.github/workflows/check_copyright_year.yml +++ b/.github/workflows/check_copyright_year.yml @@ -10,7 +10,7 @@ jobs: check_copyright_year: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6.0.3 + - uses: actions/checkout@v7.0.0 - run: | if [ "$(grep Copyright COPYING | sed -e 's/.*2014-\([0-9]*\) .*/\1/g')" -ne "$(date +%Y)" ]; then echo "Please change copyright year!" diff --git a/.github/workflows/docker-develop.yml b/.github/workflows/docker-develop.yml index 983f664917..f7dd869e02 100644 --- a/.github/workflows/docker-develop.yml +++ b/.github/workflows/docker-develop.yml @@ -27,7 +27,7 @@ jobs: platform=${{ matrix.platform }} echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV - name: Check out the repo - uses: actions/checkout@v6.0.3 + uses: actions/checkout@v7.0.0 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - name: Log in to Docker Hub diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 277c88894e..8cf0c2b666 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -32,7 +32,7 @@ jobs: platform=${{ matrix.platform }} echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV - name: Check out the repo - uses: actions/checkout@v6.0.3 + uses: actions/checkout@v7.0.0 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - name: Docker meta diff --git a/.github/workflows/dockle.yml b/.github/workflows/dockle.yml index 87256c000e..385cbede66 100644 --- a/.github/workflows/dockle.yml +++ b/.github/workflows/dockle.yml @@ -17,7 +17,7 @@ jobs: DOCKLE_VERSION: 0.4.15 steps: - - uses: actions/checkout@v6.0.3 + - uses: actions/checkout@v7.0.0 - name: Download and install dockle v${{ env.DOCKLE_VERSION }} run: | diff --git a/.github/workflows/frontend-diagnostics.inspect.yml b/.github/workflows/frontend-diagnostics.inspect.yml index ba7fc8ebfd..acbc8ea77e 100644 --- a/.github/workflows/frontend-diagnostics.inspect.yml +++ b/.github/workflows/frontend-diagnostics.inspect.yml @@ -56,7 +56,7 @@ jobs: steps: - name: Checkout base - uses: actions/checkout@v6.0.3 + uses: actions/checkout@v7.0.0 with: persist-credentials: false repository: ${{ github.event.pull_request.base.repo.full_name }} @@ -65,7 +65,7 @@ jobs: submodules: true - name: Checkout head - uses: actions/checkout@v6.0.3 + uses: actions/checkout@v7.0.0 with: persist-credentials: false repository: ${{ github.event.pull_request.head.repo.full_name }} @@ -79,7 +79,7 @@ jobs: package_json_file: head/package.json - name: Setup Node.js - uses: actions/setup-node@v6.5.0 + uses: actions/setup-node@v7.0.0 with: node-version-file: head/.node-version cache: pnpm diff --git a/.github/workflows/get-api-diff.yml b/.github/workflows/get-api-diff.yml index 82ce6873c8..77da323634 100644 --- a/.github/workflows/get-api-diff.yml +++ b/.github/workflows/get-api-diff.yml @@ -25,14 +25,14 @@ jobs: ref: refs/pull/${{ github.event.number }}/merge steps: - - uses: actions/checkout@v6.0.3 + - uses: actions/checkout@v7.0.0 with: ref: ${{ matrix.ref }} submodules: true - name: Setup pnpm uses: pnpm/action-setup@v6.0.9 - name: Use Node.js - uses: actions/setup-node@v6.5.0 + uses: actions/setup-node@v7.0.0 with: node-version-file: '.node-version' cache: 'pnpm' diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 5787572dd5..0cfbb22c65 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -11,6 +11,6 @@ jobs: pull-requests: write runs-on: ubuntu-latest steps: - - uses: actions/labeler@v6 + - uses: actions/labeler@v7 with: repo-token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index ebc88a74c4..70b6f74981 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -40,13 +40,13 @@ jobs: pnpm_install: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6.0.3 + - uses: actions/checkout@v7.0.0 with: fetch-depth: 0 submodules: true - name: Setup pnpm uses: pnpm/action-setup@v6.0.9 - - uses: actions/setup-node@v6.5.0 + - uses: actions/setup-node@v7.0.0 with: node-version-file: '.node-version' cache: 'pnpm' @@ -73,19 +73,19 @@ jobs: eslint-cache-version: v1 eslint-cache-path: ${{ github.workspace }}/node_modules/.cache/eslint-${{ matrix.workspace }} steps: - - uses: actions/checkout@v6.0.3 + - uses: actions/checkout@v7.0.0 with: fetch-depth: 0 submodules: true - name: Setup pnpm uses: pnpm/action-setup@v6.0.9 - - uses: actions/setup-node@v6.5.0 + - uses: actions/setup-node@v7.0.0 with: node-version-file: '.node-version' cache: 'pnpm' - run: pnpm i --frozen-lockfile - name: Restore eslint cache - uses: actions/cache@v5.1.0 + uses: actions/cache@v6.1.0 with: path: ${{ env.eslint-cache-path }} key: eslint-${{ env.eslint-cache-version }}-${{ matrix.workspace }}-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ github.ref_name }}-${{ github.sha }} @@ -104,13 +104,13 @@ jobs: - sw - misskey-js steps: - - uses: actions/checkout@v6.0.3 + - uses: actions/checkout@v7.0.0 with: fetch-depth: 0 submodules: true - name: Setup pnpm uses: pnpm/action-setup@v6.0.9 - - uses: actions/setup-node@v6.5.0 + - uses: actions/setup-node@v7.0.0 with: node-version-file: '.node-version' cache: 'pnpm' @@ -123,13 +123,13 @@ jobs: runs-on: ubuntu-latest continue-on-error: true steps: - - uses: actions/checkout@v6.0.3 + - uses: actions/checkout@v7.0.0 with: fetch-depth: 0 submodules: true - name: Setup pnpm uses: pnpm/action-setup@v6.0.9 - - uses: actions/setup-node@v6.5.0 + - uses: actions/setup-node@v7.0.0 with: node-version-file: '.node-version' cache: 'pnpm' diff --git a/.github/workflows/locale.yml b/.github/workflows/locale.yml index a0f26f4596..1eb2f56056 100644 --- a/.github/workflows/locale.yml +++ b/.github/workflows/locale.yml @@ -16,13 +16,13 @@ jobs: runs-on: ubuntu-latest continue-on-error: true steps: - - uses: actions/checkout@v6.0.3 + - uses: actions/checkout@v7.0.0 with: fetch-depth: 0 submodules: true - name: Setup pnpm uses: pnpm/action-setup@v6.0.9 - - uses: actions/setup-node@v6.5.0 + - uses: actions/setup-node@v7.0.0 with: node-version-file: ".node-version" cache: "pnpm" diff --git a/.github/workflows/on-release-created.yml b/.github/workflows/on-release-created.yml index 48a20c360e..37e976f25f 100644 --- a/.github/workflows/on-release-created.yml +++ b/.github/workflows/on-release-created.yml @@ -16,13 +16,13 @@ jobs: id-token: write steps: - - uses: actions/checkout@v6.0.3 + - uses: actions/checkout@v7.0.0 with: submodules: true - name: Setup pnpm uses: pnpm/action-setup@v6.0.9 - name: Use Node.js - uses: actions/setup-node@v6.5.0 + uses: actions/setup-node@v7.0.0 with: node-version-file: '.node-version' cache: 'pnpm' diff --git a/.github/workflows/packages-private.yml b/.github/workflows/packages-private.yml index b3f69e43ae..2a93455c25 100644 --- a/.github/workflows/packages-private.yml +++ b/.github/workflows/packages-private.yml @@ -36,12 +36,12 @@ jobs: - diagnostics-frontend - changelog-checker steps: - - uses: actions/checkout@v6.0.3 + - uses: actions/checkout@v7.0.0 with: persist-credentials: false - name: Setup pnpm uses: pnpm/action-setup@v6.0.9 - - uses: actions/setup-node@v6.5.0 + - uses: actions/setup-node@v7.0.0 with: node-version-file: '.node-version' cache: 'pnpm' diff --git a/.github/workflows/release-edit-with-push.yml b/.github/workflows/release-edit-with-push.yml index bc16dbcef2..176dcde1af 100644 --- a/.github/workflows/release-edit-with-push.yml +++ b/.github/workflows/release-edit-with-push.yml @@ -19,7 +19,7 @@ jobs: edit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 # headが$GITHUB_REF_NAME, baseが$STABLE_BRANCHかつopenのPRを1つ取得 - name: Get PR run: | diff --git a/.github/workflows/release-with-dispatch.yml b/.github/workflows/release-with-dispatch.yml index f318200584..051a5e5419 100644 --- a/.github/workflows/release-with-dispatch.yml +++ b/.github/workflows/release-with-dispatch.yml @@ -36,7 +36,7 @@ jobs: outputs: pr_number: ${{ steps.get_pr.outputs.pr_number }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 # headが$GITHUB_REF_NAME, baseが$STABLE_BRANCHかつopenのPRを1つ取得 - name: Get PRs run: | diff --git a/.github/workflows/storybook.yml b/.github/workflows/storybook.yml index 03e19f4780..670ac3549e 100644 --- a/.github/workflows/storybook.yml +++ b/.github/workflows/storybook.yml @@ -22,12 +22,12 @@ jobs: NODE_OPTIONS: "--max_old_space_size=7168" steps: - - uses: actions/checkout@v6.0.3 + - uses: actions/checkout@v7.0.0 if: github.event_name != 'pull_request_target' with: fetch-depth: 0 submodules: true - - uses: actions/checkout@v6.0.3 + - uses: actions/checkout@v7.0.0 if: github.event_name == 'pull_request_target' with: fetch-depth: 0 @@ -39,7 +39,7 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v6.0.9 - name: Use Node.js - uses: actions/setup-node@v6.5.0 + uses: actions/setup-node@v7.0.0 with: node-version-file: '.node-version' cache: 'pnpm' diff --git a/.github/workflows/test-backend.yml b/.github/workflows/test-backend.yml index 4818fe051d..7992d5d83b 100644 --- a/.github/workflows/test-backend.yml +++ b/.github/workflows/test-backend.yml @@ -51,7 +51,7 @@ jobs: MEILI_ENV: development steps: - - uses: actions/checkout@v6.0.3 + - uses: actions/checkout@v7.0.0 with: submodules: true - name: Setup pnpm @@ -60,7 +60,7 @@ jobs: run: | sudo apt install -y ffmpeg - name: Use Node.js - uses: actions/setup-node@v6.5.0 + uses: actions/setup-node@v7.0.0 with: node-version-file: ${{ matrix.node-version-file }} cache: 'pnpm' @@ -74,7 +74,7 @@ jobs: - name: Test run: pnpm --filter backend test-and-coverage - name: Upload to Codecov - uses: codecov/codecov-action@v6 + uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} files: ./packages/backend/coverage/coverage-final.json @@ -103,13 +103,13 @@ jobs: - 56312:6379 steps: - - uses: actions/checkout@v6.0.3 + - uses: actions/checkout@v7.0.0 with: submodules: true - name: Setup pnpm uses: pnpm/action-setup@v6.0.9 - name: Use Node.js - uses: actions/setup-node@v6.5.0 + uses: actions/setup-node@v7.0.0 with: node-version-file: ${{ matrix.node-version-file }} cache: 'pnpm' @@ -123,7 +123,7 @@ jobs: - name: Test run: pnpm --filter backend test-and-coverage:e2e - name: Upload to Codecov - uses: codecov/codecov-action@v6 + uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} files: ./packages/backend/coverage/coverage-final.json @@ -147,7 +147,7 @@ jobs: POSTGRES_HOST_AUTH_METHOD: trust steps: - - uses: actions/checkout@v6.0.3 + - uses: actions/checkout@v7.0.0 with: submodules: true - name: Setup pnpm @@ -156,7 +156,7 @@ jobs: id: current-date run: echo "today=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT - name: Use Node.js - uses: actions/setup-node@v6.5.0 + uses: actions/setup-node@v7.0.0 with: node-version-file: ${{ matrix.node-version-file }} cache: 'pnpm' diff --git a/.github/workflows/test-federation.yml b/.github/workflows/test-federation.yml index 0b2aa1bd4b..1005cfa058 100644 --- a/.github/workflows/test-federation.yml +++ b/.github/workflows/test-federation.yml @@ -26,7 +26,7 @@ jobs: - .node-version - .github/min.node-version steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: submodules: true - name: Setup pnpm @@ -35,7 +35,7 @@ jobs: run: | sudo apt install -y ffmpeg - name: Use Node.js - uses: actions/setup-node@v6.5.0 + uses: actions/setup-node@v7.0.0 with: node-version-file: ${{ matrix.node-version-file }} cache: 'pnpm' diff --git a/.github/workflows/test-frontend.yml b/.github/workflows/test-frontend.yml index abf3332d17..774c552672 100644 --- a/.github/workflows/test-frontend.yml +++ b/.github/workflows/test-frontend.yml @@ -28,13 +28,13 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6.0.3 + - uses: actions/checkout@v7.0.0 with: submodules: true - name: Setup pnpm uses: pnpm/action-setup@v6.0.9 - name: Use Node.js - uses: actions/setup-node@v6.5.0 + uses: actions/setup-node@v7.0.0 with: node-version-file: '.node-version' cache: 'pnpm' @@ -48,7 +48,7 @@ jobs: - name: Test run: pnpm --filter frontend test-and-coverage - name: Upload Coverage - uses: codecov/codecov-action@v6 + uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} files: ./packages/frontend/coverage/coverage-final.json @@ -71,13 +71,13 @@ jobs: - 56312:6379 steps: - - uses: actions/checkout@v6.0.3 + - uses: actions/checkout@v7.0.0 with: submodules: true - name: Setup pnpm uses: pnpm/action-setup@v6.0.9 - name: Use Node.js - uses: actions/setup-node@v6.5.0 + uses: actions/setup-node@v7.0.0 with: node-version-file: '.node-version' cache: 'pnpm' diff --git a/.github/workflows/test-misskey-js.yml b/.github/workflows/test-misskey-js.yml index 82e04f8c79..e3198051aa 100644 --- a/.github/workflows/test-misskey-js.yml +++ b/.github/workflows/test-misskey-js.yml @@ -22,13 +22,13 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6.0.3 + uses: actions/checkout@v7.0.0 - name: Setup pnpm uses: pnpm/action-setup@v6.0.9 - name: Setup Node.js - uses: actions/setup-node@v6.5.0 + uses: actions/setup-node@v7.0.0 with: node-version-file: '.node-version' cache: 'pnpm' @@ -48,7 +48,7 @@ jobs: CI: true - name: Upload Coverage - uses: codecov/codecov-action@v6 + uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} files: ./packages/misskey-js/coverage/coverage-final.json diff --git a/.github/workflows/test-production.yml b/.github/workflows/test-production.yml index 527b9f4c35..8192d21c81 100644 --- a/.github/workflows/test-production.yml +++ b/.github/workflows/test-production.yml @@ -16,13 +16,13 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6.0.3 + - uses: actions/checkout@v7.0.0 with: submodules: true - name: Setup pnpm uses: pnpm/action-setup@v6.0.9 - name: Use Node.js - uses: actions/setup-node@v6.5.0 + uses: actions/setup-node@v7.0.0 with: node-version-file: '.node-version' cache: 'pnpm' diff --git a/.github/workflows/validate-api-json.yml b/.github/workflows/validate-api-json.yml index e59b39472f..b355f4e52b 100644 --- a/.github/workflows/validate-api-json.yml +++ b/.github/workflows/validate-api-json.yml @@ -17,13 +17,13 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6.0.3 + - uses: actions/checkout@v7.0.0 with: submodules: true - name: Setup pnpm uses: pnpm/action-setup@v6.0.9 - name: Use Node.js - uses: actions/setup-node@v6.5.0 + uses: actions/setup-node@v7.0.0 with: node-version-file: '.node-version' cache: 'pnpm' From e8951b7aa1c8ad35474e76f49e1bf3ae186f7e23 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 09:27:15 +0900 Subject: [PATCH 159/168] fix(deps): update dependency @fastify/static to v10.1.2 [security] (#17796) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- packages/backend/package.json | 2 +- pnpm-lock.yaml | 10 +++++----- pnpm-workspace.yaml | 2 ++ 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/backend/package.json b/packages/backend/package.json index a13ce02e8a..7c456daecc 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -58,7 +58,7 @@ "@fastify/http-proxy": "11.5.0", "@fastify/multipart": "10.1.0", "@fastify/otel": "0.20.1", - "@fastify/static": "10.1.0", + "@fastify/static": "10.1.2", "@kitajs/html": "4.2.13", "@misskey-dev/emoji-assets": "17.0.3", "@misskey-dev/emoji-data": "17.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a430acbf92..4c73584bda 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -192,8 +192,8 @@ importers: specifier: 0.20.1 version: 0.20.1(@opentelemetry/api@1.9.1) '@fastify/static': - specifier: 10.1.0 - version: 10.1.0 + specifier: 10.1.2 + version: 10.1.2 '@kitajs/html': specifier: 4.2.13 version: 4.2.13 @@ -1955,8 +1955,8 @@ packages: '@fastify/send@4.1.0': resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==} - '@fastify/static@10.1.0': - resolution: {integrity: sha512-iK/8TvRM/EgNOyQL+EpWu+x3aR6o4GWt+UI+27zmE7w6t/6d80mXqOtWLdEKQ13vL/g1Jry0ae2icj6GP7tGzA==} + '@fastify/static@10.1.2': + resolution: {integrity: sha512-G/g18cG9tLutT/OVyN1AIsHIl9L1UwmJ+S3dkyhVpplIx0nEMicd7RGQ+uJLyhKKF4a3tTcQydccn3Mop1fX+Q==} '@file-type/xml@0.4.4': resolution: {integrity: sha512-NhCyXoHlVZ8TqM476hyzwGJ24+D5IPSaZhmrPj7qXnEVb3q6jrFzA3mM9TBpknKSI9EuQeGTKRg2DXGUwvBBoQ==} @@ -10088,7 +10088,7 @@ snapshots: http-errors: 2.0.1 mime: 3.0.0 - '@fastify/static@10.1.0': + '@fastify/static@10.1.2': dependencies: '@fastify/accept-negotiator': 2.0.1 '@fastify/error': 4.2.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4d3caf525a..2eb12d2e0c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -58,6 +58,8 @@ minimumReleaseAgeExclude: - "@opentelemetry/core@2.8.0" # Renovate security update: typeorm@1.1.0 - typeorm@1.1.0 + # Renovate security update: @fastify/static@10.1.2 + - "@fastify/static@10.1.2" overrides: '@aiscript-dev/aiscript-languageserver': '-' 'bullmq>ioredis': 5.11.1 From cde2fe24c4fe3fdceef8ad878c6f43f587aa7446 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:27:21 +0900 Subject: [PATCH 160/168] fix(deps): update dependency tar to v7.5.21 [security] (#17798) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- pnpm-lock.yaml | 12 ++++++------ pnpm-workspace.yaml | 2 ++ 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 63e6db5cef..cac0c77cf2 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "execa": "9.6.1", "ignore-walk": "9.0.0", "js-yaml": "5.2.1", - "tar": "7.5.20" + "tar": "7.5.21" }, "devDependencies": { "@eslint/js": "9.39.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c73584bda..dd7afbfad5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,8 +27,8 @@ importers: specifier: 5.2.1 version: 5.2.1 tar: - specifier: 7.5.20 - version: 7.5.20 + specifier: 7.5.21 + version: 7.5.21 devDependencies: '@eslint/js': specifier: 9.39.5 @@ -8613,8 +8613,8 @@ packages: tar-stream@3.2.0: resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} - tar@7.5.20: - resolution: {integrity: sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==} + tar@7.5.21: + resolution: {integrity: sha512-XdhtCvlMywwxpCW8YEq3lOXBJpUPTR2OHHcwLPO3HwsJqOHa2Ok/oJ7ruGzp+JrKoRPVCzJwAdEjqLW/vNRPHA==} engines: {node: '>=18'} taskkill@5.0.0: @@ -15928,7 +15928,7 @@ snapshots: nopt: 10.0.1 proc-log: 7.0.0 semver: 7.8.5 - tar: 7.5.20 + tar: 7.5.21 tinyglobby: 0.2.17 undici: 6.25.0 which: 7.0.0 @@ -17586,7 +17586,7 @@ snapshots: - bare-buffer - react-native-b4a - tar@7.5.20: + tar@7.5.21: dependencies: '@isaacs/fs-minipass': 4.0.1 chownr: 3.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2eb12d2e0c..45c691ac27 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -60,6 +60,8 @@ minimumReleaseAgeExclude: - typeorm@1.1.0 # Renovate security update: @fastify/static@10.1.2 - "@fastify/static@10.1.2" + # Renovate security update: tar@7.5.21 + - tar@7.5.21 overrides: '@aiscript-dev/aiscript-languageserver': '-' 'bullmq>ioredis': 5.11.1 From c70fb20b9ef65e4694bc05569a3566be37668d16 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 25 Jul 2026 01:29:48 +0000 Subject: [PATCH 161/168] Bump version to 2026.7.0-beta.6 --- package.json | 2 +- packages/misskey-js/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index cac0c77cf2..07d2855fdb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "misskey", - "version": "2026.7.0-beta.5", + "version": "2026.7.0-beta.6", "codename": "nasubi", "repository": { "type": "git", diff --git a/packages/misskey-js/package.json b/packages/misskey-js/package.json index b0a17f768a..abe0cc5b60 100644 --- a/packages/misskey-js/package.json +++ b/packages/misskey-js/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "misskey-js", - "version": "2026.7.0-beta.5", + "version": "2026.7.0-beta.6", "description": "Misskey SDK for JavaScript", "license": "MIT", "main": "./built/index.js", From 7ecce0901cbd9a512947e0d28f011ccc2a5f0b30 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:07:49 +0900 Subject: [PATCH 162/168] fix(deps): update dependency js-yaml to v5.2.2 [security] (#17797) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- packages/backend/package.json | 2 +- packages/i18n/package.json | 2 +- pnpm-lock.yaml | 18 +++++++++--------- pnpm-workspace.yaml | 2 ++ 5 files changed, 14 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index 07d2855fdb..c073a72dfd 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "esbuild": "0.28.1", "execa": "9.6.1", "ignore-walk": "9.0.0", - "js-yaml": "5.2.1", + "js-yaml": "5.2.2", "tar": "7.5.21" }, "devDependencies": { diff --git a/packages/backend/package.json b/packages/backend/package.json index 7c456daecc..1adbde0827 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -193,7 +193,7 @@ "eslint-plugin-import": "2.32.0", "execa": "9.6.1", "fkill": "10.0.3", - "js-yaml": "5.2.1", + "js-yaml": "5.2.2", "pid-port": "2.1.1", "rolldown": "1.1.5", "simple-oauth2": "5.1.0", diff --git a/packages/i18n/package.json b/packages/i18n/package.json index c5b4b9bdfc..40a4b92732 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -38,6 +38,6 @@ "tsx": "4.23.0" }, "dependencies": { - "js-yaml": "5.2.1" + "js-yaml": "5.2.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dd7afbfad5..5bff769483 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,8 +24,8 @@ importers: specifier: 9.0.0 version: 9.0.0 js-yaml: - specifier: 5.2.1 - version: 5.2.1 + specifier: 5.2.2 + version: 5.2.2 tar: specifier: 7.5.21 version: 7.5.21 @@ -592,8 +592,8 @@ importers: specifier: 10.0.3 version: 10.0.3 js-yaml: - specifier: 5.2.1 - version: 5.2.1 + specifier: 5.2.2 + version: 5.2.2 pid-port: specifier: 2.1.1 version: 2.1.1 @@ -1230,8 +1230,8 @@ importers: packages/i18n: dependencies: js-yaml: - specifier: 5.2.1 - version: 5.2.1 + specifier: 5.2.2 + version: 5.2.2 devDependencies: '@types/node': specifier: 26.1.1 @@ -6500,8 +6500,8 @@ packages: resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} hasBin: true - js-yaml@5.2.1: - resolution: {integrity: sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw==} + js-yaml@5.2.2: + resolution: {integrity: sha512-dayzUzKkJ1MkuUtZglSebU43utNXH0OWQByK9rKOOuYIO8M5TV1y+n8ALMdG0rdzBnfNkOmZEqrURepb0ejqBw==} hasBin: true jsbn@0.1.1: @@ -15042,7 +15042,7 @@ snapshots: dependencies: argparse: 2.0.1 - js-yaml@5.2.1: + js-yaml@5.2.2: dependencies: argparse: 2.0.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 45c691ac27..55e7b614ea 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -62,6 +62,8 @@ minimumReleaseAgeExclude: - "@fastify/static@10.1.2" # Renovate security update: tar@7.5.21 - tar@7.5.21 + # Renovate security update: js-yaml@5.2.2 + - js-yaml@5.2.2 overrides: '@aiscript-dev/aiscript-languageserver': '-' 'bullmq>ioredis': 5.11.1 From 982d4905c0a6406bbcf4f205169b9ecd0d662787 Mon Sep 17 00:00:00 2001 From: anatawa12 Date: Sat, 25 Jul 2026 19:48:20 +0900 Subject: [PATCH 163/168] use UPSERT instead of select for hashtag statistics update (#17795) * feat: set defaults for hashtag table * chore: use upsert to update hashtag table * fix: query runner not cleaned up * fix: query runner not cleaned up correctly * chore: replace await using => try-finally --- .../1784899839024-HashtagTableDefaults.js | 26 +++ packages/backend/src/core/HashtagService.ts | 204 ++++++++---------- packages/backend/src/models/Hashtag.ts | 6 + 3 files changed, 122 insertions(+), 114 deletions(-) create mode 100644 packages/backend/migration/1784899839024-HashtagTableDefaults.js diff --git a/packages/backend/migration/1784899839024-HashtagTableDefaults.js b/packages/backend/migration/1784899839024-HashtagTableDefaults.js new file mode 100644 index 0000000000..4b890fa849 --- /dev/null +++ b/packages/backend/migration/1784899839024-HashtagTableDefaults.js @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class HashtagTableDefaults1784899839024 { + name = 'HashtagTableDefaults1784899839024' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "mentionedUserIds" SET DEFAULT '{}'`); + await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "mentionedLocalUserIds" SET DEFAULT '{}'`); + await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "mentionedRemoteUserIds" SET DEFAULT '{}'`); + await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "attachedUserIds" SET DEFAULT '{}'`); + await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "attachedLocalUserIds" SET DEFAULT '{}'`); + await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "attachedRemoteUserIds" SET DEFAULT '{}'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "attachedRemoteUserIds" DROP DEFAULT`); + await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "attachedLocalUserIds" DROP DEFAULT`); + await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "attachedUserIds" DROP DEFAULT`); + await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "mentionedRemoteUserIds" DROP DEFAULT`); + await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "mentionedLocalUserIds" DROP DEFAULT`); + await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "mentionedUserIds" DROP DEFAULT`); + } +} diff --git a/packages/backend/src/core/HashtagService.ts b/packages/backend/src/core/HashtagService.ts index beed786d1b..750fbb06ff 100644 --- a/packages/backend/src/core/HashtagService.ts +++ b/packages/backend/src/core/HashtagService.ts @@ -16,11 +16,20 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; import { FeaturedService } from '@/core/FeaturedService.js'; import { UtilityService } from '@/core/UtilityService.js'; -import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; import Logger from '../logger.js'; const logger = new Logger('hashtag/create'); +type AttachedOrMentioned = 'attached' | 'mentioned'; +type UpdatingHashtagColumn = { + totalUserIds: keyof MiHashtag & `${AttachedOrMentioned}UserIds`, + totalUsersCount: keyof MiHashtag & `${AttachedOrMentioned}UsersCount`, + localUserIds: keyof MiHashtag & `${AttachedOrMentioned}LocalUserIds`, + localUsersCount: keyof MiHashtag & `${AttachedOrMentioned}LocalUsersCount`, + remoteUserIds: keyof MiHashtag & `${AttachedOrMentioned}RemoteUserIds`, + remoteUsersCount: keyof MiHashtag & `${AttachedOrMentioned}RemoteUsersCount`, +}; + @Injectable() export class HashtagService { constructor( @@ -68,126 +77,93 @@ export class HashtagService { // TODO: サンプリング this.updateHashtagsRanking(tag, user.id); - { - const index = await this.hashtagsRepository.findOneBy({ name: tag }); + const column: UpdatingHashtagColumn = isUserAttached ? { + totalUserIds: 'attachedUserIds', + totalUsersCount: 'attachedUsersCount', + localUserIds: 'attachedLocalUserIds', + localUsersCount: 'attachedLocalUsersCount', + remoteUserIds: 'attachedRemoteUserIds', + remoteUsersCount: 'attachedRemoteUsersCount', + } : { + totalUserIds: 'mentionedUserIds', + totalUsersCount: 'mentionedUsersCount', + localUserIds: 'mentionedLocalUserIds', + localUsersCount: 'mentionedLocalUsersCount', + remoteUserIds: 'mentionedRemoteUserIds', + remoteUsersCount: 'mentionedRemoteUsersCount', + }; - if (index == null && inc) { - try { - if (isUserAttached) { - await this.hashtagsRepository.insert({ - id: this.idService.gen(), - name: tag, - mentionedUserIds: [], - mentionedUsersCount: 0, - mentionedLocalUserIds: [], - mentionedLocalUsersCount: 0, - mentionedRemoteUserIds: [], - mentionedRemoteUsersCount: 0, - attachedUserIds: [user.id], - attachedUsersCount: 1, - attachedLocalUserIds: this.userEntityService.isLocalUser(user) ? [user.id] : [], - attachedLocalUsersCount: this.userEntityService.isLocalUser(user) ? 1 : 0, - attachedRemoteUserIds: this.userEntityService.isRemoteUser(user) ? [user.id] : [], - attachedRemoteUsersCount: this.userEntityService.isRemoteUser(user) ? 1 : 0, - } as MiHashtag); - } else { - await this.hashtagsRepository.insert({ - id: this.idService.gen(), - name: tag, - mentionedUserIds: [user.id], - mentionedUsersCount: 1, - mentionedLocalUserIds: this.userEntityService.isLocalUser(user) ? [user.id] : [], - mentionedLocalUsersCount: this.userEntityService.isLocalUser(user) ? 1 : 0, - mentionedRemoteUserIds: this.userEntityService.isRemoteUser(user) ? [user.id] : [], - mentionedRemoteUsersCount: this.userEntityService.isRemoteUser(user) ? 1 : 0, - attachedUserIds: [], - attachedUsersCount: 0, - attachedLocalUserIds: [], - attachedLocalUsersCount: 0, - attachedRemoteUserIds: [], - attachedRemoteUsersCount: 0, - } as MiHashtag); - } - return; - } catch (err) { - if (isDuplicateKeyValueError(err)) { - logger.info(`Duplicate insertion detected. Falling back to update. #${tag}`); - } else { - throw err; - } - } - } + if (inc) { + await this.#incrementHashTag(user, tag, column); + } else { + await this.#decrementHashTag(user, tag, column); + } + } + + async #incrementHashTag( + user: { id: MiUser['id']; host: MiUser['host']; }, + tag: string, + columns: UpdatingHashtagColumn, + ) { + const isLocal = this.userEntityService.isLocalUser(user); + const { totalUserIds, totalUsersCount } = columns; + const localOrRemoteUserIds = isLocal ? columns.localUserIds : columns.remoteUserIds; + const localOrRemoteUserCount = isLocal ? columns.localUsersCount : columns.remoteUsersCount; + + const runner = this.db.createQueryRunner('master'); + try { + await runner.query( + `INSERT into "hashtag"("id", "name", "${totalUserIds}", "${totalUsersCount}", "${localOrRemoteUserIds}", + "${localOrRemoteUserCount}") + VALUES ($3, $1, ARRAY [$2], 1, ARRAY [$2], 1) + ON CONFLICT ("name") + DO UPDATE SET "${totalUserIds}" = ${appendUserIdIfNotExists(totalUserIds)}, + "${totalUsersCount}" = ${incrementCountIfNotExists(totalUserIds, totalUsersCount)}, + "${localOrRemoteUserIds}" = ${appendUserIdIfNotExists(localOrRemoteUserIds)}, + "${localOrRemoteUserCount}" = ${incrementCountIfNotExists(localOrRemoteUserIds, localOrRemoteUserCount)}`, + [tag, user.id, this.idService.gen()], + ); + } finally { + await runner.release(); } - await this.db.transaction(async transactionalEntityManager => { - const transactionalHashtagRepository = transactionalEntityManager - .getRepository(MiHashtag); + function appendUserIdIfNotExists(userIds: keyof MiHashtag & `${string}UserIds`): string { + return `CASE WHEN NOT ("hashtag"."${userIds}" @> ARRAY[$2 ::varchar]) THEN array_append("hashtag"."${userIds}", $2) ELSE "hashtag"."${userIds}" END`; + } - const index = await transactionalHashtagRepository - .createQueryBuilder() - .setLock('pessimistic_write') - .where('name = :name', { name: tag }) - .getOne(); + function incrementCountIfNotExists(userIds: keyof MiHashtag & `${string}UserIds`, userCount: keyof MiHashtag & `${string}UsersCount`): string { + return `CASE WHEN NOT ("hashtag"."${userIds}" @> ARRAY[$2 ::varchar]) THEN "hashtag"."${userCount}" + 1 ELSE "hashtag"."${userCount}" END`; + } + } - if (index == null) return; + async #decrementHashTag( + user: { id: MiUser['id']; host: MiUser['host']; }, + tag: string, + columns: UpdatingHashtagColumn, + ) { + const isLocal = this.userEntityService.isLocalUser(user); + const { totalUserIds, totalUsersCount } = columns; + const localOrRemoteUserIds = isLocal ? columns.localUserIds : columns.remoteUserIds; + const localOrRemoteUserCount = isLocal ? columns.localUsersCount : columns.remoteUsersCount; - const set = {} as any; + const runner = this.db.createQueryRunner('master'); + try { + await runner.query( + `UPDATE "hashtag" + SET "${totalUserIds}" = array_remove("${totalUserIds}", $2), + "${totalUsersCount}" = ${decrementIfExists(totalUserIds, totalUsersCount)}, + "${localOrRemoteUserIds}" = array_remove("${localOrRemoteUserIds}", $2), + "${localOrRemoteUserCount}" = ${decrementIfExists(localOrRemoteUserIds, localOrRemoteUserCount)} + WHERE "name" = $1`, + [tag, user.id], + ); + } finally { + await runner.release(); + } - if (isUserAttached) { - if (inc) { - // 自分が初めてこのタグを使ったなら - if (!index.attachedUserIds.some(id => id === user.id)) { - set.attachedUserIds = () => `array_append("attachedUserIds", '${user.id}')`; - set.attachedUsersCount = () => '"attachedUsersCount" + 1'; - } - // 自分が(ローカル内で)初めてこのタグを使ったなら - if (this.userEntityService.isLocalUser(user) && !index.attachedLocalUserIds.some(id => id === user.id)) { - set.attachedLocalUserIds = () => `array_append("attachedLocalUserIds", '${user.id}')`; - set.attachedLocalUsersCount = () => '"attachedLocalUsersCount" + 1'; - } - // 自分が(リモートで)初めてこのタグを使ったなら - if (this.userEntityService.isRemoteUser(user) && !index.attachedRemoteUserIds.some(id => id === user.id)) { - set.attachedRemoteUserIds = () => `array_append("attachedRemoteUserIds", '${user.id}')`; - set.attachedRemoteUsersCount = () => '"attachedRemoteUsersCount" + 1'; - } - } else { - set.attachedUserIds = () => `array_remove("attachedUserIds", '${user.id}')`; - set.attachedUsersCount = () => '"attachedUsersCount" - 1'; - if (this.userEntityService.isLocalUser(user)) { - set.attachedLocalUserIds = () => `array_remove("attachedLocalUserIds", '${user.id}')`; - set.attachedLocalUsersCount = () => '"attachedLocalUsersCount" - 1'; - } else { - set.attachedRemoteUserIds = () => `array_remove("attachedRemoteUserIds", '${user.id}')`; - set.attachedRemoteUsersCount = () => '"attachedRemoteUsersCount" - 1'; - } - } - } else { - // 自分が初めてこのタグを使ったなら - if (!index.mentionedUserIds.some(id => id === user.id)) { - set.mentionedUserIds = () => `array_append("mentionedUserIds", '${user.id}')`; - set.mentionedUsersCount = () => '"mentionedUsersCount" + 1'; - } - // 自分が(ローカル内で)初めてこのタグを使ったなら - if (this.userEntityService.isLocalUser(user) && !index.mentionedLocalUserIds.some(id => id === user.id)) { - set.mentionedLocalUserIds = () => `array_append("mentionedLocalUserIds", '${user.id}')`; - set.mentionedLocalUsersCount = () => '"mentionedLocalUsersCount" + 1'; - } - // 自分が(リモートで)初めてこのタグを使ったなら - if (this.userEntityService.isRemoteUser(user) && !index.mentionedRemoteUserIds.some(id => id === user.id)) { - set.mentionedRemoteUserIds = () => `array_append("mentionedRemoteUserIds", '${user.id}')`; - set.mentionedRemoteUsersCount = () => '"mentionedRemoteUsersCount" + 1'; - } - } - - if (Object.keys(set).length > 0) { - await transactionalHashtagRepository - .createQueryBuilder() - .update() - .where('id = :id', { id: index.id }) - .set(set) - .execute(); - } - }); + function decrementIfExists(userIds: keyof MiHashtag & `${string}UserIds`, userCount: keyof MiHashtag & `${string}UsersCount`): string { + return `CASE WHEN ("${userIds}" @> ARRAY[$2]) THEN "${userCount}" - 1 ELSE "${userCount}" END`; + } } @bindThis diff --git a/packages/backend/src/models/Hashtag.ts b/packages/backend/src/models/Hashtag.ts index 3add06d0c3..4301348957 100644 --- a/packages/backend/src/models/Hashtag.ts +++ b/packages/backend/src/models/Hashtag.ts @@ -20,6 +20,7 @@ export class MiHashtag { @Column({ ...id(), + default: [], array: true, }) public mentionedUserIds: MiUser['id'][]; @@ -32,6 +33,7 @@ export class MiHashtag { @Column({ ...id(), + default: [], array: true, }) public mentionedLocalUserIds: MiUser['id'][]; @@ -44,6 +46,7 @@ export class MiHashtag { @Column({ ...id(), + default: [], array: true, }) public mentionedRemoteUserIds: MiUser['id'][]; @@ -56,6 +59,7 @@ export class MiHashtag { @Column({ ...id(), + default: [], array: true, }) public attachedUserIds: MiUser['id'][]; @@ -68,6 +72,7 @@ export class MiHashtag { @Column({ ...id(), + default: [], array: true, }) public attachedLocalUserIds: MiUser['id'][]; @@ -80,6 +85,7 @@ export class MiHashtag { @Column({ ...id(), + default: [], array: true, }) public attachedRemoteUserIds: MiUser['id'][]; From 3e31e59bcd6f084d5e1253fad009323d0098eb58 Mon Sep 17 00:00:00 2001 From: 4ster1sk <146138447+4ster1sk@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:09:12 +0900 Subject: [PATCH 164/168] =?UTF-8?q?fix(backend):=20=E3=83=95=E3=82=A9?= =?UTF-8?q?=E3=83=AD=E3=83=BC=E4=B8=AD=E3=81=AE=E3=83=81=E3=83=A3=E3=83=B3?= =?UTF-8?q?=E3=83=8D=E3=83=AB=E3=82=92=E5=86=8D=E5=BA=A6=E3=83=95=E3=82=A9?= =?UTF-8?q?=E3=83=AD=E3=83=BC=E3=81=97=E3=81=9F=E9=9A=9B=E3=81=ABALREADY?= =?UTF-8?q?=5FFOLLOWING=E3=82=A8=E3=83=A9=E3=83=BC=E3=82=92=E8=BF=94?= =?UTF-8?q?=E3=81=99=E3=82=88=E3=81=86=E3=81=AB=20(#17802)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(e2e): フォロー中のチャンネルに再度フォローAPIを叩いた場合のテストを追加 * fix(backend): フォロー中のチャンネルを再度フォローした際にALREADY_FOLLOWINGエラーを返すように * docs(changelog): update changelog --- CHANGELOG.md | 1 + .../src/core/ChannelFollowingService.ts | 19 +++++++--- .../server/api/endpoints/channels/follow.ts | 15 +++++++- packages/backend/test/e2e/channel.ts | 36 +++++++++++++++++++ 4 files changed, 65 insertions(+), 6 deletions(-) create mode 100644 packages/backend/test/e2e/channel.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bcf30b603..aaf2234cc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,7 @@ - Fix: フォロワー限定投稿へのリプライをホーム投稿に出来る問題を修正 - Fix: ファイルをアップロードするAPIにて、処理終了後に一時ファイルが削除されないことがある問題を修正 - Fix: 初期設定で作成したアカウント以外でアカウント作成APIが使用できない問題を修正 +- Fix: フォロー中のチャンネルを再度フォローした際にALREADY_FOLLOWINGエラーを返すように ## 2026.6.0 diff --git a/packages/backend/src/core/ChannelFollowingService.ts b/packages/backend/src/core/ChannelFollowingService.ts index 7dae8d10f3..54198fdc06 100644 --- a/packages/backend/src/core/ChannelFollowingService.ts +++ b/packages/backend/src/core/ChannelFollowingService.ts @@ -13,6 +13,8 @@ import { GlobalEvents, GlobalEventService } from '@/core/GlobalEventService.js'; import { bindThis } from '@/decorators.js'; import type { MiLocalUser } from '@/models/User.js'; import { RedisKVCache } from '@/misc/cache.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; @Injectable() export class ChannelFollowingService implements OnModuleInit { @@ -96,11 +98,18 @@ export class ChannelFollowingService implements OnModuleInit { requestUser: MiLocalUser, targetChannel: MiChannel, ): Promise { - await this.channelFollowingsRepository.insert({ - id: this.idService.gen(), - followerId: requestUser.id, - followeeId: targetChannel.id, - }); + try { + await this.channelFollowingsRepository.insert({ + id: this.idService.gen(), + followerId: requestUser.id, + followeeId: targetChannel.id, + }); + } catch (e) { + if (isDuplicateKeyValueError(e)) { + throw new IdentifiableError('6e335e39-0203-4418-a936-b3f2dc987845', 'already following'); + } + throw e; + } this.globalEventService.publishInternalEvent('followChannel', { userId: requestUser.id, diff --git a/packages/backend/src/server/api/endpoints/channels/follow.ts b/packages/backend/src/server/api/endpoints/channels/follow.ts index 1812820ba2..b46552281f 100644 --- a/packages/backend/src/server/api/endpoints/channels/follow.ts +++ b/packages/backend/src/server/api/endpoints/channels/follow.ts @@ -8,6 +8,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import type { ChannelsRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { ChannelFollowingService } from '@/core/ChannelFollowingService.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -25,6 +26,11 @@ export const meta = { code: 'NO_SUCH_CHANNEL', id: 'c0031718-d573-4e85-928e-10039f1fbb68', }, + alreadyFollowing: { + message: 'You are already following that channel.', + code: 'ALREADY_FOLLOWING', + id: '7db31665-651e-40c1-8e6e-28e9ad829a2d', + }, }, } as const; @@ -52,7 +58,14 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.noSuchChannel); } - await this.channelFollowingService.follow(me, channel); + try { + await this.channelFollowingService.follow(me, channel); + } catch (e) { + if (e instanceof IdentifiableError) { + if (e.id === '6e335e39-0203-4418-a936-b3f2dc987845') throw new ApiError(meta.errors.alreadyFollowing); + } + throw e; + } }); } } diff --git a/packages/backend/test/e2e/channel.ts b/packages/backend/test/e2e/channel.ts new file mode 100644 index 0000000000..c2fd98cbf4 --- /dev/null +++ b/packages/backend/test/e2e/channel.ts @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +process.env.NODE_ENV = 'test'; + +import * as assert from 'assert'; +import { describe, beforeAll, test } from 'vitest'; +import { api, castAsError, signup } from '../utils.js'; +import type * as misskey from 'misskey-js'; + +describe('Channel', () => { + let alice: misskey.entities.SignupResponse; + beforeAll(async () => { + alice = await signup({ username: 'alice' }); + }); + + describe('Follow', () => { + let channel: misskey.entities.ChannelsCreateResponse; + + beforeAll(async () => { + const res = await api('channels/create', { name: 'follow-test-channel' }, alice); + channel = res.body; + }); + + test('フォローしているチャンネルを再度フォローするとALREADY_FOLLOWINGエラーになる', async () => { + const res1 = await api('channels/follow', { channelId: channel.id }, alice); + assert.strictEqual(res1.status, 204); + + const res2 = await api('channels/follow', { channelId: channel.id }, alice); + assert.strictEqual(res2.status, 400); + assert.strictEqual(castAsError(res2.body as any).error.code, 'ALREADY_FOLLOWING'); + }); + }); +}); From 1a59ec20e3a917b23d12663e49329ecce1ea8e88 Mon Sep 17 00:00:00 2001 From: chocolate-pie <106949016+chocolate-pie@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:34:46 +0900 Subject: [PATCH 165/168] Merge commit from fork MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Fix improper authorization in `admin/reset-password` * Apply suggestions from code review Co-authored-by: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com> * fix: improper authorization in `admin/unset-mfa` * fix --------- Co-authored-by: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com> --- .../server/api/endpoints/admin/reset-password.ts | 15 +++++++++------ .../src/server/api/endpoints/admin/unset-mfa.ts | 11 +++++++++++ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/packages/backend/src/server/api/endpoints/admin/reset-password.ts b/packages/backend/src/server/api/endpoints/admin/reset-password.ts index f19ffa173b..dc86768044 100644 --- a/packages/backend/src/server/api/endpoints/admin/reset-password.ts +++ b/packages/backend/src/server/api/endpoints/admin/reset-password.ts @@ -9,7 +9,9 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import { ApiError } from '@/server/api/error.js'; import type { UsersRepository, UserProfilesRepository, MiMeta } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; +import { ApiError } from '@/server/api/error.js'; import { secureRndstr } from '@/misc/secure-rndstr.js'; +import { RoleService } from '@/core/RoleService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; export const meta = { @@ -25,10 +27,10 @@ export const meta = { code: 'NO_SUCH_USER', id: 'ccafc7fe-5074-4edd-9dc0-8ef9ef6a701d', }, - cannotResetPasswordOfRootUser: { - message: 'Cannot reset password of the root user.', - code: 'CANNOT_RESET_PASSWORD_OF_ROOT_USER', - id: 'f28fc207-42ca-44c7-a577-44b4f0ec5999', + accessDenied: { + message: 'Access denied.', + code: 'ACCESS_DENIED', + id: 'cda8f8ce-89a6-4f92-8055-33bbe0c1464d', }, }, @@ -66,6 +68,7 @@ export default class extends Endpoint { // eslint- @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, + private roleService: RoleService, private moderationLogService: ModerationLogService, ) { super(meta, paramDef, async (ps, me) => { @@ -75,8 +78,8 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.noSuchUser); } - if (this.serverSettings.rootUserId === user.id) { - throw new ApiError(meta.errors.cannotResetPasswordOfRootUser); + if (await this.roleService.isAdministrator(user) && me.id !== user.id) { + throw new ApiError(meta.errors.accessDenied); } const passwd = secureRndstr(8); diff --git a/packages/backend/src/server/api/endpoints/admin/unset-mfa.ts b/packages/backend/src/server/api/endpoints/admin/unset-mfa.ts index 3d964825d3..75b3e3e2bf 100644 --- a/packages/backend/src/server/api/endpoints/admin/unset-mfa.ts +++ b/packages/backend/src/server/api/endpoints/admin/unset-mfa.ts @@ -11,6 +11,7 @@ import { MiUserProfile } from '@/models/UserProfile.js'; import { MiUserSecurityKey } from '@/models/UserSecurityKey.js'; import type { UsersRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; +import { RoleService } from '@/core/RoleService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; export const meta = { @@ -26,6 +27,11 @@ export const meta = { code: 'NO_SUCH_USER', id: 'ccafc7fe-5074-4edd-9dc0-8ef9ef6a701d', }, + accessDenied: { + message: 'Access denied.', + code: 'ACCESS_DENIED', + id: 'cda8f8ce-89a6-4f92-8055-33bbe0c1464d', + }, }, } as const; @@ -46,6 +52,7 @@ export default class extends Endpoint { // eslint- @Inject(DI.usersRepository) private usersRepository: UsersRepository, + private roleService: RoleService, private moderationLogService: ModerationLogService, ) { super(meta, paramDef, async (ps, me) => { @@ -55,6 +62,10 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.noSuchUser); } + if (await this.roleService.isAdministrator(user) && me.id !== user.id) { + throw new ApiError(meta.errors.accessDenied); + } + await this.db.transaction(async (transactionalEntityManager) => { // パスキーを全て削除 await transactionalEntityManager.delete(MiUserSecurityKey, { userId: user.id }); From daf2a855390949e489e285ba23a926a243b2eaac Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:37:20 +0900 Subject: [PATCH 166/168] Update CHANGELOG.md --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index aaf2234cc5..1cb45c9b43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - Feat: コントロールパネルから二要素認証を解除できるように - Feat: 条件に一致したURLプレビューのサムネイルを隠すことができるように (Based on https://github.com/MisskeyIO/misskey/pull/214) +- Enhance: 依存関係の更新 ### Client - 2025.4.0 以前の設定情報の移行処理が削除されました @@ -31,6 +32,7 @@ - Fix: 一部の画像をビューワーで読み込んだ際に正しく表示されない問題を修正 - Enhance: ファイルアップロード前にプレビューできるように - Enhance: タブがバックグラウンドの間は必要ない定期更新処理を停止するように +- Enhance: 翻訳の更新 - Fix: 「画像を新しいタブで開く」が機能しなくなっていた問題を修正 - Fix: デバイスタイプをスマートフォンに固定している状態で画面幅が広いとき、画面左上のアイコンが表示されない問題を修正 - Fix: チャットでIMEの変換を確定するEnterでメッセージが送信されてしまうことがある問題を修正 @@ -72,6 +74,7 @@ - Fix: ファイルをアップロードするAPIにて、処理終了後に一時ファイルが削除されないことがある問題を修正 - Fix: 初期設定で作成したアカウント以外でアカウント作成APIが使用できない問題を修正 - Fix: フォロー中のチャンネルを再度フォローした際にALREADY_FOLLOWINGエラーを返すように +- Fix: セキュリティに関する修正 ## 2026.6.0 From e897255e71dd863fdeb88c01db361669061e9b20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=8B=E3=81=A3=E3=81=93=E3=81=8B=E3=82=8A?= <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:43:41 +0900 Subject: [PATCH 167/168] fix --- .../backend/src/server/api/endpoints/admin/reset-password.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/backend/src/server/api/endpoints/admin/reset-password.ts b/packages/backend/src/server/api/endpoints/admin/reset-password.ts index dc86768044..61aed00077 100644 --- a/packages/backend/src/server/api/endpoints/admin/reset-password.ts +++ b/packages/backend/src/server/api/endpoints/admin/reset-password.ts @@ -9,7 +9,6 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import { ApiError } from '@/server/api/error.js'; import type { UsersRepository, UserProfilesRepository, MiMeta } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; -import { ApiError } from '@/server/api/error.js'; import { secureRndstr } from '@/misc/secure-rndstr.js'; import { RoleService } from '@/core/RoleService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; From f6979333d40cf3c7df98c72207dea2696be51670 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=8B=E3=81=A3=E3=81=93=E3=81=8B=E3=82=8A?= <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Mon, 27 Jul 2026 00:14:34 +0900 Subject: [PATCH 168/168] docs: Update CHANGELOG.md to include about js-yaml changes (#17805) --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cb45c9b43..229e00d2b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ - Node.js のセキュリティアップデートに伴い、最低動作バージョンを 22.22.2 / 24.17.0 / 26.4.0 に引き上げました。 - Docker Image は Node.js 26.4.0-trixie に更新されています。 - バックエンドで画像処理に用いているライブラリ sharp のシステム要件の変更により、**SSE4.2 命令セットをサポートしていない x86_64 CPU では Misskey が正しく動作しなくなります**。仮想マシンに Misskey をデプロイしている場合や、古いハードウェアをお使いの場合は、アップデート前にお使いの環境をご確認ください。なお、ARM64 など x86_64 ではない環境においてはこの変更による影響はありません。 +- YAMLパーサーをアップデートし、より厳格なチェックが行われるようになりました。起動時にconfigの読み取りで構文エラーが発生する可能性があります。\ + 例えば `allowPrivateNetworks` を 2026.6.0 までの `example.yml` の構文を元に記述している場合は、配列の閉じ括弧のインデントを上げるか、リスト表示に書き換える必要があります。詳しくは https://github.com/misskey-dev/misskey/pull/17701 をご覧ください。 ### General - Feat: コントロールパネルから二要素認証を解除できるように