mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-07-19 23:04:43 +02:00
Compare commits
126 Commits
2026.6.1-a
...
l10n_devel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f1479b2e6 | ||
|
|
000e2d5cf2 | ||
|
|
fe178b42ff | ||
|
|
89bfb498f9 | ||
|
|
3446799723 | ||
|
|
f8bd62b63e | ||
|
|
462e346ae9 | ||
|
|
7b4ee6f922 | ||
|
|
e509eb5518 | ||
|
|
cd17eccc04 | ||
|
|
9ff1bb227c | ||
|
|
0be4d352ec | ||
|
|
8691babd56 | ||
|
|
b5f0e96bf1 | ||
|
|
5a6b9439b6 | ||
|
|
3de6b610b5 | ||
|
|
3760e7a9f8 | ||
|
|
88c6cd1766 | ||
|
|
0272ee73eb | ||
|
|
ab21fc725b | ||
|
|
5d0ecf976d | ||
|
|
ceb31f16c8 | ||
|
|
b028d4ad5b | ||
|
|
68193dd38c | ||
|
|
203a6b3792 | ||
|
|
2874a6d5ba | ||
|
|
8aff44ae2e | ||
|
|
153875e1d9 | ||
|
|
fd83c9cb64 | ||
|
|
64f98faca9 | ||
|
|
ab92f98472 | ||
|
|
77c2f54fa5 | ||
|
|
1a04d4c2d9 | ||
|
|
12be1fe5b5 | ||
|
|
93bbc89c7a | ||
|
|
875554003e | ||
|
|
828a24f6df | ||
|
|
ac8e8c4892 | ||
|
|
b5e470b6d4 | ||
|
|
212fcaf254 | ||
|
|
22cdc24b04 | ||
|
|
df063d9386 | ||
|
|
3c1f01af10 | ||
|
|
0f3210946d | ||
|
|
61b8332988 | ||
|
|
8439080916 | ||
|
|
112263135e | ||
|
|
2199f9040d | ||
|
|
1e4260cfb1 | ||
|
|
a7fd58e4a5 | ||
|
|
fb483d8367 | ||
|
|
ef852e6ab0 | ||
|
|
0ad72528da | ||
|
|
11c6fb1567 | ||
|
|
afca066833 | ||
|
|
945fd40578 | ||
|
|
b6996a2766 | ||
|
|
9076a4ecf3 | ||
|
|
d74a7a515c | ||
|
|
ce411817b4 | ||
|
|
0849dc7cdd | ||
|
|
ed70123ace | ||
|
|
1110d334c8 | ||
|
|
667919581e | ||
|
|
152e0fc48c | ||
|
|
e3cb641c98 | ||
|
|
4d7e62d677 | ||
|
|
ecd85e5613 | ||
|
|
91ea966b04 | ||
|
|
3d4c62b0bb | ||
|
|
a98469d34d | ||
|
|
5a2acf71a0 | ||
|
|
49e59a3d56 | ||
|
|
ecd1d615ff | ||
|
|
53c398ce43 | ||
|
|
f74b427e67 | ||
|
|
aab7903ab0 | ||
|
|
6ceda0f004 | ||
|
|
df5c491f4c | ||
|
|
15c187ffa2 | ||
|
|
b4abbe432e | ||
|
|
2335eb7b7f | ||
|
|
059fbaa92b | ||
|
|
76ab562810 | ||
|
|
9472e72678 | ||
|
|
9440423a73 | ||
|
|
b2867eb0d7 | ||
|
|
5590f8bad9 | ||
|
|
c15f93e48d | ||
|
|
03d224acd0 | ||
|
|
c24ce9b55a | ||
|
|
7243bd34b9 | ||
|
|
d4dd02c744 | ||
|
|
3817f00803 | ||
|
|
f066c1c2ef | ||
|
|
1d41cb3d29 | ||
|
|
1858d56918 | ||
|
|
97082efd99 | ||
|
|
187610d516 | ||
|
|
00b2cb2076 | ||
|
|
426f6748e5 | ||
|
|
c423bf92a8 | ||
|
|
f59bf35615 | ||
|
|
0f056c4955 | ||
|
|
eb2c7ff6c6 | ||
|
|
9f614517c0 | ||
|
|
5432984af8 | ||
|
|
c29a3d902b | ||
|
|
721b1b06a0 | ||
|
|
97e54a1ee8 | ||
|
|
96d6a09ebc | ||
|
|
1eedf04d9a | ||
|
|
2d67380f2a | ||
|
|
174fb434cc | ||
|
|
7e29f04287 | ||
|
|
bf88122140 | ||
|
|
4daa1ffe05 | ||
|
|
62f8589c05 | ||
|
|
6193c35f9f | ||
|
|
1220f05903 | ||
|
|
7544ebf7a3 | ||
|
|
ffe65caf10 | ||
|
|
4f993cef1b | ||
|
|
ba3fb4aa14 | ||
|
|
0137b1c406 | ||
|
|
797dec7d0e |
@@ -35,7 +35,7 @@ Misskey リポジトリの `.claude/` 構成を 7 カテゴリで採点し、改
|
||||
| 2 | Context Efficiency | frontmatter description の冗長度、SKILL.md の長さ分布、重複情報、CLAUDE.md の肥大化 |
|
||||
| 3 | Quality Gates | Stop / PreToolUse / PostToolUse hook の整備、`/quality-gate` 等の完了前ゲートの有無、自動 lint/typecheck |
|
||||
| 4 | Memory Persistence | `.claude/skills/*/SKILL.md` と `references/` の同期状態を評価。プロジェクト側 `.claude/memory/` は未採用方針 (auto-memory はユーザーホーム側で自動運用) のため、ここを採点起点にせず既定 5/10 から開始する |
|
||||
| 5 | Eval Coverage | `working-on-backend` / `working-on-frontend` の testing リファレンス (backend-testing.md / frontend-testing.md) の網羅、Misskey 固有の e2e/fed/Storybook/Cypress 適用ガイド |
|
||||
| 5 | Eval Coverage | `working-on-backend` / `working-on-frontend` の testing リファレンス (backend-testing.md / frontend-testing.md) の網羅、Misskey 固有の e2e/fed/Storybook/Playwright 適用ガイド |
|
||||
| 6 | Security Guardrails | SPDX 規約適用、migration 不変性ルール、ja-JP.yml 限定編集ルール、secrets 検出 |
|
||||
| 7 | Cost Efficiency | enabledPlugins の重複・過剰、context-budget の整備、MCP 過剰登録なし |
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ note: 元 ECC 版は言語自動判定 + format/lint/type のジェネリック
|
||||
|
||||
`/quality-gate [scope]`
|
||||
|
||||
完了前の **軽量** 品質チェック。重い E2E / 連合テスト (test:e2e / test:fed / Cypress) は CI 側で実行されるため、本コマンドには含めない。
|
||||
完了前の **軽量** 品質チェック。重い E2E / 連合テスト (test:e2e / test:fed / Playwright) は CI 側で実行されるため、本コマンドには含めない。
|
||||
|
||||
## Scope
|
||||
|
||||
@@ -120,4 +120,4 @@ Frontend ut: PASS (87/87)
|
||||
|
||||
- ジェネリックな言語自動判定を排除し、Misskey 固定 pipeline に。
|
||||
- formatter フェーズなし (Misskey は ESLint --fix のみ採用)。
|
||||
- e2e / federation / Cypress は重いため除外し CI 側に委譲。
|
||||
- e2e / federation / Playwright は重いため除外し CI 側に委譲。
|
||||
|
||||
@@ -26,7 +26,7 @@ cp .github/misskey/test.yml .config/test.yml
|
||||
|
||||
補足:
|
||||
|
||||
- ルートの `pnpm start:test` (Cypress 用にテストサーバーを起動するコマンド) を使う経路では実行時に `ncp` で自動コピーされる ([package.json](../../../../../package.json))。それ以外で backend テストを直接走らせる時は上記の手動コピーが必要
|
||||
- ルートの `pnpm start:test` (Playwright 用にテストサーバーを起動するコマンド) を使う経路では実行時に `ncp` で自動コピーされる ([package.json](../../../../../package.json))。それ以外で backend テストを直接走らせる時は上記の手動コピーが必要
|
||||
- すでに `.config/test.yml` があれば各テストスクリプトの内部 `compile-config` で十分なので、追加で `pnpm --filter backend compile-config` を叩く必要はない
|
||||
- `pnpm start:test` は backend e2e テスト (`pnpm --filter backend test:e2e`) の前提ではない (ポート競合の元になるため使わないこと)
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ SKILL.md 本体は references への索引だけ。具体的な手順や規約
|
||||
- SCSS Modules / `--MI_THEME-*` `--MI-*` CSS 変数 / グローバル utility class (`_button` 等) → [references/knowledge/scss-modules.md](references/knowledge/scss-modules.md)
|
||||
- `os.alert` / `os.confirm` / `os.popup` 等 UI ヘルパー (ブラウザ標準 `alert()` 直呼びは禁止) → [references/knowledge/os-api.md](references/knowledge/os-api.md)
|
||||
- `*.stories.impl.ts` 併設規則 + 複数 story / argTypes / layout / action パターン → [references/knowledge/storybook.md](references/knowledge/storybook.md)
|
||||
- frontend Vitest / Cypress E2E の書き方と前提 → [references/knowledge/frontend-testing.md](references/knowledge/frontend-testing.md)
|
||||
- frontend Vitest / Playwright E2E の書き方と前提 → [references/knowledge/frontend-testing.md](references/knowledge/frontend-testing.md)
|
||||
|
||||
## 必ず最後に通る場所
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Frontend テスト (Vitest / Cypress)
|
||||
# Frontend テスト (Vitest / Playwright)
|
||||
|
||||
Misskey frontend のテスト構成。
|
||||
|
||||
@@ -13,11 +13,11 @@ pnpm --filter frontend test-and-coverage # カバレッジ付き
|
||||
|
||||
- 主な配置: `packages/frontend/test/*.test.ts` (例: `i18n.test.ts`, `theme.test.ts`, `is-birthday.test.ts`)
|
||||
- ビルドツール周りなど対象コードと隣接させた方が分かりやすいテストは、コードと同じディレクトリに `*.test.ts` として置く (例: [packages/frontend/lib/rollup-plugin-unwind-css-module-class-name.test.ts](../../../../../packages/frontend/lib/rollup-plugin-unwind-css-module-class-name.test.ts))
|
||||
- 共有コンポーネント (`MkX.vue`) のユニットテストは現状少なく、`*.spec.ts` / `__tests__/` 形式は採用していない (Storybook + Cypress でカバー)
|
||||
- 共有コンポーネント (`MkX.vue`) のユニットテストは現状少なく、`*.spec.ts` / `__tests__/` 形式は採用していない (Storybook + Playwright でカバー)
|
||||
|
||||
## Cypress E2E
|
||||
## Playwright E2E
|
||||
|
||||
Cypress は **起動済みのテストサーバー** に対して走るため、unit より前提が多い。[.github/workflows/test-frontend.yml](../../../../../.github/workflows/test-frontend.yml) の `e2e` ジョブと同じ手順をローカルで踏む:
|
||||
Playwright は **起動済みのテストサーバー** に対して走るため、unit より前提が多い。[.github/workflows/test-frontend.yml](../../../../../.github/workflows/test-frontend.yml) の `e2e` ジョブと同じ手順をローカルで踏む:
|
||||
|
||||
```bash
|
||||
# 1. テスト用 DB / Redis を起動 (テスト用ポート。開発用の compose.local-db.yml ではない)
|
||||
@@ -29,15 +29,14 @@ cp .github/misskey/test.yml .config/test.yml
|
||||
# 3. 全体ビルド
|
||||
pnpm build
|
||||
|
||||
# 4. テストサーバー起動 + Cypress 実行 (いずれもルートから)
|
||||
pnpm e2e # 内部で pnpm start:test を起動し http://localhost:61812 を待って Cypress run
|
||||
pnpm cy:open # 対話的に開く (サーバーは別途 pnpm start:test で起動しておく)
|
||||
# 4. テストサーバー起動 + Playwright 実行 (いずれもルートから)
|
||||
pnpm e2e # 内部で pnpm start:test を起動し http://localhost:61812 を待って Playwright を実行
|
||||
```
|
||||
|
||||
- 設定: ルート [cypress.config.ts](../../../../../cypress.config.ts)
|
||||
- テスト本体は [cypress/](../../../../../cypress/) 配下
|
||||
- 設定: ルート [packages/frontend/playwright.config.ts](../../../../../packages/frontend/playwright.config.ts) で、`packages/frontend/test/e2e/` 配下の `*.spec.ts` を対象にする
|
||||
- テスト本体は [packages/frontend/test/e2e/](../../../../../packages/frontend/test/e2e/) 配下に配置する。
|
||||
|
||||
新規 frontend 機能の E2E は Cypress に書くのが基本。ただし対象は主要 UI フロー (login / post / drive etc) に限定し、細かい単位テストは Vitest または Storybook で代替する慣習。
|
||||
新規 frontend 機能の E2E は Playwright に書くのが基本。ただし対象は主要 UI フロー (login / post / drive etc) に限定し、細かい単位テストは Vitest または Storybook で代替する慣習。
|
||||
|
||||
## Storybook (視覚確認 + Chromatic 視覚回帰)
|
||||
|
||||
@@ -55,6 +54,6 @@ pnpm --filter frontend build-storybook # 静的ビルド
|
||||
frontend のテスト種別で DB / Redis の要否が違う:
|
||||
|
||||
- **Vitest (unit)** — DB 不要。ロジック / コンポーネント単体のテストで backend に繋がない (CI の `vitest` ジョブにも `services:` は無い)
|
||||
- **Cypress (E2E)** — テストサーバー (`pnpm start:test`) 経由で backend に繋ぐため DB / Redis が必要。**テスト用ポートの [packages/backend/test/compose.yml](../../../../../packages/backend/test/compose.yml)** を使う (上記 Cypress E2E の手順を参照)
|
||||
- **Playwright (E2E)** — テストサーバー (`pnpm start:test`) 経由で backend に繋ぐため DB / Redis が必要。**テスト用ポートの [packages/backend/test/compose.yml](../../../../../packages/backend/test/compose.yml)** を使う (上記 Playwright E2E の手順を参照)
|
||||
|
||||
開発用の `compose.local-db.yml` (db `5432` / redis `6379`) は **テストには使わない**。テスト用の `packages/backend/test/compose.yml` (`54312` / `56312`) とはポートが異なり、混同すると接続できない。
|
||||
|
||||
11
.coderabbit.yaml
Normal file
11
.coderabbit.yaml
Normal file
@@ -0,0 +1,11 @@
|
||||
language: "ja-JP"
|
||||
tone_instructions: "常に丁寧語(です・ます調)を用い、敬意のある表現で応答してください。人格や能力への評価、皮肉、威圧的・高圧的・命令的な表現は避けてください。指摘では問題点と影響を明確にし、理由を簡潔に説明したうえで、具体的な改善案を提案してください。不確実な事項は推測と明示し、確認を促してください。重大なバグやセキュリティ上の懸念は重要度と根拠を明確に伝えてください。"
|
||||
reviews:
|
||||
profile: "chill"
|
||||
high_level_summary: false
|
||||
poem: true
|
||||
auto_review:
|
||||
enabled: true
|
||||
drafts: false
|
||||
base_branches:
|
||||
- "develop"
|
||||
@@ -233,15 +233,24 @@ proxyBypassHosts:
|
||||
# For security reasons, uploading attachments from the intranet is prohibited,
|
||||
# but exceptions can be made from the following settings. Default value is "undefined".
|
||||
# Read changelog to learn more (Improvements of 12.90.0 (2021/09/04)).
|
||||
#allowedPrivateNetworks: [
|
||||
# '127.0.0.1/32'
|
||||
#]
|
||||
#allowedPrivateNetworks:
|
||||
# - '127.0.0.1/32'
|
||||
|
||||
# Upload or download file size limits (bytes)
|
||||
#maxFileSize: 262144000
|
||||
|
||||
# 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.
|
||||
# domains:
|
||||
# core.boot: info
|
||||
# queue: error
|
||||
# queue.deliver: debug
|
||||
# db.sql: off
|
||||
# sql:
|
||||
# # Outputs query parameters during SQL execution to the log.
|
||||
# # default: false
|
||||
|
||||
@@ -303,8 +303,88 @@ id: 'aidx'
|
||||
|
||||
#sentryForBackend:
|
||||
# enableNodeProfiling: true
|
||||
# # Specify Sentry integration names to disable individual auto-instrumentation.
|
||||
# # The names are integration .name values, not factory function names.
|
||||
# # To check enabled names, set `options.debug: true` and see
|
||||
# # "Integration installed: <name>" in the Sentry logs.
|
||||
# #
|
||||
# # As of 2026-07-07 / @sentry/node 10.62.0, useful names for Misskey include:
|
||||
# # Postgres ... DB queries (when pg is externalized)
|
||||
# # Redis ... ioredis commands
|
||||
# # Fastify ... inbound HTTP routes
|
||||
# # Http ... inbound/outbound HTTP; disabling this can also affect request isolation
|
||||
# # NodeFetch ... fetch/undici requests
|
||||
# disabledIntegrations: ['Postgres']
|
||||
# options:
|
||||
# dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0'
|
||||
# # By default, Misskey prevents Sentry trace headers (`sentry-trace` and
|
||||
# # `baggage`) from being sent to remote ActivityPub/Webhook/etc. hosts.
|
||||
# # To intentionally propagate distributed traces to trusted internal services,
|
||||
# # list only those internal URL patterns here.
|
||||
# # Avoid broad patterns that match remote servers unless you intentionally
|
||||
# # want to restore Sentry's legacy behavior of propagating traces to all
|
||||
# # outbound HTTP requests.
|
||||
# #tracePropagationTargets: []
|
||||
# # Internal allowlist example:
|
||||
# #tracePropagationTargets:
|
||||
# # - 'internal-service.example'
|
||||
|
||||
# ┌─────────┐
|
||||
#───┘ Tracing └────────────────────────────────────────────────
|
||||
# OpenTelemetry distributed tracing (Traces only, opt-in).
|
||||
# Existing API and Queue spans are exported to an OTLP http/protobuf endpoint.
|
||||
#
|
||||
# Standard OTEL_* environment variables are honored for values omitted below:
|
||||
# OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, OTEL_EXPORTER_OTLP_ENDPOINT,
|
||||
# OTEL_EXPORTER_OTLP_HEADERS, OTEL_TRACES_SAMPLER, OTEL_TRACES_SAMPLER_ARG, etc.
|
||||
#
|
||||
# When this is enabled together with sentryForBackend, Misskey shares Sentry's
|
||||
# TracerProvider and adds an OTLP SpanProcessor to it. In that mode:
|
||||
# - sampleRate below is ignored; sampling follows sentryForBackend.options
|
||||
# tracesSampleRate / tracesSampler.
|
||||
# - resourceAttributes below is ignored; set OTEL_SERVICE_NAME and
|
||||
# OTEL_RESOURCE_ATTRIBUTES in the environment if you need to override them.
|
||||
# - spans produced by Sentry integrations are exported to both Sentry and OTLP.
|
||||
# - sentry-trace / baggage propagation to outbound remote requests is disabled
|
||||
# by default while OTel is enabled. Set propagateTraceToRemote: true only if
|
||||
# you intentionally want Sentry trace propagation for your deployment.
|
||||
|
||||
#otelForBackend:
|
||||
# endpoint: 'http://localhost:4318/v1/traces'
|
||||
# #headers:
|
||||
# # authorization: 'Bearer xxxxx'
|
||||
# #sampleRate: 1.0
|
||||
# # Record PostgreSQL query spans. Disabled by default to avoid instrumentation
|
||||
# # overhead when database-level diagnostics are not needed.
|
||||
# #capturePgSpans: false
|
||||
# # Include raw SQL text in PostgreSQL spans. Requires capturePgSpans and is
|
||||
# # disabled by default because non-parameterized queries can expose values to
|
||||
# # the OTLP Collector.
|
||||
# #capturePgStatement: false
|
||||
# # Include PostgreSQL connection-pool spans with an existing parent span.
|
||||
# # Requires capturePgSpans and is disabled by default to avoid noisy spans
|
||||
# # from connection churn.
|
||||
# #capturePgConnectionSpans: false
|
||||
# # Record Redis command spans. Disabled by default because subscribing to the
|
||||
# # ioredis diagnostics channel adds work to every Redis command, including
|
||||
# # commands without a parent span. Enable for development diagnostics or when
|
||||
# # the added overhead is acceptable in production.
|
||||
# #captureRedisCommandSpans: false
|
||||
# # Include Redis startup/reconnect spans. Disabled by default to avoid noisy
|
||||
# # connection churn; these spans are recorded even without an HTTP or job
|
||||
# # parent span.
|
||||
# #captureRedisConnectionSpans: false
|
||||
# # Record Redis commands without an HTTP or job parent span. Disabled by
|
||||
# # default because queue polling and background work can produce many root
|
||||
# # traces; only applies when captureRedisCommandSpans is enabled.
|
||||
# #captureRedisRootSpans: false
|
||||
# #resourceAttributes:
|
||||
# # deployment.environment: 'production'
|
||||
# #propagateTraceToRemote: false
|
||||
# # Queue worker spans are independent traces linked to their enqueuer by default.
|
||||
# # Set to 'parent' to make them child spans instead. This can create very large
|
||||
# # traces for high-fan-out queues such as federation delivery.
|
||||
# #jobTraceContextMode: 'link'
|
||||
|
||||
#sentryForFrontend:
|
||||
# vueIntegration:
|
||||
@@ -383,9 +463,8 @@ proxyBypassHosts:
|
||||
# For security reasons, uploading attachments from the intranet is prohibited,
|
||||
# but exceptions can be made from the following settings. Default value is "undefined".
|
||||
# Read changelog to learn more (Improvements of 12.90.0 (2021/09/04)).
|
||||
#allowedPrivateNetworks: [
|
||||
# '127.0.0.1/32'
|
||||
#]
|
||||
#allowedPrivateNetworks:
|
||||
# - '127.0.0.1/32'
|
||||
|
||||
# Upload or download file size limits (bytes)
|
||||
#maxFileSize: 262144000
|
||||
@@ -395,6 +474,16 @@ 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.
|
||||
# domains:
|
||||
# core.boot: info
|
||||
# queue: error
|
||||
# queue.deliver: debug
|
||||
# db.sql: off
|
||||
# sql:
|
||||
# # Outputs query parameters during SQL execution to the log.
|
||||
# # default: false
|
||||
|
||||
@@ -218,9 +218,8 @@ proxyBypassHosts:
|
||||
# Media Proxy
|
||||
#mediaProxy: https://example.com/proxy
|
||||
|
||||
allowedPrivateNetworks: [
|
||||
'127.0.0.1/32'
|
||||
]
|
||||
allowedPrivateNetworks:
|
||||
- '127.0.0.1/32'
|
||||
|
||||
# Upload or download file size limits (bytes)
|
||||
#maxFileSize: 262144000
|
||||
@@ -205,9 +205,8 @@ proxyBypassHosts:
|
||||
# Media Proxy
|
||||
#mediaProxy: https://example.com/proxy
|
||||
|
||||
allowedPrivateNetworks: [
|
||||
'127.0.0.1/32'
|
||||
]
|
||||
allowedPrivateNetworks:
|
||||
- '127.0.0.1/32'
|
||||
|
||||
# Upload or download file size limits (bytes)
|
||||
#maxFileSize: 262144000
|
||||
|
||||
@@ -12,4 +12,4 @@ pnpm install --frozen-lockfile
|
||||
cp .devcontainer/devcontainer.yml .config/default.yml
|
||||
pnpm build
|
||||
pnpm migrate
|
||||
pnpm exec cypress install
|
||||
pnpm --filter frontend exec playwright install --with-deps chromium
|
||||
|
||||
2
.github/labeler.yml
vendored
2
.github/labeler.yml
vendored
@@ -16,7 +16,7 @@
|
||||
'packages/frontend:test':
|
||||
- any:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ['cypress/**/*']
|
||||
- any-glob-to-any-file: ['packages/frontend/test/**/*']
|
||||
|
||||
'packages/sw':
|
||||
- any:
|
||||
|
||||
239
.github/scripts/backend-diagnostics.inspect.mts
vendored
Normal file
239
.github/scripts/backend-diagnostics.inspect.mts
vendored
Normal file
@@ -0,0 +1,239 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { createRequire } from 'node:module';
|
||||
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 { MemorySample } from '../../packages/backend/scripts/measure-memory.mts';
|
||||
|
||||
const phases = ['afterGc'] as const;
|
||||
|
||||
export type MemoryReport = {
|
||||
timestamp: string;
|
||||
sampleCount: number;
|
||||
aggregation: string;
|
||||
summary: Record<typeof phases[number], {
|
||||
memoryUsage: Record<string, number>;
|
||||
heapSnapshot?: heapSnapshotUtil.HeapSnapshotData;
|
||||
}>;
|
||||
samples: (MemorySample & {
|
||||
round: number;
|
||||
})[];
|
||||
};
|
||||
|
||||
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 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');
|
||||
|
||||
async function resetState(repoDir: string) {
|
||||
const require = createRequire(join(repoDir, 'packages/backend/package.json'));
|
||||
const pg = require('pg');
|
||||
const Redis = require('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 summarizeSamples(samples: MemoryReport['samples']) {
|
||||
const summary = {} as MemoryReport['summary'];
|
||||
|
||||
for (const phase of phases) {
|
||||
summary[phase] = {
|
||||
memoryUsage: {},
|
||||
};
|
||||
|
||||
const metricKeys = new Set<string>();
|
||||
for (const sample of samples) {
|
||||
for (const key of Object.keys(sample.phases[phase].memoryUsage)) {
|
||||
metricKeys.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of metricKeys) {
|
||||
const values = samples.map(sample => sample.phases[phase].memoryUsage[key]);
|
||||
summary[phase].memoryUsage[key] = util.median(values);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function genSample(label: string, repoDir: string, round: number, options: { heapSnapshotSavePath?: string } = {}) {
|
||||
process.stderr.write(`[${label}] Resetting database and Redis\n`);
|
||||
await resetState(repoDir);
|
||||
|
||||
process.stderr.write(`[${label}] Running migrations\n`);
|
||||
await util.run('pnpm', ['--filter', 'backend', 'migrate'], {
|
||||
cwd: repoDir,
|
||||
env: process.env,
|
||||
logStdout: true,
|
||||
});
|
||||
|
||||
process.stderr.write(`[${label}] Measuring memory\n`);
|
||||
const measureEnv = {
|
||||
...process.env,
|
||||
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', [MEASURE_MEMORY_SCRIPT], {
|
||||
cwd: repoDir,
|
||||
env: measureEnv,
|
||||
});
|
||||
|
||||
return JSON.parse(stdout) as MemorySample;
|
||||
}
|
||||
|
||||
function heapSnapshotPath(label: typeof heapSnapshotLabels[number], 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: typeof heapSnapshotLabels[number], samples: MemoryReport['samples'], summary: MemoryReport['summary']) {
|
||||
const round = selectRepresentativeHeapSnapshotRound(samples, summary);
|
||||
if (round == null) return;
|
||||
|
||||
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() {
|
||||
const baseDir = resolve(baseDirArg);
|
||||
const headDir = resolve(headDirArg);
|
||||
const baseOutput = resolve(baseOutputArg);
|
||||
const headOutput = resolve(headOutputArg);
|
||||
const rounds = util.readIntegerEnv('MK_MEMORY_COMPARE_ROUNDS', 5, 1);
|
||||
const warmupRounds = util.readIntegerEnv('MK_MEMORY_COMPARE_WARMUP_ROUNDS', 1, 0);
|
||||
const startedAt = new Date().toISOString();
|
||||
|
||||
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: {
|
||||
dir: baseDir,
|
||||
samples: [] as MemoryReport['samples'],
|
||||
},
|
||||
head: {
|
||||
dir: headDir,
|
||||
samples: [] as MemoryReport['samples'],
|
||||
},
|
||||
};
|
||||
|
||||
for (let round = 1; round <= warmupRounds; round++) {
|
||||
process.stderr.write(`Starting warmup round ${round}/${warmupRounds}\n`);
|
||||
for (const label of heapSnapshotLabels) {
|
||||
await genSample(label, reports[label].dir, -round);
|
||||
}
|
||||
}
|
||||
|
||||
for (let round = 1; round <= rounds; round++) {
|
||||
const order = round % 2 === 1 ? ['base', 'head'] as const : ['head', 'base'] as const;
|
||||
process.stderr.write(`Starting measurement round ${round}/${rounds}: ${order.join(' -> ')}\n`);
|
||||
|
||||
for (const label of order) {
|
||||
const options = { heapSnapshotSavePath: heapSnapshotPath(label, round) };
|
||||
const sample = await genSample(label, reports[label].dir, round, options);
|
||||
reports[label].samples.push({
|
||||
...sample,
|
||||
round,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const summaries = {
|
||||
base: summarizeSamples(reports.base.samples),
|
||||
head: summarizeSamples(reports.head.samples),
|
||||
};
|
||||
for (const label of heapSnapshotLabels) {
|
||||
await saveRepresentativeHeapSnapshot(label, reports[label].samples, summaries[label]);
|
||||
}
|
||||
|
||||
for (const label of heapSnapshotLabels) {
|
||||
const report = {
|
||||
timestamp: new Date().toISOString(),
|
||||
sampleCount: reports[label].samples.length,
|
||||
aggregation: 'median',
|
||||
comparison: {
|
||||
strategy: 'interleaved-pairs',
|
||||
rounds,
|
||||
warmupRounds,
|
||||
startedAt,
|
||||
},
|
||||
summary: summaries[label],
|
||||
samples: reports[label].samples,
|
||||
};
|
||||
|
||||
await writeFile(label === 'base' ? baseOutput : headOutput, `${JSON.stringify(report, null, 2)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
183
.github/scripts/backend-diagnostics.render-md.mts
vendored
Normal file
183
.github/scripts/backend-diagnostics.render-md.mts
vendored
Normal file
@@ -0,0 +1,183 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import * as util from './utility.mts';
|
||||
import * as heapSnapshotUtil from './heap-snapshot-util.mts';
|
||||
import type { MemoryReport } from './backend-diagnostics.inspect.mts';
|
||||
|
||||
const [baseFile, headFile, outputFile] = process.argv.slice(2);
|
||||
|
||||
const memoryReportPhases = [
|
||||
{
|
||||
key: 'afterGc',
|
||||
title: 'After GC',
|
||||
},
|
||||
] as const;
|
||||
|
||||
const memoryMetrics = [
|
||||
'HeapUsed',
|
||||
'Pss',
|
||||
'USS',
|
||||
'External',
|
||||
] as const;
|
||||
|
||||
function formatMemoryMb(valueKiB: number | null | undefined) {
|
||||
if (valueKiB == null) return '-';
|
||||
return `${util.formatNumber(valueKiB / 1000)} MB`;
|
||||
}
|
||||
|
||||
function formatMemoryMetricName(metric: typeof memoryMetrics[number]) {
|
||||
return metric === 'Pss' ? 'PSS' : metric;
|
||||
}
|
||||
|
||||
function getMemoryValueFromSample(sample: MemoryReport['samples'][number], phase: typeof memoryReportPhases[number]['key'], metric: typeof memoryMetrics[number]) {
|
||||
const memoryUsage = sample.phases[phase].memoryUsage;
|
||||
if (metric !== 'USS') return memoryUsage[metric];
|
||||
return memoryUsage.Private_Clean + memoryUsage.Private_Dirty;
|
||||
}
|
||||
|
||||
function getSampleValues(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof memoryMetrics[number]) {
|
||||
return report.samples.map(sample => getMemoryValueFromSample(sample, phase, metric));
|
||||
}
|
||||
|
||||
function getMemoryValue(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof memoryMetrics[number]) {
|
||||
if (metric !== 'USS') return report.summary[phase].memoryUsage[metric];
|
||||
return util.median(getSampleValues(report, phase, metric));
|
||||
}
|
||||
|
||||
function getSampleSpread(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof memoryMetrics[number]) {
|
||||
const values = getSampleValues(report, phase, metric);
|
||||
if (values.length < 2) return null;
|
||||
|
||||
const center = util.median(values);
|
||||
return util.median(values.map(value => Math.abs(value - center)));
|
||||
}
|
||||
|
||||
function renderMainTableForPhase(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key']) {
|
||||
const lines = [
|
||||
'| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |',
|
||||
'| --- | ---: | ---: | ---: | ---: | ---: | ---: |',
|
||||
];
|
||||
|
||||
function formatDeltaMemory(deltaKiB: number) {
|
||||
return util.formatColoredDelta(deltaKiB, v => formatMemoryMb(v), 100); // 0.1 MB threshold
|
||||
}
|
||||
|
||||
for (const metric of memoryMetrics) {
|
||||
const baseValue = getMemoryValue(base, phase, metric);
|
||||
const headValue = getMemoryValue(head, phase, metric);
|
||||
|
||||
const baseSpread = getSampleSpread(base, phase, metric);
|
||||
const headSpread = getSampleSpread(head, phase, metric);
|
||||
const summary = util.pairedDeltaSummary(base.samples, head.samples, (sample) => getMemoryValueFromSample(sample, phase, metric));
|
||||
const percent = summary.median * 100 / baseValue;
|
||||
const deltaMedian = `${formatDeltaMemory(summary.median)}<br>${util.formatDeltaPercent(percent, 0.1).replaceAll('\\%', '\\\\%')}`;
|
||||
|
||||
lines.push(`| **${formatMemoryMetricName(metric)}** | ${formatMemoryMb(baseValue)} <br> ± ${formatMemoryMb(baseSpread)} | ${formatMemoryMb(headValue)} <br> ± ${formatMemoryMb(headSpread)} | ${deltaMedian} | ${formatMemoryMb(summary.mad)} | ${formatDeltaMemory(summary.min)} | ${formatDeltaMemory(summary.max)} |`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderHeapSnapshotSection(base: MemoryReport, head: MemoryReport) {
|
||||
const baseHeapSnapshotReport = {
|
||||
summary: base.summary.afterGc.heapSnapshot!,
|
||||
samples: base.samples.map(sample => ({
|
||||
round: sample.round,
|
||||
data: sample.phases.afterGc.heapSnapshot!,
|
||||
})),
|
||||
};
|
||||
|
||||
const headHeapSnapshotReport = {
|
||||
summary: head.summary.afterGc.heapSnapshot!,
|
||||
samples: head.samples.map(sample => ({
|
||||
round: sample.round,
|
||||
data: sample.phases.afterGc.heapSnapshot!,
|
||||
})),
|
||||
};
|
||||
|
||||
const table = heapSnapshotUtil.renderHeapSnapshotTable(baseHeapSnapshotReport, headHeapSnapshotReport);
|
||||
if (table == null) return null;
|
||||
|
||||
const lines = [
|
||||
'### V8 Heap Snapshot Statistics',
|
||||
'',
|
||||
table,
|
||||
'',
|
||||
];
|
||||
|
||||
for (const graph of [
|
||||
//heapSnapshotUtil.renderHeapSnapshotSankey(baseHeapSnapshotReport, 'Base'),
|
||||
heapSnapshotUtil.renderHeapSnapshotSankey(headHeapSnapshotReport, 'Head'),
|
||||
]) {
|
||||
if (graph == null) continue;
|
||||
lines.push(graph);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
const base = JSON.parse(await readFile(baseFile, 'utf8')) as MemoryReport;
|
||||
const head = JSON.parse(await readFile(headFile, 'utf8')) as MemoryReport;
|
||||
|
||||
const lines = [
|
||||
'## ⚙️ Backend Diagnostics Report',
|
||||
'',
|
||||
];
|
||||
|
||||
//const summary = measurementSummary(base, head);
|
||||
//if (summary != null) {
|
||||
// lines.push(summary);
|
||||
// lines.push('');
|
||||
//}
|
||||
|
||||
for (const phase of memoryReportPhases) {
|
||||
lines.push(`### Memory: ${phase.title}`);
|
||||
lines.push(renderMainTableForPhase(base, head, phase.key));
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
const heapSnapshotSection = renderHeapSnapshotSection(base, head);
|
||||
if (heapSnapshotSection != null) {
|
||||
lines.push(heapSnapshotSection);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
const baseHeapSnapshotArtifactUrl = process.env.MK_MEMORY_HEAP_SNAPSHOT_ARTIFACT_URL_BASE!.trim();
|
||||
const headHeapSnapshotArtifactUrl = process.env.MK_MEMORY_HEAP_SNAPSHOT_ARTIFACT_URL_HEAD!.trim();
|
||||
lines.push(`You can download the representative heap snapshot: [base](${baseHeapSnapshotArtifactUrl}) / [head](${headHeapSnapshotArtifactUrl})`);
|
||||
lines.push('');
|
||||
|
||||
function getDiffPercent(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof memoryMetrics[number]) {
|
||||
const baseValue = getMemoryValue(base, phase, metric);
|
||||
const headValue = getMemoryValue(head, phase, metric);
|
||||
return ((headValue - baseValue) * 100) / baseValue;
|
||||
}
|
||||
|
||||
function isBeyondSampleNoise(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof memoryMetrics[number]) {
|
||||
const baseValue = getMemoryValue(base, phase, metric);
|
||||
const headValue = getMemoryValue(head, phase, metric);
|
||||
|
||||
const delta = headValue - baseValue;
|
||||
if (delta <= 0) return false;
|
||||
|
||||
const baseSpread = getSampleSpread(base, phase, metric);
|
||||
const headSpread = getSampleSpread(head, phase, metric);
|
||||
if (baseSpread == null || headSpread == null) return true;
|
||||
|
||||
const combinedSpread = Math.hypot(baseSpread, headSpread);
|
||||
return delta > combinedSpread * 3;
|
||||
}
|
||||
|
||||
const warningMetric = 'Pss';
|
||||
const warningDiffPercent = getDiffPercent(base, head, 'afterGc', warningMetric);
|
||||
if (warningDiffPercent > 5 && isBeyondSampleNoise(base, head, 'afterGc', warningMetric)) {
|
||||
lines.push(`⚠️ **Warning**: Memory usage (${formatMemoryMetricName(warningMetric)}) has increased by more than 5% and exceeds the observed sample noise. Please verify this is not an unintended change.`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
await writeFile(outputFile, `${lines.join('\n')}\n`);
|
||||
46
.github/scripts/backend-js-footprint-loader.mjs
vendored
46
.github/scripts/backend-js-footprint-loader.mjs
vendored
@@ -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;
|
||||
}
|
||||
46
.github/scripts/backend-js-footprint-require.cjs
vendored
46
.github/scripts/backend-js-footprint-require.cjs
vendored
@@ -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;
|
||||
};
|
||||
473
.github/scripts/backend-js-footprint.mjs
vendored
473
.github/scripts/backend-js-footprint.mjs
vendored
@@ -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`);
|
||||
429
.github/scripts/backend-memory-report.mts
vendored
429
.github/scripts/backend-memory-report.mts
vendored
@@ -1,429 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import * as util from './utility.mts';
|
||||
import * as heapSnapshotUtil from './heap-snapshot-util.mts';
|
||||
import type { MemoryReport } from './measure-backend-memory-comparison.mts';
|
||||
|
||||
const [baseFile, headFile, outputFile, baseJsFootprintFile, headJsFootprintFile] = process.argv.slice(2);
|
||||
|
||||
type RuntimeLoadedJsFootprintReport = {
|
||||
phases: Record<'afterRequest', {
|
||||
totals: {
|
||||
loadedJsModules: number;
|
||||
loadedJsSourceBytes: number;
|
||||
loadedJsGzipBytes: number;
|
||||
astNodeCount: number;
|
||||
functionCount: number;
|
||||
classCount: number;
|
||||
stringLiteralBytes: number;
|
||||
externalPackageCount: number;
|
||||
nativeAddonPackageCount: number;
|
||||
};
|
||||
modules: {
|
||||
path: string;
|
||||
package: string;
|
||||
category: string;
|
||||
sourceBytes: number;
|
||||
gzipBytes: number;
|
||||
astNodeCount: number;
|
||||
functionCount: number;
|
||||
classCount: number;
|
||||
stringLiteralBytes: number;
|
||||
}[];
|
||||
}>;
|
||||
};
|
||||
|
||||
const memoryReportPhases = [
|
||||
{
|
||||
key: 'afterGc',
|
||||
title: 'After GC',
|
||||
},
|
||||
] as const;
|
||||
|
||||
const metrics = [
|
||||
'HeapUsed',
|
||||
'Pss',
|
||||
'Private_Dirty',
|
||||
'VmRSS',
|
||||
'External',
|
||||
] as const;
|
||||
|
||||
function formatMemoryMb(valueKiB: number | null | undefined) {
|
||||
if (valueKiB == null) return '-';
|
||||
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 getMemoryValueFromSample(sample: MemoryReport['samples'][number], phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) {
|
||||
return sample.phases[phase].memoryUsage[metric];
|
||||
}
|
||||
|
||||
function getSampleSpread(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) {
|
||||
const values = report.samples.map(sample => getMemoryValueFromSample(sample, phase, metric));
|
||||
if (values.length < 2) return null;
|
||||
|
||||
const center = util.median(values);
|
||||
return util.median(values.map(value => Math.abs(value - center)));
|
||||
}
|
||||
|
||||
function renderMainTableForPhase(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key']) {
|
||||
const lines = [
|
||||
'| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |',
|
||||
'| --- | ---: | ---: | ---: | ---: | ---: | ---: |',
|
||||
];
|
||||
|
||||
function formatDeltaMemory(deltaKiB: number) {
|
||||
return util.formatColoredDelta(deltaKiB, v => formatMemoryMb(v), 100); // 0.1 MB threshold
|
||||
}
|
||||
|
||||
for (const metric of metrics) {
|
||||
const baseValue = getMemoryValue(base, phase, metric);
|
||||
const headValue = getMemoryValue(head, phase, metric);
|
||||
|
||||
const baseSpread = getSampleSpread(base, phase, metric);
|
||||
const headSpread = getSampleSpread(head, phase, metric);
|
||||
const summary = util.pairedDeltaSummary(base.samples, head.samples, (sample) => getMemoryValueFromSample(sample, phase, metric));
|
||||
const percent = summary.median * 100 / baseValue;
|
||||
const deltaMedian = summary == null ? '-' : `${formatDeltaMemory(summary.median)}<br>${util.formatDeltaPercent(percent, 0.1).replaceAll('\\%', '\\\\%')}`;
|
||||
|
||||
lines.push(`| **${metric}** | ${formatMemoryMb(baseValue)} <br> ± ${formatMemoryMb(baseSpread)} | ${formatMemoryMb(headValue)} <br> ± ${formatMemoryMb(headSpread)} | ${deltaMedian} | ${summary?.mad == null ? '-' : formatMemoryMb(summary.mad)} | ${summary == null ? '-' : formatDeltaMemory(summary.min)} | ${summary == null ? '-' : formatDeltaMemory(summary.max)} |`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/*
|
||||
function measurementSummary(base, head) {
|
||||
const baseCount = base?.sampleCount;
|
||||
const headCount = head?.sampleCount;
|
||||
const strategy = base?.comparison?.strategy;
|
||||
if (baseCount == null || headCount == null) return null;
|
||||
|
||||
if (strategy === 'interleaved-pairs') {
|
||||
const rounds = base?.comparison?.rounds ?? baseCount;
|
||||
const warmupRounds = base?.comparison?.warmupRounds ?? 0;
|
||||
return `_Measured as ${rounds} interleaved base/head pairs after ${warmupRounds} warmup pair(s). Values are medians; ± is median absolute deviation._`;
|
||||
}
|
||||
|
||||
return `_Sample count: base ${baseCount}, head ${headCount}. Values are medians; ± is median absolute deviation._`;
|
||||
}
|
||||
*/
|
||||
|
||||
function renderHeapSnapshotSection(base: MemoryReport, head: MemoryReport) {
|
||||
const baseHeapSnapshotReport = {
|
||||
summary: base.summary.afterGc.heapSnapshot!,
|
||||
samples: base.samples.map(sample => ({
|
||||
round: sample.round,
|
||||
data: sample.phases.afterGc.heapSnapshot!,
|
||||
})),
|
||||
};
|
||||
|
||||
const headHeapSnapshotReport = {
|
||||
summary: head.summary.afterGc.heapSnapshot!,
|
||||
samples: head.samples.map(sample => ({
|
||||
round: sample.round,
|
||||
data: sample.phases.afterGc.heapSnapshot!,
|
||||
})),
|
||||
};
|
||||
|
||||
const table = heapSnapshotUtil.renderHeapSnapshotTable(baseHeapSnapshotReport, headHeapSnapshotReport);
|
||||
if (table == null) return null;
|
||||
|
||||
const lines = [
|
||||
'### V8 Heap Snapshot Statistics',
|
||||
'',
|
||||
table,
|
||||
'',
|
||||
];
|
||||
|
||||
for (const graph of [
|
||||
//heapSnapshotUtil.renderHeapSnapshotSankey(baseHeapSnapshotReport, 'Base'),
|
||||
heapSnapshotUtil.renderHeapSnapshotSankey(headHeapSnapshotReport, 'Head'),
|
||||
]) {
|
||||
if (graph == null) continue;
|
||||
lines.push(graph);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function getJsFootprintValue(report: RuntimeLoadedJsFootprintReport, phase: 'afterRequest', key: keyof RuntimeLoadedJsFootprintReport['phases'][typeof phase]['totals']) {
|
||||
const value = report.phases[phase].totals[key];
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function renderJsFootprintMetricTable(base: RuntimeLoadedJsFootprintReport, head: RuntimeLoadedJsFootprintReport) {
|
||||
const metricRows = [
|
||||
['Loaded JS modules', 'loadedJsModules', util.formatNumber],
|
||||
['Loaded JS source', 'loadedJsSourceBytes', util.formatBytes],
|
||||
//['Loaded JS gzip estimate', 'loadedJsGzipBytes', util.formatBytes],
|
||||
//['AST nodes', 'astNodeCount', util.formatNumber],
|
||||
//['Functions', 'functionCount', util.formatNumber],
|
||||
//['Classes', 'classCount', util.formatNumber],
|
||||
//['String literals', 'stringLiteralBytes', util.formatBytes],
|
||||
['External packages loaded', 'externalPackageCount', util.formatNumber],
|
||||
['Native addon packages', 'nativeAddonPackageCount', util.formatNumber],
|
||||
] as const;
|
||||
|
||||
const lines = [
|
||||
'| Metric | Base | Head | Δ | Δ (%) |',
|
||||
'| --- | ---: | ---: | ---: | ---: |',
|
||||
];
|
||||
|
||||
for (const [title, key, formatter] of metricRows) {
|
||||
const baseValue = getJsFootprintValue(base, 'afterRequest', key);
|
||||
const headValue = getJsFootprintValue(head, 'afterRequest', key);
|
||||
if (baseValue == null || headValue == null) continue;
|
||||
|
||||
lines.push(`| **${title}** | ${formatter(baseValue)} | ${formatter(headValue)} | ${util.formatColoredDelta(headValue - baseValue, v => formatter(v))} | ${util.calcAndFormatDeltaPercent(baseValue, headValue).replaceAll('\\%', '\\\\%')} |`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/*
|
||||
function renderJsFootprintPhaseTable(base, head) {
|
||||
const lines = [
|
||||
'| Phase | Base modules | Head modules | Δ modules | Base source | Head source | Δ source |',
|
||||
'| --- | ---: | ---: | ---: | ---: | ---: | ---: |',
|
||||
];
|
||||
|
||||
for (const [phase, title] of [['startup', 'Startup'], ['afterRequest', 'After warmup requests']]) {
|
||||
const baseModules = getJsFootprintValue(base, phase, 'loadedJsModules');
|
||||
const headModules = getJsFootprintValue(head, phase, 'loadedJsModules');
|
||||
const baseSource = getJsFootprintValue(base, phase, 'loadedJsSourceBytes');
|
||||
const headSource = getJsFootprintValue(head, phase, 'loadedJsSourceBytes');
|
||||
if (baseModules == null || headModules == null || baseSource == null || headSource == null) continue;
|
||||
|
||||
lines.push(`| ${title} | ${util.formatNumber(baseModules)} | ${util.formatNumber(headModules)} | ${formatPlainDelta(baseModules, headModules)} | ${util.formatBytes(baseSource)} | ${util.formatBytes(headSource)} | ${formatPlainDelta(baseSource, headSource, util.formatBytes)} |`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
*/
|
||||
|
||||
function packageMap(report: RuntimeLoadedJsFootprintReport) {
|
||||
const map = new Map();
|
||||
for (const packageSummary of report.phases.afterRequest.packages) {
|
||||
if (packageSummary?.category !== 'external' || typeof packageSummary.name !== 'string') continue;
|
||||
map.set(packageSummary.name, packageSummary);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function packageDisplayName(packageSummary: { name: string; version?: string | null }) {
|
||||
if (packageSummary.version == null) return packageSummary.name;
|
||||
return `${packageSummary.name} ${packageSummary.version}`;
|
||||
}
|
||||
|
||||
function renderNewExternalPackages(base: RuntimeLoadedJsFootprintReport, head: RuntimeLoadedJsFootprintReport) {
|
||||
const basePackages = packageMap(base);
|
||||
const headPackages = packageMap(head);
|
||||
const newPackages = [...headPackages.values()]
|
||||
.filter(packageSummary => !basePackages.has(packageSummary.name))
|
||||
.toSorted((a, b) => b.sourceBytes - a.sourceBytes)
|
||||
.slice(0, 10);
|
||||
|
||||
if (newPackages.length === 0) return null;
|
||||
|
||||
const lines = [
|
||||
'#### Newly Loaded External Packages',
|
||||
'',
|
||||
'| Package | Loaded JS | Modules | Notes |',
|
||||
'| --- | ---: | ---: | --- |',
|
||||
];
|
||||
|
||||
for (const packageSummary of newPackages) {
|
||||
lines.push(`| ${packageDisplayName(packageSummary)} | ${util.formatBytes(packageSummary.sourceBytes)} | ${util.formatNumber(packageSummary.modules)} | ${packageSummary.nativeAddon ? 'native addon' : ''} |`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderLargestPackageIncreases(base: RuntimeLoadedJsFootprintReport, head: RuntimeLoadedJsFootprintReport) {
|
||||
const basePackages = packageMap(base);
|
||||
const headPackages = packageMap(head);
|
||||
const increases = [...headPackages.values()]
|
||||
.map(headPackage => {
|
||||
const basePackage = basePackages.get(headPackage.name);
|
||||
const baseSourceBytes = basePackage?.sourceBytes ?? 0;
|
||||
const baseModules = basePackage?.modules ?? 0;
|
||||
return {
|
||||
...headPackage,
|
||||
baseSourceBytes,
|
||||
baseModules,
|
||||
sourceDiff: headPackage.sourceBytes - baseSourceBytes,
|
||||
moduleDiff: headPackage.modules - baseModules,
|
||||
};
|
||||
})
|
||||
.filter(packageSummary => packageSummary.sourceDiff > 0)
|
||||
.toSorted((a, b) => b.sourceDiff - a.sourceDiff)
|
||||
.slice(0, 10);
|
||||
|
||||
if (increases.length === 0) return null;
|
||||
|
||||
const lines = [
|
||||
'#### Largest Package Increases',
|
||||
'',
|
||||
'| Package | Base | Head | Δ | Modules Δ |',
|
||||
'| --- | ---: | ---: | ---: | ---: |',
|
||||
];
|
||||
|
||||
for (const packageSummary of increases) {
|
||||
lines.push(`| ${packageDisplayName(packageSummary)} | ${util.formatBytes(packageSummary.baseSourceBytes)} | ${util.formatBytes(packageSummary.sourceBytes)} | ${util.formatColoredDelta(packageSummary.sourceBytes - packageSummary.baseSourceBytes, v => util.formatBytes(v))} | ${util.formatColoredDelta(packageSummary.modules - packageSummary.baseModules, v => util.formatNumber(v))} |`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderNewLoadedModules(base: RuntimeLoadedJsFootprintReport, head: RuntimeLoadedJsFootprintReport) {
|
||||
function moduleMap(report: RuntimeLoadedJsFootprintReport) {
|
||||
const map = new Map();
|
||||
for (const moduleSummary of report.phases.afterRequest.modules) {
|
||||
if (typeof moduleSummary.path !== 'string') continue;
|
||||
map.set(moduleSummary.path, moduleSummary);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
const baseModules = moduleMap(base);
|
||||
const headModules = moduleMap(head);
|
||||
const newModules = [...headModules.values()]
|
||||
.filter(moduleSummary => !baseModules.has(moduleSummary.path))
|
||||
.toSorted((a, b) => b.sourceBytes - a.sourceBytes)
|
||||
.slice(0, 10);
|
||||
|
||||
if (newModules.length === 0) return null;
|
||||
|
||||
const lines = [
|
||||
'#### Largest Newly Loaded Modules',
|
||||
'',
|
||||
'| Module | Package | Loaded JS |',
|
||||
'| --- | --- | ---: |',
|
||||
];
|
||||
|
||||
for (const moduleSummary of newModules) {
|
||||
lines.push(`| \`${moduleSummary.path}\` | ${moduleSummary.package} | ${util.formatBytes(moduleSummary.sourceBytes)} |`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderJsFootprintSection(base: RuntimeLoadedJsFootprintReport, head: RuntimeLoadedJsFootprintReport) {
|
||||
const lines = [
|
||||
'### Runtime Loaded JS Footprint',
|
||||
'',
|
||||
'<details><summary>Click to show</summary>',
|
||||
'',
|
||||
renderJsFootprintMetricTable(base, head),
|
||||
'',
|
||||
//'#### Load Phase Breakdown',
|
||||
//'',
|
||||
//renderJsFootprintPhaseTable(base, head),
|
||||
//'',
|
||||
];
|
||||
|
||||
for (const block of [
|
||||
renderNewExternalPackages(base, head),
|
||||
renderLargestPackageIncreases(base, head),
|
||||
renderNewLoadedModules(base, head),
|
||||
]) {
|
||||
if (block == null) continue;
|
||||
lines.push(block);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push('</details>');
|
||||
lines.push('');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
const base = JSON.parse(await readFile(baseFile, 'utf8')) as MemoryReport;
|
||||
const head = JSON.parse(await readFile(headFile, 'utf8')) as MemoryReport;
|
||||
const baseJsFootprint = JSON.parse(await readFile(baseJsFootprintFile, 'utf8')) as RuntimeLoadedJsFootprintReport;
|
||||
const headJsFootprint = JSON.parse(await readFile(headJsFootprintFile, 'utf8')) as RuntimeLoadedJsFootprintReport;
|
||||
const lines = [
|
||||
'## ⚙️ Backend Memory Usage Report',
|
||||
'',
|
||||
];
|
||||
|
||||
//const summary = measurementSummary(base, head);
|
||||
//if (summary != null) {
|
||||
// lines.push(summary);
|
||||
// lines.push('');
|
||||
//}
|
||||
|
||||
for (const phase of memoryReportPhases) {
|
||||
lines.push(`### ${phase.title}`);
|
||||
lines.push(renderMainTableForPhase(base, head, phase.key));
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
const heapSnapshotSection = renderHeapSnapshotSection(base, head);
|
||||
if (heapSnapshotSection != null) {
|
||||
lines.push(heapSnapshotSection);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
const artifactUrl = process.env.MK_MEMORY_HEAP_SNAPSHOT_ARTIFACT_URL_HEAD!.trim();
|
||||
lines.push(`[Download representative V8 heap snapshot (head)](${artifactUrl})`);
|
||||
lines.push('');
|
||||
|
||||
const jsFootprintSection = renderJsFootprintSection(baseJsFootprint, headJsFootprint);
|
||||
if (jsFootprintSection != null) {
|
||||
lines.push(jsFootprintSection);
|
||||
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;
|
||||
|
||||
const baseSpread = getSampleSpread(base, phase, metric);
|
||||
const headSpread = getSampleSpread(head, phase, metric);
|
||||
if (baseSpread == null || headSpread == null) return true;
|
||||
|
||||
const combinedSpread = Math.hypot(baseSpread, headSpread);
|
||||
return delta > combinedSpread * 3;
|
||||
}
|
||||
|
||||
const warningMetric = 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.`);
|
||||
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`);
|
||||
570
.github/scripts/chrome.mts
vendored
Normal file
570
.github/scripts/chrome.mts
vendored
Normal file
@@ -0,0 +1,570 @@
|
||||
/*
|
||||
* 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';
|
||||
import * as util from './utility.mts';
|
||||
|
||||
export type NetworkRequest = {
|
||||
requestId: string;
|
||||
url: string;
|
||||
method: string;
|
||||
resourceType: string;
|
||||
startedAt: number;
|
||||
documentUrl?: string;
|
||||
requestHeaders?: Record<string, string>;
|
||||
requestBody?: string;
|
||||
hasRequestBody: boolean;
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
mimeType?: string;
|
||||
responseHeaders?: Record<string, string>;
|
||||
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<string, string>;
|
||||
handshakeResponseStatus?: number;
|
||||
handshakeResponseStatusText?: string;
|
||||
handshakeResponseHeaders?: Record<string, string>;
|
||||
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<string, {
|
||||
requests: number;
|
||||
encodedBytes: number;
|
||||
decodedBodyBytes: number;
|
||||
}>;
|
||||
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 BrowserDiagnostics = {
|
||||
pageErrorCount: number;
|
||||
console: Record<'log' | 'warning' | 'error' | 'info', number>;
|
||||
};
|
||||
|
||||
export function summarizeBrowserDiagnostics(samples: BrowserDiagnostics[]): BrowserDiagnostics {
|
||||
const median = (select: (sample: BrowserDiagnostics) => number) => util.median(samples.map(select));
|
||||
|
||||
return {
|
||||
pageErrorCount: median(sample => sample.pageErrorCount),
|
||||
console: {
|
||||
log: median(sample => sample.console.log),
|
||||
warning: median(sample => sample.console.warning),
|
||||
error: median(sample => sample.console.error),
|
||||
info: median(sample => sample.console.info),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type BrowserMeasurement = {
|
||||
label: string;
|
||||
timestamp: string;
|
||||
url: string;
|
||||
scenario: string;
|
||||
diagnostics: BrowserDiagnostics;
|
||||
durationMs: number;
|
||||
network: NetworkSummary;
|
||||
performance: {
|
||||
cdpMetrics: Record<string, number>;
|
||||
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<string, unknown> | undefined) {
|
||||
if (headers == null) return undefined;
|
||||
const normalized = {} as Record<string, string>;
|
||||
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 diagnostics = {
|
||||
pageErrorCount: 0,
|
||||
console: {} as Record<string, number | undefined>,
|
||||
};
|
||||
private readonly browser: Browser;
|
||||
private readonly context: BrowserContext;
|
||||
public readonly page: Page;
|
||||
private readonly cdp: CDPSession;
|
||||
private pendingNetworkDetailReads: Promise<void>[] = [];
|
||||
|
||||
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);
|
||||
this.page.on('pageerror', () => {
|
||||
this.diagnostics.pageErrorCount++;
|
||||
});
|
||||
this.page.on('console', message => {
|
||||
if (this.diagnostics.console[message.type()] == null) {
|
||||
this.diagnostics.console[message.type()] = 1;
|
||||
} else {
|
||||
this.diagnostics.console[message.type()]++;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static async create(label: string, options: PlaywrightBrowserOptions): Promise<HeadlessChromeController> {
|
||||
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<T>(label: string, options: PlaywrightBrowserOptions, callback: (browser: HeadlessChromeController) => T | Promise<T>): Promise<T> {
|
||||
const browser = await HeadlessChromeController.create(label, options);
|
||||
try {
|
||||
return await callback(browser);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
public async enableNetworkTracking() {
|
||||
const requests = new Map<string, NetworkRequest>();
|
||||
const webSockets = new Map<string, WebSocketConnection>();
|
||||
|
||||
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<T>(expression: string, timeoutMs = 30_000): Promise<T> {
|
||||
return await Promise.race([
|
||||
this.page.evaluate(expression),
|
||||
new Promise<never>((_, reject) => setTimeout(() => reject(new Error(`Playwright evaluate timed out after ${timeoutMs}ms`)), timeoutMs).unref()),
|
||||
]) as T;
|
||||
}
|
||||
|
||||
public collectDiagnostics(): BrowserDiagnostics {
|
||||
return {
|
||||
pageErrorCount: this.diagnostics.pageErrorCount,
|
||||
console: {
|
||||
log: this.diagnostics.console.log ?? 0,
|
||||
warning: this.diagnostics.console.warning ?? 0,
|
||||
error: this.diagnostics.console.error ?? 0,
|
||||
info: this.diagnostics.console.info ?? 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public async collectPerformance(): Promise<BrowserMeasurement['performance']> {
|
||||
const cdpMetricsResult = await this.cdp.send<{ metrics: { name: string; value: number }[] }>('Performance.getMetrics');
|
||||
const cdpMetrics = Object.fromEntries(cdpMetricsResult.metrics.map(metric => [metric.name, metric.value]));
|
||||
const runtimeHeap = await this.cdp.send<{ usedSize: number; totalSize: number }>('Runtime.getHeapUsage').catch(() => undefined);
|
||||
const tabMemory = await this.collectTabMemory();
|
||||
const webVitals = await this.evaluate<BrowserMeasurement['performance']['webVitals']>(`(() => {
|
||||
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<TabMemory> {
|
||||
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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
279
.github/scripts/frontend-browser-diagnostics.inspect.mts
vendored
Normal file
279
.github/scripts/frontend-browser-diagnostics.inspect.mts
vendored
Normal file
@@ -0,0 +1,279 @@
|
||||
/*
|
||||
* 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, summarizeBrowserDiagnostics, summarizeNetwork } from './chrome.mts';
|
||||
import type { BrowserMeasurement, NetworkRequest, NetworkSummary } from './chrome.mts';
|
||||
import { closeUserSetupDialog, postNote, signupThroughUi, visitHome } from '../../packages/frontend/test/e2e/shared.ts';
|
||||
|
||||
const [baseDirArg, headDirArg, baseOutputArg, headOutputArg] = 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 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;
|
||||
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<string>();
|
||||
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<string>();
|
||||
for (const sample of samples) {
|
||||
for (const key of Object.keys(sample.performance.cdpMetrics)) {
|
||||
cdpMetricKeys.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
const cdpMetrics = {} as Record<string, number>;
|
||||
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,
|
||||
diagnostics: summarizeBrowserDiagnostics(samples.map(sample => sample.diagnostics)),
|
||||
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',
|
||||
diagnostics: chrome.collectDiagnostics(),
|
||||
durationMs,
|
||||
network: summarizeNetwork(chrome.networkRequests, baseUrl, chrome.webSocketConnections),
|
||||
networkRequests: chrome.networkRequests,
|
||||
performance,
|
||||
heapSnapshot,
|
||||
};
|
||||
|
||||
return measurement;
|
||||
});
|
||||
}
|
||||
|
||||
function heapSnapshotPath(label: 'base' | 'head', round: number) {
|
||||
return join(heapSnapshotWorkDirs[label], `round-${round}.heapsnapshot`);
|
||||
}
|
||||
|
||||
async function saveRepresentativeHeapSnapshot(label: 'base' | 'head', report: BrowserMetricsReport) {
|
||||
const representative = selectRepresentativeSample(report.samples, sample => sample.heapSnapshot.categories.total);
|
||||
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) {
|
||||
let server: ReturnType<typeof util.startServer> | null = null;
|
||||
|
||||
try {
|
||||
server = util.startServer(label, repoDir);
|
||||
await util.waitForServer(baseUrl, server);
|
||||
|
||||
await rm(heapSnapshotWorkDirs[label], { recursive: true, force: true });
|
||||
await mkdir(heapSnapshotWorkDirs[label], { 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,
|
||||
heapSnapshotPath(label, round),
|
||||
));
|
||||
}
|
||||
|
||||
const report = summarizeSamples(label, samples);
|
||||
await writeFile(outputPath, JSON.stringify(report, null, '\t'));
|
||||
process.stderr.write(`[${label}] Wrote browser metrics report to ${outputPath}\n`);
|
||||
|
||||
await saveRepresentativeHeapSnapshot(label, report);
|
||||
} finally {
|
||||
if (server != null) await util.stopServer(server);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await genReport('base', resolve(baseDirArg), resolve(baseOutputArg));
|
||||
await genReport('head', resolve(headDirArg), resolve(headOutputArg));
|
||||
}
|
||||
|
||||
await main();
|
||||
446
.github/scripts/frontend-browser-diagnostics.render-additional-html.mts
vendored
Normal file
446
.github/scripts/frontend-browser-diagnostics.render-additional-html.mts
vendored
Normal file
@@ -0,0 +1,446 @@
|
||||
/*
|
||||
* 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-diagnostics.render-md.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<string, NetworkRequest[]>();
|
||||
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<string, string> | undefined) {
|
||||
if (headers == null || Object.keys(headers).length === 0) return null;
|
||||
return JSON.stringify(headers, null, '\t');
|
||||
}
|
||||
|
||||
function countBy<T extends string>(diffs: RequestDiff[], getKey: (diff: RequestDiff) => T) {
|
||||
const counts = new Map<T, number>();
|
||||
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]) => `
|
||||
<tr>
|
||||
<td>${escapeHtml(type)}</td>
|
||||
<td class="num">${util.formatNumber(count)}</td>
|
||||
</tr>`).join('');
|
||||
|
||||
return `
|
||||
<section class="summary">
|
||||
<div>
|
||||
<span class="label">Base samples</span>
|
||||
<strong>${util.formatNumber(base.sampleCount)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Head samples</span>
|
||||
<strong>${util.formatNumber(head.sampleCount)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Added in Head</span>
|
||||
<strong class="added-text">${util.formatNumber(added)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Removed in Head</span>
|
||||
<strong class="removed-text">${util.formatNumber(removed)}</strong>
|
||||
</div>
|
||||
</section>
|
||||
${typeRows === '' ? '' : `
|
||||
<section>
|
||||
<h2>Diffs by Resource Type</h2>
|
||||
<table>
|
||||
<thead><tr><th>Type</th><th>Diff requests</th></tr></thead>
|
||||
<tbody>${typeRows}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>`}`;
|
||||
}
|
||||
|
||||
function renderDetails(title: string, content: string | null, open = false) {
|
||||
if (content == null || content === '') return '';
|
||||
return `
|
||||
<details${open ? ' open' : ''}>
|
||||
<summary>${escapeHtml(title)}</summary>
|
||||
<pre>${escapeHtml(content)}</pre>
|
||||
</details>`;
|
||||
}
|
||||
|
||||
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
|
||||
? '<p class="empty">Request body was present but could not be retrieved from CDP.</p>'
|
||||
: '';
|
||||
|
||||
return `
|
||||
<article class="request ${diff.direction}">
|
||||
<header>
|
||||
<span class="badge">${diff.direction === 'added' ? 'Added in Head' : 'Removed in Head'}</span>
|
||||
<span class="method">${escapeHtml(request.method)}</span>
|
||||
<span class="type">${escapeHtml(request.resourceType)}</span>
|
||||
<span class="status">${escapeHtml(request.status ?? '-')}</span>
|
||||
</header>
|
||||
<a class="url" href="${escapeAttribute(request.url)}">${escapeHtml(request.url)}</a>
|
||||
<dl>
|
||||
<div><dt>Round</dt><dd>${util.formatNumber(diff.round)}</dd></div>
|
||||
<div><dt>Base count</dt><dd>${util.formatNumber(diff.baseCount)}</dd></div>
|
||||
<div><dt>Head count</dt><dd>${util.formatNumber(diff.headCount)}</dd></div>
|
||||
<div><dt>Encoded</dt><dd>${util.formatBytes(request.encodedDataLength ?? 0)}</dd></div>
|
||||
<div><dt>Decoded body</dt><dd>${util.formatBytes(request.decodedBodyLength ?? 0)}</dd></div>
|
||||
<div><dt>MIME</dt><dd>${escapeHtml(request.mimeType ?? '-')}</dd></div>
|
||||
<div><dt>Protocol</dt><dd>${escapeHtml(request.protocol ?? '-')}</dd></div>
|
||||
<div><dt>Remote</dt><dd>${escapeHtml(request.remoteIPAddress == null ? '-' : `${request.remoteIPAddress}:${request.remotePort ?? ''}`)}</dd></div>
|
||||
<div><dt>Failed</dt><dd>${request.failed ? escapeHtml(request.errorText ?? 'yes') : 'no'}</dd></div>
|
||||
</dl>
|
||||
${bodyNote}
|
||||
${renderDetails('Request body', requestBody, requestBody != null)}
|
||||
${renderDetails('Request headers', requestHeaders)}
|
||||
${renderDetails('Response headers', responseHeaders)}
|
||||
</article>`;
|
||||
}
|
||||
|
||||
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 `
|
||||
<section>
|
||||
<h2>Round ${util.formatNumber(round)}</h2>
|
||||
<p>${util.formatNumber(added)} added, ${util.formatNumber(removed)} removed</p>
|
||||
<div class="requests">
|
||||
${diffs.map(renderRequest).join('\n')}
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
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
|
||||
? '<section><p>No added or removed HTTP(S) requests were found in paired samples.</p></section>'
|
||||
: rounds.map(round => renderRound(round, diffs.filter(diff => diff.round === round))).join('\n');
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Frontend Browser Network Request Diff</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #f7f7f8;
|
||||
--fg: #202124;
|
||||
--muted: #5f6368;
|
||||
--card: #ffffff;
|
||||
--border: #dfe1e5;
|
||||
--added: #137333;
|
||||
--added-bg: #e6f4ea;
|
||||
--removed: #a50e0e;
|
||||
--removed-bg: #fce8e6;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #111315;
|
||||
--fg: #e8eaed;
|
||||
--muted: #bdc1c6;
|
||||
--card: #1b1d20;
|
||||
--border: #3c4043;
|
||||
--added-bg: #17351f;
|
||||
--removed-bg: #3c1f1d;
|
||||
}
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font: 14px/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
}
|
||||
main {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
h2 {
|
||||
font-size: 18px;
|
||||
margin: 32px 0 8px;
|
||||
}
|
||||
.meta {
|
||||
color: var(--muted);
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
.summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 12px;
|
||||
margin: 24px 0;
|
||||
}
|
||||
.summary > div, .request, table {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.summary > div {
|
||||
padding: 14px;
|
||||
}
|
||||
.label {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.summary strong {
|
||||
display: block;
|
||||
font-size: 24px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.added-text {
|
||||
color: var(--added);
|
||||
}
|
||||
.removed-text {
|
||||
color: var(--removed);
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
th, td {
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
}
|
||||
th {
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.num {
|
||||
text-align: right;
|
||||
}
|
||||
.requests {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.request {
|
||||
padding: 14px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.request.added {
|
||||
border-left: 4px solid var(--added);
|
||||
}
|
||||
.request.removed {
|
||||
border-left: 4px solid var(--removed);
|
||||
}
|
||||
.request header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.badge, .method, .type, .status {
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.added .badge {
|
||||
background: var(--added-bg);
|
||||
color: var(--added);
|
||||
}
|
||||
.removed .badge {
|
||||
background: var(--removed-bg);
|
||||
color: var(--removed);
|
||||
}
|
||||
.method, .type, .status {
|
||||
background: color-mix(in srgb, var(--muted) 14%, transparent);
|
||||
color: var(--fg);
|
||||
}
|
||||
.url {
|
||||
display: block;
|
||||
margin: 8px 0 12px;
|
||||
color: inherit;
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 8px 16px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
dl div {
|
||||
min-width: 0;
|
||||
}
|
||||
dt {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
dd {
|
||||
margin: 0;
|
||||
}
|
||||
details {
|
||||
margin-top: 8px;
|
||||
}
|
||||
summary {
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
}
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
overflow-x: auto;
|
||||
background: color-mix(in srgb, var(--muted) 10%, transparent);
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
}
|
||||
.empty {
|
||||
color: var(--muted);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Frontend Browser Network Request Diff</h1>
|
||||
<p class="meta">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.</p>
|
||||
${renderSummary(base, head, diffs)}
|
||||
${content}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [baseFile, headFile, outputFile] = process.argv.slice(2);
|
||||
|
||||
const base = JSON.parse(await readFile(baseFile, 'utf8')) as BrowserMetricsReport;
|
||||
const head = JSON.parse(await readFile(headFile, 'utf8')) as BrowserMetricsReport;
|
||||
await writeFile(outputFile, renderHtml(base, head));
|
||||
}
|
||||
|
||||
// 直接実行されたときだけ呼ぶための判定(テストなどでこのファイル内の関数をimportするだけのとき用)
|
||||
if (process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
await main();
|
||||
}
|
||||
378
.github/scripts/frontend-browser-diagnostics.render-md.mts
vendored
Normal file
378
.github/scripts/frontend-browser-diagnostics.render-md.mts
vendored
Normal file
@@ -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 { BrowserDiagnostics, NetworkRequest } from './chrome.mts';
|
||||
|
||||
export type BrowserMeasurement = {
|
||||
label: string;
|
||||
timestamp: string;
|
||||
url: string;
|
||||
scenario: string;
|
||||
diagnostics: BrowserDiagnostics;
|
||||
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<string, {
|
||||
requests: number;
|
||||
encodedBytes: number;
|
||||
decodedBodyBytes: number;
|
||||
}>;
|
||||
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<string, number>;
|
||||
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', '<br>');
|
||||
}
|
||||
|
||||
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)}<br>± ${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)}<br>${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('Page errors', base, head, summary => summary.diagnostics.pageErrorCount, sample => sample.diagnostics.pageErrorCount, util.formatNumber, 1, !all),
|
||||
metricRow('Console log', base, head, summary => summary.diagnostics.console.log, sample => sample.diagnostics.console.log, util.formatNumber, 1, !all),
|
||||
metricRow('Console warnings', base, head, summary => summary.diagnostics.console.warning, sample => sample.diagnostics.console.warning, util.formatNumber, 1, !all),
|
||||
metricRow('Console errors', base, head, summary => summary.diagnostics.console.error, sample => sample.diagnostics.console.error, util.formatNumber, 1, !all),
|
||||
metricRow('Console info', base, head, summary => summary.diagnostics.console.info, sample => sample.diagnostics.console.info, util.formatNumber, 1, !all),
|
||||
metricRow('Page-attributed memory', base, head, summary => summary.performance.tabMemory.totalBytes, sample => sample.performance.tabMemory.totalBytes, util.formatBytes, 10000, !all),
|
||||
].filter(row => row != null);
|
||||
|
||||
return [
|
||||
'| 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 = [
|
||||
'<table>',
|
||||
'<thead>',
|
||||
'<tr>',
|
||||
'<th rowspan="2">Type</th>',
|
||||
'<th colspan="3">Requests</th>',
|
||||
'<th colspan="3">Encoded bytes</th>',
|
||||
'</tr>',
|
||||
'<tr>',
|
||||
'<th>Base</th>',
|
||||
'<th>Head</th>',
|
||||
'<th>Δ</th>',
|
||||
'<th>Base</th>',
|
||||
'<th>Head</th>',
|
||||
'<th>Δ</th>',
|
||||
'</tr>',
|
||||
'</thead>',
|
||||
'<tbody>',
|
||||
];
|
||||
|
||||
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('<tr>');
|
||||
lines.push(`<td><b>${key}</b></td>`);
|
||||
lines.push(`<td align="right">${util.formatNumber(baseRow.requests)}</td>`);
|
||||
lines.push(`<td align="right">${util.formatNumber(headRow.requests)}</td>`);
|
||||
lines.push(`<td align="right">${formatDelta(headRow.requests - baseRow.requests, util.formatNumber)}</td>`);
|
||||
lines.push(`<td align="right">${util.formatBytes(baseRow.encodedBytes)}</td>`);
|
||||
lines.push(`<td align="right">${util.formatBytes(headRow.encodedBytes)}</td>`);
|
||||
lines.push(`<td align="right">${formatDelta(headRow.encodedBytes - baseRow.encodedBytes, util.formatBytes)}</td>`);
|
||||
lines.push('</tr>');
|
||||
}
|
||||
|
||||
lines.push('</tbody>');
|
||||
lines.push('</table>');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderLargestRequests(report: BrowserMetricsReport, title: string) {
|
||||
if (report.summary.network.largestRequests.length === 0) return null;
|
||||
|
||||
const lines = [
|
||||
`<details><summary>${title}</summary>`,
|
||||
'',
|
||||
'| 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('', '</details>');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderFailedRequests(report: BrowserMetricsReport, title: string) {
|
||||
if (report.summary.network.failedRequests.length === 0) return null;
|
||||
|
||||
const lines = [
|
||||
`<details><summary>${title}</summary>`,
|
||||
'',
|
||||
'| 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('', '</details>');
|
||||
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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function renderMd(base: BrowserMetricsReport, head: BrowserMetricsReport, options: {
|
||||
baseHeapSnapshotUrl: string;
|
||||
headHeapSnapshotUrl: string;
|
||||
detailedHtmlUrl?: string;
|
||||
}) {
|
||||
const detailedHtmlUrl = options.detailedHtmlUrl;
|
||||
const heapSnapshotTable = heapSnapshotUtil.renderHeapSnapshotTable(toHeapSnapshotReport(base), toHeapSnapshotReport(head));
|
||||
const lines = [
|
||||
'## 🖥 Frontend Browser Diagnostics Report',
|
||||
'',
|
||||
renderSummaryTable(base, head),
|
||||
'',
|
||||
'<i>Only metrics showing significant changes are displayed.</i>',
|
||||
'',
|
||||
detailedHtmlUrl == null || detailedHtmlUrl === '' ? null : `[View details](${detailedHtmlUrl})`,
|
||||
detailedHtmlUrl == null || detailedHtmlUrl === '' ? null : '',
|
||||
'<details>',
|
||||
'<summary>Requests by resource type</summary>',
|
||||
'',
|
||||
renderResourceTypeTable(base, head),
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
'<details>',
|
||||
'<summary>V8 heap snapshot statistics</summary>',
|
||||
'',
|
||||
heapSnapshotTable ?? '_No V8 heap snapshot data._',
|
||||
'',
|
||||
heapSnapshotUtil.renderHeapSnapshotSankey(toHeapSnapshotReport(head), 'Head'),
|
||||
'',
|
||||
`Download representative heap snapshot: [base](${options.baseHeapSnapshotUrl}) / [head](${options.headHeapSnapshotUrl})`,
|
||||
'</details>',
|
||||
'',
|
||||
];
|
||||
|
||||
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);
|
||||
|
||||
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, {
|
||||
baseHeapSnapshotUrl: process.env.FRONTEND_BROWSER_BASE_HEAP_SNAPSHOT_ARTIFACT_URL!,
|
||||
headHeapSnapshotUrl: process.env.FRONTEND_BROWSER_HEAD_HEAP_SNAPSHOT_ARTIFACT_URL!,
|
||||
detailedHtmlUrl: process.env.FRONTEND_BROWSER_DETAILED_HTML_ARTIFACT_URL,
|
||||
}));
|
||||
}
|
||||
|
||||
// 直接実行されたときだけ呼ぶための判定(テストなどでこのファイル内の関数をimportするだけのとき用)
|
||||
if (process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
await main();
|
||||
}
|
||||
@@ -7,9 +7,9 @@ import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import * as util from './utility.mts';
|
||||
|
||||
const marker = '<!-- misskey-frontend-js-size -->';
|
||||
const marker = '<!-- misskey-frontend-bundle-diagnostics -->';
|
||||
|
||||
const locale = process.env.FRONTEND_JS_SIZE_LOCALE ?? 'ja-JP';
|
||||
const locale = 'ja-JP';
|
||||
|
||||
//function sharePercent(value, total) {
|
||||
// if (total === 0) return '0%';
|
||||
@@ -40,13 +40,24 @@ function escapeCell(value: string) {
|
||||
|
||||
type Manifest = Record<string, { file?: string; src?: string; name?: string; isEntry?: boolean; imports?: string[] }>;
|
||||
|
||||
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<string, FileEntry>;
|
||||
chunksByManifestKey: Record<string, FileEntry>;
|
||||
startupFiles: string[];
|
||||
};
|
||||
|
||||
function entryDisplayName(entry: FileEntry) {
|
||||
if (!entry) return '';
|
||||
return entry.displayName || entry.file;
|
||||
@@ -60,23 +71,26 @@ 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<string>();
|
||||
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;
|
||||
keys.add(stableChunkKey(key, chunk));
|
||||
for (const importKey of chunk.imports ?? []) {
|
||||
visit(importKey);
|
||||
}
|
||||
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, key);
|
||||
}
|
||||
|
||||
visit(entryKey);
|
||||
@@ -102,26 +116,41 @@ async function resolveBuiltFile(outDir: string, file: string) {
|
||||
};
|
||||
}
|
||||
|
||||
async function collectReport(repoDir: string) {
|
||||
async function collectReport(repoDir: string): Promise<CollectedReport> {
|
||||
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<string, FileEntry>();
|
||||
const byFile = new Set<string>();
|
||||
const chunksByFile = new Map<string, FileEntry>();
|
||||
const comparableChunks = new Map<string, FileEntry>();
|
||||
const chunksByManifestKey = new Map<string, FileEntry>();
|
||||
|
||||
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 +158,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<string>();
|
||||
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 +349,17 @@ function renderVisualizerSummaryTable(before: ReturnType<typeof collectVisualize
|
||||
];
|
||||
}
|
||||
|
||||
function getChunkComparisonRows(keys: string[], before: Awaited<ReturnType<typeof collectReport>>, after: Awaited<ReturnType<typeof collectReport>>) {
|
||||
return keys.map((key) => {
|
||||
const beforeEntry = before.chunks[key];
|
||||
const afterEntry = after.chunks[key];
|
||||
function getChunkComparisonRows(keys: string[], before: Record<string, FileEntry>, after: Record<string, FileEntry>) {
|
||||
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 +368,53 @@ function getChunkComparisonRows(keys: string[], before: Awaited<ReturnType<typeo
|
||||
});
|
||||
}
|
||||
|
||||
type ChunkComparisonRow = ReturnType<typeof getChunkComparisonRows>[number];
|
||||
|
||||
type ChunkAggregate = {
|
||||
beforeSize: number;
|
||||
afterSize: number;
|
||||
beforeCount: number;
|
||||
afterCount: number;
|
||||
};
|
||||
|
||||
const smallDeltaThreshold = 5;
|
||||
|
||||
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 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) {
|
||||
if (chunk.comparisonKey != null) entries.push([chunk.comparisonKey, chunk]);
|
||||
}
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
function summarizeChunkChanges(rows: ReturnType<typeof getChunkComparisonRows>) {
|
||||
return {
|
||||
updated: rows.filter((row) => row.changeType === 'updated').length,
|
||||
@@ -342,15 +427,28 @@ function formatChunkChangeSummary(label: string, summary: ReturnType<typeof summ
|
||||
return `${label} (${summary.updated} updated, ${summary.added} added, ${summary.removed} removed)`;
|
||||
}
|
||||
|
||||
function compareChunkComparisonRows(a: ReturnType<typeof getChunkComparisonRows>[number], b: ReturnType<typeof getChunkComparisonRows>[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 chunkMarkdownTable(rows: ReturnType<typeof getChunkComparisonRows>, total?: { beforeSize: number; afterSize: number }) {
|
||||
if (rows.length === 0) return '_No data_';
|
||||
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}`;
|
||||
}
|
||||
|
||||
function chunkMarkdownTable(
|
||||
rows: ReturnType<typeof getChunkComparisonRows>,
|
||||
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 | Δ | Δ (%) |',
|
||||
@@ -360,49 +458,58 @@ function chunkMarkdownTable(rows: ReturnType<typeof getChunkComparisonRows>, tot
|
||||
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) {
|
||||
const chunkFile = chunkFileDisplay(row);
|
||||
if (row.changeType === 'added') {
|
||||
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{orange}{\\text{( + )}}$ |`);
|
||||
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(chunkFile)}\` </details> | ${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(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{green}{\\text{( - )}}$ |`);
|
||||
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(chunkFile)}\` </details> | ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{green}{\\text{( - )}}$ |`);
|
||||
} else {
|
||||
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${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(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(chunkFile)}\` </details> | ${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) {
|
||||
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('\\%', '\\\\%')} |`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderFrontendChunkReport(before: Awaited<ReturnType<typeof collectReport>>, after: Awaited<ReturnType<typeof collectReport>>) {
|
||||
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 diffRows = changedRows.sort(compareChunkComparisonRows).slice(0, 30); // TODO: 実際に30を超えて切り捨てられたrowがあった場合はその旨をmarkdown内に表示するようにする
|
||||
const diffGenerated = generatedAggregate(before.chunks, after.chunks);
|
||||
const diffOther = comparisonRowsAggregate(changedRows.filter(hasSmallDelta));
|
||||
const diffRows = changedRows.filter(row => !hasSmallDelta(row)).sort(compareChunkComparisonRows).slice(0, 30); // TODO: 実際に30を超えて切り捨てられたrowがあった場合はその旨をmarkdown内に表示するようにする
|
||||
|
||||
const startupKeys = new Set([
|
||||
...before.startupKeys,
|
||||
...after.startupKeys,
|
||||
]);
|
||||
const startupComparisonRows = getChunkComparisonRows([...startupKeys], before, after);
|
||||
const startupRows = startupComparisonRows.sort(compareChunkComparisonRows);
|
||||
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: 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 +519,14 @@ function renderFrontendChunkReport(before: Awaited<ReturnType<typeof collectRepo
|
||||
'<details open>',
|
||||
`<summary>${formatChunkChangeSummary('Chunk size diff', diffSummary)}</summary>`,
|
||||
'',
|
||||
chunkMarkdownTable(diffRows, diffTotal),
|
||||
chunkMarkdownTable(diffRows, diffTotal, diffGenerated, diffOther),
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
'<details>',
|
||||
`<summary>${formatChunkChangeSummary('Startup chunk size', startupSummary)}</summary>`,
|
||||
'',
|
||||
chunkMarkdownTable(startupRows, startupTotal),
|
||||
chunkMarkdownTable(startupRows, startupTotal, startupGenerated, startupOther),
|
||||
'',
|
||||
`_Startup chunks are the Vite entry for \`src/_boot_.ts\` and its static imports._`,
|
||||
'',
|
||||
320
.github/scripts/heap-snapshot-util.mts
vendored
320
.github/scripts/heap-snapshot-util.mts
vendored
@@ -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<string, number>, 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<Record<keyof typeof heapSnapshotCategory, Record<string, number>>>,
|
||||
topN = defaultHeapSnapshotBreakdownTopN,
|
||||
) {
|
||||
const collapsed = {} as NonNullable<HeapSnapshotData['breakdowns']>;
|
||||
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<keyof typeof heapSnapshotCategory, Record<string, number>>;
|
||||
for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) {
|
||||
if (category !== 'total') breakdowns[category] = {};
|
||||
}
|
||||
|
||||
function addValue(map: Record<string, number>, key: string, value: number) {
|
||||
map[key] = (map[key] ?? 0) + value;
|
||||
}
|
||||
|
||||
const edgeStartIndexes = new Map<number, number>();
|
||||
const retainerCounts = new Map<number, number>();
|
||||
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<number>();
|
||||
|
||||
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<T>(
|
||||
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<HeapSnapshotData['breakdowns']>;
|
||||
for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) {
|
||||
if (category === 'total') continue;
|
||||
|
||||
const childKeys = new Set<string>();
|
||||
for (const snapshot of data) {
|
||||
for (const childKey of Object.keys(snapshot?.breakdowns?.[category] ?? {})) {
|
||||
childKeys.add(childKey);
|
||||
}
|
||||
}
|
||||
|
||||
const categoryBreakdown = {} as Record<string, number>;
|
||||
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];
|
||||
}
|
||||
|
||||
@@ -1,309 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { createRequire } from 'node:module';
|
||||
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';
|
||||
|
||||
const phases = ['afterGc'] as const;
|
||||
|
||||
export type MemoryReport = {
|
||||
timestamp: string;
|
||||
sampleCount: any;
|
||||
aggregation: string;
|
||||
measurement: {
|
||||
startupTimeoutMs: any;
|
||||
memorySettleTimeMs: any;
|
||||
ipcTimeoutMs: any;
|
||||
requestCount: any;
|
||||
heapSnapshot: {
|
||||
enabled: any;
|
||||
timeoutMs: any;
|
||||
breakdownTopN: any;
|
||||
};
|
||||
};
|
||||
summary: Record<typeof phases[number], {
|
||||
memoryUsage: Record<string, number>;
|
||||
heapSnapshot?: heapSnapshotUtil.HeapSnapshotData;
|
||||
}>;
|
||||
samples: (MemoryReportRaw['samples'][number] & {
|
||||
round: number;
|
||||
})[];
|
||||
};
|
||||
|
||||
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 HEAD_HEAP_SNAPSHOT_WORK_DIR = resolve('head-heap-snapshots');
|
||||
const HEAD_HEAP_SNAPSHOT_OUTPUT_PATH = resolve('head-heap-snapshot.heapsnapshot');
|
||||
|
||||
async function resetState(repoDir: string) {
|
||||
const require = createRequire(join(repoDir, 'packages/backend/package.json'));
|
||||
const pg = require('pg');
|
||||
const Redis = require('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 summarizeHeapSnapshotBreakdowns(samples: MemoryReport['samples'], phase: typeof phases[number]) {
|
||||
const breakdowns = {} as Record<keyof typeof heapSnapshotUtil.heapSnapshotCategory, Record<string, number>>;
|
||||
|
||||
for (const category of Object.keys(heapSnapshotUtil.heapSnapshotCategory) as (keyof typeof heapSnapshotUtil.heapSnapshotCategory)[]) {
|
||||
if (category === 'total') continue;
|
||||
|
||||
const childKeys = new Set<string>();
|
||||
for (const sample of samples) {
|
||||
for (const childKey of Object.keys(sample.phases[phase].heapSnapshot?.breakdowns?.[category] ?? {})) {
|
||||
childKeys.add(childKey);
|
||||
}
|
||||
}
|
||||
|
||||
const categoryBreakdown = {} as Record<string, number>;
|
||||
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<string, number>) {
|
||||
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'];
|
||||
|
||||
for (const phase of phases) {
|
||||
summary[phase] = {
|
||||
memoryUsage: {},
|
||||
};
|
||||
|
||||
const metricKeys = new Set<string>();
|
||||
for (const sample of samples) {
|
||||
for (const key of Object.keys(sample.phases[phase].memoryUsage)) {
|
||||
metricKeys.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of metricKeys) {
|
||||
const values = samples.map(sample => sample.phases[phase].memoryUsage[key]);
|
||||
summary[phase].memoryUsage[key] = util.median(values);
|
||||
}
|
||||
|
||||
const heapSnapshotCategoryValues = {} as Record<keyof typeof heapSnapshotUtil.heapSnapshotCategory, number>;
|
||||
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<keyof typeof heapSnapshotUtil.heapSnapshotCategory, number>;
|
||||
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 } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
async function measureRepo(label: string, repoDir: string, round: number, options: { heapSnapshotSavePath?: string } = {}) {
|
||||
process.stderr.write(`[${label}] Resetting database and Redis\n`);
|
||||
await resetState(repoDir);
|
||||
|
||||
process.stderr.write(`[${label}] Running migrations\n`);
|
||||
await util.run('pnpm', ['--filter', 'backend', 'migrate'], {
|
||||
cwd: repoDir,
|
||||
env: process.env,
|
||||
logStdout: true,
|
||||
});
|
||||
|
||||
process.stderr.write(`[${label}] Measuring memory\n`);
|
||||
const measureEnv = {
|
||||
...process.env,
|
||||
MK_MEMORY_SAMPLE_COUNT: '1',
|
||||
} 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'], {
|
||||
cwd: repoDir,
|
||||
env: measureEnv,
|
||||
});
|
||||
|
||||
const report = JSON.parse(stdout) as MemoryReportRaw;
|
||||
const sample = report.samples[0];
|
||||
|
||||
return sample;
|
||||
}
|
||||
|
||||
function headHeapSnapshotPath(round: number) {
|
||||
return join(HEAD_HEAP_SNAPSHOT_WORK_DIR, `round-${round}.heapsnapshot`);
|
||||
}
|
||||
|
||||
function selectRepresentativeHeadHeapSnapshotRound(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 saveRepresentativeHeadHeapSnapshot(samples: MemoryReport['samples'], summary: MemoryReport['summary']) {
|
||||
const round = selectRepresentativeHeadHeapSnapshotRound(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 });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const baseDir = resolve(baseDirArg);
|
||||
const headDir = resolve(headDirArg);
|
||||
const baseOutput = resolve(baseOutputArg);
|
||||
const headOutput = resolve(headOutputArg);
|
||||
const rounds = util.readIntegerEnv('MK_MEMORY_COMPARE_ROUNDS', 5, 1);
|
||||
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 });
|
||||
|
||||
const reports = {
|
||||
base: {
|
||||
dir: baseDir,
|
||||
samples: [] as MemoryReport['samples'],
|
||||
},
|
||||
head: {
|
||||
dir: headDir,
|
||||
samples: [] as MemoryReport['samples'],
|
||||
},
|
||||
};
|
||||
|
||||
for (let round = 1; round <= warmupRounds; round++) {
|
||||
process.stderr.write(`Starting warmup round ${round}/${warmupRounds}\n`);
|
||||
for (const label of ['base', 'head'] as const) {
|
||||
await measureRepo(label, reports[label].dir, -round);
|
||||
}
|
||||
}
|
||||
|
||||
for (let round = 1; round <= rounds; round++) {
|
||||
const order = round % 2 === 1 ? ['base', 'head'] as const : ['head', 'base'] as const;
|
||||
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 sample = await measureRepo(label, reports[label].dir, round, options);
|
||||
reports[label].samples.push({
|
||||
...sample,
|
||||
round,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const summaries = {
|
||||
base: summarizeSamples(reports.base.samples),
|
||||
head: summarizeSamples(reports.head.samples),
|
||||
};
|
||||
await saveRepresentativeHeadHeapSnapshot(reports.head.samples, summaries.head);
|
||||
|
||||
for (const label of ['base', 'head'] as const) {
|
||||
const report = {
|
||||
timestamp: new Date().toISOString(),
|
||||
sampleCount: reports[label].samples.length,
|
||||
aggregation: 'median',
|
||||
comparison: {
|
||||
strategy: 'interleaved-pairs',
|
||||
rounds,
|
||||
warmupRounds,
|
||||
startedAt,
|
||||
},
|
||||
summary: summaries[label],
|
||||
samples: reports[label].samples,
|
||||
};
|
||||
|
||||
await writeFile(label === 'base' ? baseOutput : headOutput, `${JSON.stringify(report, null, 2)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
75
.github/scripts/memory-stability-util.mts
vendored
Normal file
75
.github/scripts/memory-stability-util.mts
vendored
Normal file
@@ -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<void>;
|
||||
};
|
||||
|
||||
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<T extends Record<string, number>>(readings: { elapsedMs: number; memoryUsage: T }[]) {
|
||||
const result = {} as Record<typeof stabilityMetrics[number], number>;
|
||||
|
||||
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<T extends Record<string, number>>(
|
||||
readMemoryUsage: () => Promise<T>,
|
||||
timer: MemoryStabilityTimer = defaultTimer,
|
||||
) {
|
||||
const startedAt = timer.now();
|
||||
const readings: { elapsedMs: number; memoryUsage: T }[] = [];
|
||||
let maxAbsoluteSlopesKiBPerSecond: Record<typeof stabilityMetrics[number], number> | 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));
|
||||
}
|
||||
}
|
||||
110
.github/scripts/memory-stability-util.test.mts
vendored
Normal file
110
.github/scripts/memory-stability-util.test.mts
vendored
Normal file
@@ -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<string, number>[]) {
|
||||
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,
|
||||
});
|
||||
});
|
||||
94
.github/scripts/utility.mts
vendored
94
.github/scripts/utility.mts
vendored
@@ -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<string, unknown>) {
|
||||
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<void>(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();
|
||||
});
|
||||
}
|
||||
|
||||
4
.github/workflows/api-misskey-js.yml
vendored
4
.github/workflows/api-misskey-js.yml
vendored
@@ -16,10 +16,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
uses: actions/checkout@v6.0.3
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.3
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
name: Report backend memory
|
||||
name: Backend diagnostics (comment)
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
types: [completed]
|
||||
workflows:
|
||||
- Get backend memory usage # get-backend-memory.yml
|
||||
- Backend diagnostics (inspect) # backend-diagnostics.inspect.yml
|
||||
|
||||
jobs:
|
||||
compare-memory:
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
uses: actions/checkout@v6.0.3
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
@@ -33,9 +33,9 @@ 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
|
||||
uses: actions/github-script@v8
|
||||
- name: Find heap snapshot artifacts
|
||||
id: find-heap-snapshot-artifacts
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
@@ -45,15 +45,22 @@ 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 }}
|
||||
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
|
||||
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
|
||||
- uses: thollander/actions-comment-pull-request@v3
|
||||
with:
|
||||
pr-number: ${{ steps.load-pr-num.outputs.pr-number }}
|
||||
@@ -1,5 +1,5 @@
|
||||
# this name is used in report-backend-memory.yml so be careful when change name
|
||||
name: Get backend memory usage
|
||||
# this name is used in backend-diagnostics.comment.yml so be careful when change name
|
||||
name: Backend diagnostics (inspect)
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -10,13 +10,11 @@ on:
|
||||
- packages/backend/**
|
||||
- packages/misskey-js/**
|
||||
- .github/scripts/utility.mts
|
||||
- .github/scripts/backend-memory-report.mts
|
||||
- .github/scripts/measure-backend-memory-comparison.mts
|
||||
- .github/scripts/backend-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/scripts/backend-diagnostics.render-md.mts
|
||||
- .github/scripts/backend-diagnostics.inspect.mts
|
||||
- .github/scripts/memory-stability-util*.mts
|
||||
- .github/workflows/backend-diagnostics.inspect.yml
|
||||
- .github/workflows/backend-diagnostics.comment.yml
|
||||
|
||||
jobs:
|
||||
get-memory-usage:
|
||||
@@ -39,19 +37,19 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout base
|
||||
uses: actions/checkout@v6.0.2
|
||||
uses: actions/checkout@v6.0.3
|
||||
with:
|
||||
ref: ${{ github.base_ref }}
|
||||
path: base
|
||||
submodules: true
|
||||
- name: Checkout head
|
||||
uses: actions/checkout@v6.0.2
|
||||
uses: actions/checkout@v6.0.3
|
||||
with:
|
||||
ref: refs/pull/${{ github.event.number }}/merge
|
||||
path: head
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.3
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
with:
|
||||
package_json_file: head/package.json
|
||||
- name: Use Node.js
|
||||
@@ -92,21 +90,24 @@ 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
|
||||
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:
|
||||
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
|
||||
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:
|
||||
@@ -114,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
|
||||
2
.github/workflows/changelog-check.yml
vendored
2
.github/workflows/changelog-check.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout head
|
||||
uses: actions/checkout@v6.0.2
|
||||
uses: actions/checkout@v6.0.3
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
|
||||
@@ -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.2
|
||||
uses: actions/checkout@v6.0.3
|
||||
with:
|
||||
submodules: true
|
||||
persist-credentials: false
|
||||
@@ -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.2
|
||||
uses: actions/checkout@v6.0.3
|
||||
with:
|
||||
submodules: true
|
||||
persist-credentials: false
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
uses: actions/checkout@v6.0.3
|
||||
- name: Check version
|
||||
run: |
|
||||
if [ "$(jq -r '.version' package.json)" != "$(jq -r '.version' packages/misskey-js/package.json)" ]; then
|
||||
|
||||
3
.github/workflows/check-spdx-license-id.yml
vendored
3
.github/workflows/check-spdx-license-id.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
uses: actions/checkout@v6.0.3
|
||||
- name: Check
|
||||
run: |
|
||||
counter=0
|
||||
@@ -44,7 +44,6 @@ jobs:
|
||||
}
|
||||
|
||||
directories=(
|
||||
"cypress/e2e"
|
||||
"packages/backend/migration"
|
||||
"packages/backend/src"
|
||||
"packages/backend/test"
|
||||
|
||||
2
.github/workflows/check_copyright_year.yml
vendored
2
.github/workflows/check_copyright_year.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
||||
check_copyright_year:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.2
|
||||
- uses: actions/checkout@v6.0.3
|
||||
- run: |
|
||||
if [ "$(grep Copyright COPYING | sed -e 's/.*2014-\([0-9]*\) .*/\1/g')" -ne "$(date +%Y)" ]; then
|
||||
echo "Please change copyright year!"
|
||||
|
||||
2
.github/workflows/docker-develop.yml
vendored
2
.github/workflows/docker-develop.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v6.0.2
|
||||
uses: actions/checkout@v6.0.3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
- name: Log in to Docker Hub
|
||||
|
||||
2
.github/workflows/docker.yml
vendored
2
.github/workflows/docker.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v6.0.2
|
||||
uses: actions/checkout@v6.0.3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
- name: Docker meta
|
||||
|
||||
2
.github/workflows/dockle.yml
vendored
2
.github/workflows/dockle.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
DOCKLE_VERSION: 0.4.15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.2
|
||||
- uses: actions/checkout@v6.0.3
|
||||
|
||||
- name: Download and install dockle v${{ env.DOCKLE_VERSION }}
|
||||
run: |
|
||||
|
||||
44
.github/workflows/frontend-browser-diagnostics.comment.yml
vendored
Normal file
44
.github/workflows/frontend-browser-diagnostics.comment.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
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
|
||||
210
.github/workflows/frontend-browser-diagnostics.inspect.yml
vendored
Normal file
210
.github/workflows/frontend-browser-diagnostics.inspect.yml
vendored
Normal file
@@ -0,0 +1,210 @@
|
||||
name: Frontend browser diagnostics (inspect)
|
||||
|
||||
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/heap-snapshot-util.mts
|
||||
- .github/scripts/chrome.mts
|
||||
- .github/scripts/frontend-browser-diagnostics.render-additional-html.mts
|
||||
- .github/scripts/frontend-browser-diagnostics.render-md.mts
|
||||
- .github/scripts/frontend-browser-diagnostics.inspect.mts
|
||||
- .github/workflows/frontend-browser-diagnostics.inspect.yml
|
||||
- .github/workflows/frontend-browser-diagnostics.comment.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: frontend-browser-diagnostics-inspect-${{ 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.3
|
||||
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.3
|
||||
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.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: 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-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"
|
||||
|
||||
- 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:
|
||||
path: head-heap-snapshot.heapsnapshot
|
||||
archive: false
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
- name: Generate browser detailed html
|
||||
shell: bash
|
||||
run: |
|
||||
REPORT_DIR="$RUNNER_TEMP/frontend-browser-diagnostics"
|
||||
test -s "$REPORT_DIR/before-browser.json"
|
||||
test -s "$REPORT_DIR/after-browser.json"
|
||||
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-diagnostics/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_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"
|
||||
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"
|
||||
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-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-diagnostics.md" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload browser metrics report
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: frontend-browser-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
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
44
.github/workflows/frontend-bundle-diagnostics.comment.yml
vendored
Normal file
44
.github/workflows/frontend-bundle-diagnostics.comment.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
name: Frontend bundle diagnostics (comment)
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- Frontend bundle diagnostics (inspect)
|
||||
types:
|
||||
- completed
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
name: Comment frontend bundle report
|
||||
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: frontend-bundle-diagnostics-report-${{ github.event.workflow_run.id }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Download bundle report
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: frontend-bundle-report
|
||||
path: ${{ runner.temp }}/frontend-bundle-report
|
||||
github-token: ${{ github.token }}
|
||||
repository: ${{ github.repository }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
|
||||
- name: Load PR number
|
||||
id: load-pr-number
|
||||
shell: bash
|
||||
run: echo "pr-number=$(cat "$RUNNER_TEMP/frontend-bundle-report/pr-number.txt")" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Comment on pull request
|
||||
uses: thollander/actions-comment-pull-request@v3
|
||||
with:
|
||||
pr-number: ${{ steps.load-pr-number.outputs.pr-number }}
|
||||
comment-tag: frontend-bundle-diagnostics
|
||||
file-path: ${{ runner.temp }}/frontend-bundle-report/frontend-bundle-diagnostics-report.md
|
||||
@@ -1,4 +1,4 @@
|
||||
name: frontend-bundle-report
|
||||
name: Frontend bundle diagnostics (inspect)
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -21,27 +21,25 @@ on:
|
||||
- pnpm-workspace.yaml
|
||||
- .node-version
|
||||
- .github/scripts/utility.mts
|
||||
- .github/scripts/frontend-js-size.mts
|
||||
- .github/workflows/frontend-bundle-report.yml
|
||||
- .github/workflows/frontend-bundle-report-comment.yml
|
||||
- .github/scripts/frontend-bundle-diagnostics.render-md.mts
|
||||
- .github/workflows/frontend-bundle-diagnostics.inspect.yml
|
||||
- .github/workflows/frontend-bundle-diagnostics.comment.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: frontend-bundle-report-${{ github.event.pull_request.number }}
|
||||
group: frontend-bundle-diagnostics-inspect-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
report:
|
||||
name: Build frontend bundle report
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
FRONTEND_JS_SIZE_LOCALE: ja-JP
|
||||
steps:
|
||||
- name: Checkout base
|
||||
uses: actions/checkout@v6.0.2
|
||||
uses: actions/checkout@v6.0.3
|
||||
with:
|
||||
repository: ${{ github.event.pull_request.base.repo.full_name }}
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
@@ -49,32 +47,19 @@ jobs:
|
||||
submodules: true
|
||||
|
||||
- name: Checkout pull request
|
||||
uses: actions/checkout@v6.0.2
|
||||
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: 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.3
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
with:
|
||||
package_json_file: after/package.json
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.check-base-visualizer.outputs.supported == 'true'
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
node-version-file: after/.node-version
|
||||
@@ -84,21 +69,17 @@ jobs:
|
||||
after/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies for base
|
||||
if: steps.check-base-visualizer.outputs.supported == 'true'
|
||||
working-directory: before
|
||||
run: pnpm i --frozen-lockfile
|
||||
|
||||
- name: Build frontend dependencies for base
|
||||
if: steps.check-base-visualizer.outputs.supported == 'true'
|
||||
working-directory: before
|
||||
run: pnpm --filter "frontend^..." run build
|
||||
|
||||
- name: Prepare report output
|
||||
if: steps.check-base-visualizer.outputs.supported == 'true'
|
||||
run: mkdir -p "$RUNNER_TEMP/frontend-bundle-report"
|
||||
|
||||
- name: Build frontend report for base
|
||||
if: steps.check-base-visualizer.outputs.supported == 'true'
|
||||
working-directory: before
|
||||
env:
|
||||
FRONTEND_BUNDLE_VISUALIZER: 'true'
|
||||
@@ -106,17 +87,14 @@ jobs:
|
||||
run: pnpm --filter frontend run build
|
||||
|
||||
- name: Install dependencies for pull request
|
||||
if: steps.check-base-visualizer.outputs.supported == 'true'
|
||||
working-directory: after
|
||||
run: pnpm i --frozen-lockfile
|
||||
|
||||
- name: Build frontend dependencies for pull request
|
||||
if: steps.check-base-visualizer.outputs.supported == 'true'
|
||||
working-directory: after
|
||||
run: pnpm --filter "frontend^..." run build
|
||||
|
||||
- name: Build frontend report for pull request
|
||||
if: steps.check-base-visualizer.outputs.supported == 'true'
|
||||
working-directory: after
|
||||
env:
|
||||
FRONTEND_BUNDLE_VISUALIZER: 'true'
|
||||
@@ -125,7 +103,6 @@ jobs:
|
||||
run: pnpm --filter frontend run build
|
||||
|
||||
- name: Upload bundle visualizer
|
||||
if: steps.check-base-visualizer.outputs.supported == 'true'
|
||||
id: upload-bundle-visualizer
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
@@ -136,7 +113,6 @@ jobs:
|
||||
retention-days: 7
|
||||
|
||||
- name: Generate report markdown
|
||||
if: steps.check-base-visualizer.outputs.supported == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
@@ -145,23 +121,21 @@ jobs:
|
||||
FRONTEND_BUNDLE_REPORT_ARTIFACT_URL: ${{ steps.upload-bundle-visualizer.outputs.artifact-url }}
|
||||
run: |
|
||||
REPORT_DIR="$RUNNER_TEMP/frontend-bundle-report"
|
||||
node after/.github/scripts/frontend-js-size.mts before after "$REPORT_DIR/before-stats.json" "$REPORT_DIR/after-stats.json" "$REPORT_DIR/frontend-js-size-report.md"
|
||||
node after/.github/scripts/frontend-bundle-diagnostics.render-md.mts before after "$REPORT_DIR/before-stats.json" "$REPORT_DIR/after-stats.json" "$REPORT_DIR/frontend-bundle-diagnostics-report.md"
|
||||
printf '%s\n' "$PR_NUMBER" > "$REPORT_DIR/pr-number.txt"
|
||||
printf '%s\n' "$BASE_SHA" > "$REPORT_DIR/base-sha.txt"
|
||||
printf '%s\n' "$HEAD_SHA" > "$REPORT_DIR/head-sha.txt"
|
||||
printf '%s\n' "${{ github.event.pull_request.html_url }}" > "$REPORT_DIR/pr-url.txt"
|
||||
|
||||
- name: Check report
|
||||
if: steps.check-base-visualizer.outputs.supported == 'true'
|
||||
run: |
|
||||
REPORT_DIR="$RUNNER_TEMP/frontend-bundle-report"
|
||||
test -s "$REPORT_DIR/before-stats.json"
|
||||
test -s "$REPORT_DIR/after-stats.json"
|
||||
test -s "$REPORT_DIR/frontend-js-size-report.md"
|
||||
cat "$REPORT_DIR/frontend-js-size-report.md" >> "$GITHUB_STEP_SUMMARY"
|
||||
test -s "$REPORT_DIR/frontend-bundle-diagnostics-report.md"
|
||||
cat "$REPORT_DIR/frontend-bundle-diagnostics-report.md" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload bundle report
|
||||
if: steps.check-base-visualizer.outputs.supported == 'true'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: frontend-bundle-report
|
||||
318
.github/workflows/frontend-bundle-report-comment.yml
vendored
318
.github/workflows/frontend-bundle-report-comment.yml
vendored
@@ -1,318 +0,0 @@
|
||||
name: frontend-bundle-report-comment
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- frontend-bundle-report
|
||||
types:
|
||||
- completed
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
paths:
|
||||
- packages/frontend/**
|
||||
- packages/frontend-shared/**
|
||||
- packages/frontend-builder/**
|
||||
- packages/i18n/**
|
||||
- packages/icons-subsetter/**
|
||||
- packages/misskey-js/**
|
||||
- packages/misskey-reversi/**
|
||||
- packages/misskey-bubble-game/**
|
||||
- package.json
|
||||
- pnpm-lock.yaml
|
||||
- pnpm-workspace.yaml
|
||||
- .node-version
|
||||
- .github/scripts/utility.mts
|
||||
- .github/scripts/frontend-js-size.mts
|
||||
- .github/workflows/frontend-bundle-report.yml
|
||||
- .github/workflows/frontend-bundle-report-comment.yml
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
name: Comment frontend bundle report
|
||||
if: github.event_name == 'pull_request_target' || (github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success')
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: frontend-bundle-report-comment-${{ github.event.pull_request.number || github.event.workflow_run.id }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Find bundle report run
|
||||
if: github.event_name == 'pull_request_target'
|
||||
id: find-report-run
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const workflow_id = 'frontend-bundle-report.yml';
|
||||
const artifactName = 'frontend-bundle-report';
|
||||
const headSha = context.payload.pull_request.head.sha;
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
const pollIntervalMs = 30_000;
|
||||
const timeoutMs = 90 * 60_000;
|
||||
const startedAt = Date.now();
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
async function listReportWorkflowRuns() {
|
||||
const runsForHead = await github.paginate(github.rest.actions.listWorkflowRuns, {
|
||||
owner,
|
||||
repo,
|
||||
workflow_id,
|
||||
event: 'pull_request',
|
||||
head_sha: headSha,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
if (runsForHead.length > 0) {
|
||||
return runsForHead;
|
||||
}
|
||||
|
||||
const recentRuns = await github.paginate(github.rest.actions.listWorkflowRuns, {
|
||||
owner,
|
||||
repo,
|
||||
workflow_id,
|
||||
event: 'pull_request',
|
||||
per_page: 100,
|
||||
});
|
||||
return recentRuns.filter((run) =>
|
||||
run.pull_requests?.some((pullRequest) => pullRequest.number === prNumber));
|
||||
}
|
||||
|
||||
async function findReportRun() {
|
||||
const runs = (await listReportWorkflowRuns())
|
||||
.sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at));
|
||||
|
||||
for (const run of runs) {
|
||||
if (run.status !== 'completed') continue;
|
||||
if (run.conclusion !== 'success') {
|
||||
core.warning(`Frontend bundle report run ${run.id} completed with conclusion: ${run.conclusion}`);
|
||||
return { done: true, run: null };
|
||||
}
|
||||
|
||||
const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
|
||||
owner,
|
||||
repo,
|
||||
run_id: run.id,
|
||||
per_page: 100,
|
||||
});
|
||||
const report = artifacts.find((artifact) => artifact.name === artifactName && !artifact.expired);
|
||||
if (report) return { done: true, run };
|
||||
|
||||
core.info(`Frontend bundle report run ${run.id} did not produce ${artifactName}.`);
|
||||
return { done: true, run: null };
|
||||
}
|
||||
|
||||
return { done: false, run: null };
|
||||
}
|
||||
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const { done, run } = await findReportRun();
|
||||
if (run) {
|
||||
core.info(`Found frontend bundle report on workflow run ${run.id}.`);
|
||||
core.setOutput('run-id', String(run.id));
|
||||
return;
|
||||
}
|
||||
if (done) {
|
||||
return;
|
||||
}
|
||||
|
||||
core.info('Waiting for frontend bundle report artifact...');
|
||||
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
||||
}
|
||||
|
||||
core.warning(`Timed out waiting for ${artifactName} from ${workflow_id} for ${headSha}.`);
|
||||
|
||||
- name: Find bundle report artifact
|
||||
if: github.event_name == 'workflow_run'
|
||||
id: find-report-artifact
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const artifactName = 'frontend-bundle-report';
|
||||
const { owner, repo } = context.repo;
|
||||
const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
|
||||
owner,
|
||||
repo,
|
||||
run_id: context.payload.workflow_run.id,
|
||||
per_page: 100,
|
||||
});
|
||||
const report = artifacts.find((artifact) => artifact.name === artifactName && !artifact.expired);
|
||||
if (report) {
|
||||
core.setOutput('exists', 'true');
|
||||
} else {
|
||||
core.info(`Workflow run ${context.payload.workflow_run.id} did not produce ${artifactName}.`);
|
||||
core.setOutput('exists', 'false');
|
||||
}
|
||||
|
||||
- name: Download bundle report from workflow_run
|
||||
if: github.event_name == 'workflow_run' && steps.find-report-artifact.outputs.exists == 'true'
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: frontend-bundle-report
|
||||
path: ${{ runner.temp }}/frontend-bundle-report
|
||||
github-token: ${{ github.token }}
|
||||
repository: ${{ github.repository }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
|
||||
- name: Download bundle report from pull_request_target
|
||||
if: github.event_name == 'pull_request_target' && steps.find-report-run.outputs.run-id != ''
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: frontend-bundle-report
|
||||
path: ${{ runner.temp }}/frontend-bundle-report
|
||||
github-token: ${{ github.token }}
|
||||
repository: ${{ github.repository }}
|
||||
run-id: ${{ steps.find-report-run.outputs.run-id }}
|
||||
|
||||
- name: Comment on pull request
|
||||
if: (github.event_name == 'workflow_run' && steps.find-report-artifact.outputs.exists == 'true') || steps.find-report-run.outputs.run-id != ''
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
github-token: ${{ secrets.FRONTEND_BUNDLE_REPORT_COMMENT_TOKEN || secrets.FRONTEND_JS_SIZE_COMMENT_TOKEN || secrets.FRONTEND_BUNDLE_VISUALIZER_COMMENT_TOKEN || github.token }}
|
||||
script: |
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const jsSizeMarker = '<!-- misskey-frontend-js-size -->';
|
||||
const visualizerMarker = '<!-- misskey-frontend-bundle-visualizer -->';
|
||||
const reportMarkers = [jsSizeMarker, visualizerMarker];
|
||||
const reportDir = path.join(process.env.RUNNER_TEMP, 'frontend-bundle-report');
|
||||
const jsSizeReportPath = path.join(reportDir, 'frontend-js-size-report.md');
|
||||
const prNumberPath = path.join(reportDir, 'pr-number.txt');
|
||||
const headShaPath = path.join(reportDir, 'head-sha.txt');
|
||||
const workflowRun = context.payload.workflow_run;
|
||||
const pullRequest = context.payload.pull_request;
|
||||
const eventHeadSha = workflowRun?.head_sha ?? pullRequest?.head?.sha ?? null;
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
if (!fs.existsSync(jsSizeReportPath)) {
|
||||
core.setFailed('The frontend bundle report artifact does not contain frontend-js-size-report.md.');
|
||||
return;
|
||||
}
|
||||
|
||||
const artifactHeadSha = fs.existsSync(headShaPath)
|
||||
? fs.readFileSync(headShaPath, 'utf8').trim()
|
||||
: null;
|
||||
if (eventHeadSha != null && artifactHeadSha != null && artifactHeadSha !== eventHeadSha) {
|
||||
core.info(`The artifact head SHA (${artifactHeadSha}) differs from the event head SHA (${eventHeadSha}). Using artifact metadata for PR validation.`);
|
||||
}
|
||||
const reportHeadSha = artifactHeadSha ?? eventHeadSha;
|
||||
|
||||
const artifactPrNumber = fs.existsSync(prNumberPath)
|
||||
? Number(fs.readFileSync(prNumberPath, 'utf8').trim())
|
||||
: null;
|
||||
let issue_number = null;
|
||||
if (pullRequest != null) {
|
||||
issue_number = pullRequest.number;
|
||||
if (Number.isInteger(artifactPrNumber) && artifactPrNumber !== issue_number) {
|
||||
core.setFailed(`The artifact pull request number (${artifactPrNumber}) does not match the event pull request number (${issue_number}).`);
|
||||
return;
|
||||
}
|
||||
} else if (workflowRun != null) {
|
||||
const associatedPullRequests = new Map();
|
||||
for (const pullRequest of workflowRun.pull_requests ?? []) {
|
||||
if (Number.isInteger(pullRequest.number)) {
|
||||
associatedPullRequests.set(pullRequest.number, pullRequest);
|
||||
}
|
||||
}
|
||||
|
||||
if (reportHeadSha != null) {
|
||||
const pullRequestsForCommit = await github.paginate(github.rest.repos.listPullRequestsAssociatedWithCommit, {
|
||||
owner,
|
||||
repo,
|
||||
commit_sha: reportHeadSha,
|
||||
per_page: 100,
|
||||
});
|
||||
for (const pullRequest of pullRequestsForCommit) {
|
||||
associatedPullRequests.set(pullRequest.number, pullRequest);
|
||||
}
|
||||
}
|
||||
|
||||
if (Number.isInteger(artifactPrNumber) && associatedPullRequests.has(artifactPrNumber)) {
|
||||
issue_number = artifactPrNumber;
|
||||
} else if (Number.isInteger(artifactPrNumber) && associatedPullRequests.size === 0) {
|
||||
issue_number = artifactPrNumber;
|
||||
} else if (!Number.isInteger(artifactPrNumber) && associatedPullRequests.size === 1) {
|
||||
issue_number = [...associatedPullRequests.keys()][0];
|
||||
} else if (Number.isInteger(artifactPrNumber)) {
|
||||
core.setFailed(`The artifact pull request number (${artifactPrNumber}) is not associated with ${reportHeadSha}.`);
|
||||
return;
|
||||
} else {
|
||||
core.setFailed(`Could not determine the pull request associated with ${reportHeadSha}.`);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
core.setFailed('Could not determine the pull request event for this report.');
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPullRequest = await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: issue_number,
|
||||
});
|
||||
const currentHeadSha = currentPullRequest.data.head?.sha;
|
||||
if (reportHeadSha != null && currentHeadSha != null && reportHeadSha !== currentHeadSha) {
|
||||
core.info(`The report head SHA (${reportHeadSha}) is not the current pull request head SHA (${currentHeadSha}). Skipping stale frontend bundle report.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const jsSizeReport = fs.readFileSync(jsSizeReportPath, 'utf8').trim();
|
||||
if (!jsSizeReport.includes(jsSizeMarker)) {
|
||||
core.setFailed('The frontend JS size report is missing the expected marker.');
|
||||
return;
|
||||
}
|
||||
let body = `${jsSizeReport}\n`;
|
||||
|
||||
const maxCommentLength = 65_000;
|
||||
if (body.length > maxCommentLength) {
|
||||
const reportLocation = workflowRun?.html_url != null
|
||||
? `[workflow run](${workflowRun.html_url})`
|
||||
: 'workflow artifact';
|
||||
const footer = [
|
||||
'',
|
||||
'',
|
||||
`_Report truncated because it exceeded ${maxCommentLength.toLocaleString('en-US')} characters. See the ${reportLocation} for the full report._`,
|
||||
].join('\n');
|
||||
body = `${body.slice(0, maxCommentLength - footer.length)}${footer}`;
|
||||
}
|
||||
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
per_page: 100,
|
||||
});
|
||||
const previousReports = comments.filter((comment) =>
|
||||
comment.user?.type === 'Bot' && reportMarkers.some((reportMarker) => comment.body?.includes(reportMarker)));
|
||||
|
||||
if (previousReports.length > 0) {
|
||||
const [previous, ...duplicates] = previousReports;
|
||||
await github.rest.issues.updateComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: previous.id,
|
||||
body,
|
||||
});
|
||||
for (const duplicate of duplicates) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: duplicate.id,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
body,
|
||||
});
|
||||
}
|
||||
4
.github/workflows/get-api-diff.yml
vendored
4
.github/workflows/get-api-diff.yml
vendored
@@ -25,12 +25,12 @@ jobs:
|
||||
ref: refs/pull/${{ github.event.number }}/merge
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.2
|
||||
- uses: actions/checkout@v6.0.3
|
||||
with:
|
||||
ref: ${{ matrix.ref }}
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.3
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
|
||||
35
.github/workflows/lint.yml
vendored
35
.github/workflows/lint.yml
vendored
@@ -17,7 +17,9 @@ on:
|
||||
- packages/misskey-bubble-game/**
|
||||
- packages/misskey-reversi/**
|
||||
- packages/shared/eslint.config.js
|
||||
- scripts/check-dts*.mjs
|
||||
- .github/workflows/lint.yml
|
||||
- package.json
|
||||
pull_request:
|
||||
paths:
|
||||
- packages/backend/**
|
||||
@@ -31,17 +33,19 @@ on:
|
||||
- packages/misskey-bubble-game/**
|
||||
- packages/misskey-reversi/**
|
||||
- packages/shared/eslint.config.js
|
||||
- scripts/check-dts*.mjs
|
||||
- .github/workflows/lint.yml
|
||||
- package.json
|
||||
jobs:
|
||||
pnpm_install:
|
||||
runs-on: ubuntu-latest
|
||||
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'
|
||||
@@ -69,12 +73,12 @@ jobs:
|
||||
eslint-cache-version: v1
|
||||
eslint-cache-path: ${{ github.workspace }}/node_modules/.cache/eslint-${{ matrix.workspace }}
|
||||
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'
|
||||
@@ -100,12 +104,12 @@ jobs:
|
||||
- sw
|
||||
- misskey-js
|
||||
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'
|
||||
@@ -113,3 +117,22 @@ jobs:
|
||||
- run: pnpm i --frozen-lockfile
|
||||
- run: pnpm --filter "${{ matrix.workspace }}^..." run build
|
||||
- run: pnpm --filter ${{ matrix.workspace }} run typecheck
|
||||
|
||||
check-dts:
|
||||
needs: [pnpm_install]
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
- uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
node-version-file: '.node-version'
|
||||
cache: 'pnpm'
|
||||
- run: pnpm i --frozen-lockfile
|
||||
- run: node --test scripts/check-dts.test.mjs
|
||||
- run: pnpm check-dts
|
||||
|
||||
6
.github/workflows/locale.yml
vendored
6
.github/workflows/locale.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Lint
|
||||
name: Locale
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -16,12 +16,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"
|
||||
|
||||
4
.github/workflows/on-release-created.yml
vendored
4
.github/workflows/on-release-created.yml
vendored
@@ -16,11 +16,11 @@ jobs:
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.2
|
||||
- uses: actions/checkout@v6.0.3
|
||||
with:
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.3
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
|
||||
2
.github/workflows/report-api-diff.yml
vendored
2
.github/workflows/report-api-diff.yml
vendored
@@ -65,7 +65,7 @@ jobs:
|
||||
echo '```diff' >> ./output.md
|
||||
cat ./api.json.diff >> ./output.md
|
||||
echo '```' >> ./output.md
|
||||
echo '</details>' >> ./output.md
|
||||
echo '</details>' >> .output.md
|
||||
fi
|
||||
|
||||
echo "$FOOTER" >> ./output.md
|
||||
|
||||
6
.github/workflows/storybook.yml
vendored
6
.github/workflows/storybook.yml
vendored
@@ -22,12 +22,12 @@ jobs:
|
||||
NODE_OPTIONS: "--max_old_space_size=7168"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.2
|
||||
- uses: actions/checkout@v6.0.3
|
||||
if: github.event_name != 'pull_request_target'
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
- uses: actions/checkout@v6.0.2
|
||||
- uses: actions/checkout@v6.0.3
|
||||
if: github.event_name == 'pull_request_target'
|
||||
with:
|
||||
fetch-depth: 0
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
if: github.event_name == 'pull_request_target'
|
||||
run: git checkout "$(git rev-list --parents -n1 HEAD | cut -d" " -f3)"
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.3
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
|
||||
14
.github/workflows/test-backend.yml
vendored
14
.github/workflows/test-backend.yml
vendored
@@ -43,7 +43,7 @@ jobs:
|
||||
ports:
|
||||
- 56312:6379
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.42.1
|
||||
image: getmeili/meilisearch:v1.48.1
|
||||
ports:
|
||||
- 57712:7700
|
||||
env:
|
||||
@@ -51,11 +51,11 @@ jobs:
|
||||
MEILI_ENV: development
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.2
|
||||
- uses: actions/checkout@v6.0.3
|
||||
with:
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.3
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
- name: Install FFmpeg
|
||||
run: |
|
||||
sudo apt install -y ffmpeg
|
||||
@@ -103,11 +103,11 @@ jobs:
|
||||
- 56312:6379
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.2
|
||||
- uses: actions/checkout@v6.0.3
|
||||
with:
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.3
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
@@ -147,11 +147,11 @@ jobs:
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.2
|
||||
- uses: actions/checkout@v6.0.3
|
||||
with:
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.3
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
- name: Get current date
|
||||
id: current-date
|
||||
run: echo "today=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
|
||||
|
||||
2
.github/workflows/test-federation.yml
vendored
2
.github/workflows/test-federation.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
with:
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.3
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
- name: Install FFmpeg
|
||||
run: |
|
||||
sudo apt install -y ffmpeg
|
||||
|
||||
48
.github/workflows/test-frontend.yml
vendored
48
.github/workflows/test-frontend.yml
vendored
@@ -28,11 +28,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.2
|
||||
- uses: actions/checkout@v6.0.3
|
||||
with:
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.3
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
@@ -57,11 +57,6 @@ jobs:
|
||||
name: E2E tests (frontend)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
browser: [chrome]
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18
|
||||
@@ -76,17 +71,11 @@ jobs:
|
||||
- 56312:6379
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.2
|
||||
- uses: actions/checkout@v6.0.3
|
||||
with:
|
||||
submodules: true
|
||||
# https://github.com/cypress-io/cypress-docker-images/issues/150
|
||||
#- name: Install mplayer for FireFox
|
||||
# run: sudo apt install mplayer -y
|
||||
# if: ${{ matrix.browser == 'firefox' }}
|
||||
#- uses: browser-actions/setup-firefox@latest
|
||||
# if: ${{ matrix.browser == 'firefox' }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.3
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
@@ -97,29 +86,14 @@ jobs:
|
||||
run: cp .github/misskey/test.yml .config
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
# https://github.com/cypress-io/cypress/issues/4351#issuecomment-559489091
|
||||
- name: ALSA Env
|
||||
run: echo -e 'pcm.!default {\n type hw\n card 0\n}\n\nctl.!default {\n type hw\n card 0\n}' > ~/.asoundrc
|
||||
# XXX: This tries reinstalling Cypress if the binary is not cached
|
||||
# Remove this when the cache issue is fixed
|
||||
- name: Cypress install
|
||||
run: pnpm exec cypress install
|
||||
- name: Cypress run
|
||||
uses: cypress-io/github-action@v7.1.9
|
||||
- name: Playwright install
|
||||
working-directory: packages/frontend
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
- name: Test
|
||||
run: pnpm start-server-and-test start:test http://localhost:61812 "pnpm --filter frontend test:e2e"
|
||||
timeout-minutes: 15
|
||||
with:
|
||||
install: false
|
||||
start: pnpm start:test
|
||||
wait-on: 'http://localhost:61812'
|
||||
headed: true
|
||||
browser: ${{ matrix.browser }}
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: failure()
|
||||
with:
|
||||
name: ${{ matrix.browser }}-cypress-screenshots
|
||||
path: cypress/screenshots
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: ${{ matrix.browser }}-cypress-videos
|
||||
path: cypress/videos
|
||||
name: playwright-e2e-artifacts
|
||||
path: packages/frontend/test/e2e/artifacts
|
||||
|
||||
4
.github/workflows/test-misskey-js.yml
vendored
4
.github/workflows/test-misskey-js.yml
vendored
@@ -22,10 +22,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
uses: actions/checkout@v6.0.3
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.3
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
|
||||
4
.github/workflows/test-production.yml
vendored
4
.github/workflows/test-production.yml
vendored
@@ -16,11 +16,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.2
|
||||
- uses: actions/checkout@v6.0.3
|
||||
with:
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.3
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
|
||||
4
.github/workflows/validate-api-json.yml
vendored
4
.github/workflows/validate-api-json.yml
vendored
@@ -17,11 +17,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.2
|
||||
- uses: actions/checkout@v6.0.3
|
||||
with:
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.3
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -23,9 +23,8 @@ packages/sw/.yarn/cache
|
||||
# pnpm
|
||||
.pnpm-store
|
||||
|
||||
# Cypress
|
||||
cypress/screenshots
|
||||
cypress/videos
|
||||
# Playwright
|
||||
packages/frontend/test/e2e/artifacts
|
||||
|
||||
# Coverage
|
||||
coverage
|
||||
@@ -35,7 +34,7 @@ coverage
|
||||
!/.config/example.yml
|
||||
!/.config/docker_example.yml
|
||||
!/.config/docker_example.env
|
||||
!/.config/cypress-devcontainer.yml
|
||||
!/.config/playwright-devcontainer.yml
|
||||
docker-compose.yml
|
||||
./compose.yml
|
||||
.devcontainer/compose.yml
|
||||
|
||||
@@ -1 +1 @@
|
||||
22.23.1
|
||||
26.4.0
|
||||
|
||||
49
CHANGELOG.md
49
CHANGELOG.md
@@ -1,25 +1,64 @@
|
||||
## 2026.6.1
|
||||
## 2026.7.0
|
||||
|
||||
### Note
|
||||
|
||||
**今回のリリースではMisskeyの各種動作要件が変更されます。必ずアップグレード前にお使いの環境をご確認ください。**
|
||||
|
||||
### Note
|
||||
- センシティブメディアの判定 (NSFW検出) が、本体に内蔵された nsfwjs による推論から、外部サービス [sensitive-detector](https://github.com/misskey-dev/sensitive-detector) への HTTP 呼び出し方式に変更されました。
|
||||
- これに伴い、本体から `nsfwjs` / `@tensorflow/tfjs` / `@tensorflow/tfjs-node` および同梱の NSFW 判定モデルが削除され、インストール要件 (ネイティブ ML スタック) が緩和されました。
|
||||
- **センシティブ判定機能を利用しているサーバーは対応が必要です。** 別途 [sensitive-detector](https://github.com/misskey-dev/sensitive-detector) サービスを立ち上げ、コントロールパネルの「モデレーション > センシティブなメディアの検出」で接続先 URL を設定してください。接続先が未設定の場合、センシティブ判定は行われません (すべて非センシティブ扱い)。
|
||||
- 画像の正規化・動画フレームの抽出・しきい値判定・集約は引き続き本体側で行われ、外部サービスには正規化済み画像の推論のみを委譲します。
|
||||
- バックエンドで画像処理に用いているライブラリ sharp のシステム要件の変更により、**SSE4.2 命令セットをサポートしていない CPU では Misskey が正しく動作しなくなります**。仮想マシンに Misskey をデプロイしている場合や、古いハードウェアをお使いの場合は、アップデート前にお使いの環境をご確認ください。
|
||||
- Node.jsの最低サポートバージョンを 22.22.2 に引き上げました。これより古いバージョンの Node.js では Misskey が起動しません。
|
||||
- Node.js v24, v26 をサポートしました。**Node.js v22 でも動作しますが、今後のリリースで v22 のサポートを終了する予定**ですので、Node.js のアップデートをご検討ください。
|
||||
- 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 ではない環境においてはこの変更による影響はありません。
|
||||
|
||||
### General
|
||||
- Feat: コントロールパネルから二要素認証を解除できるように
|
||||
- Feat: 条件に一致したURLプレビューのサムネイルを隠すことができるように
|
||||
(Based on https://github.com/MisskeyIO/misskey/pull/214)
|
||||
|
||||
### Client
|
||||
- 2025.4.0 以前の設定情報の移行処理が削除されました
|
||||
- 2025.4.0 から直接 2026.6.0 以上にアップデートする場合は設定が移行されませんので注意してください。移行したい場合は一度 2026.5.1 を経由してください。
|
||||
- Enhance: 画像ビューワーを独自実装に変更・動画プレイヤーを統合
|
||||
- 操作性の改善
|
||||
- ビューワーとMisskeyの各種機能との統合を強化
|
||||
- パフォーマンスの向上
|
||||
- Enhance: マウスホイールで拡大縮小できるように
|
||||
- Fix: 幅が狭い画面で動画の再生が困難な問題を修正
|
||||
- Fix: 一部の画像のみセンシティブなとき、ビューワー内で画像を切り替えるとセンシティブな画像がそのまま表示される問題を修正
|
||||
- Fix: 一部の画像をビューワーで読み込んだ際に正しく表示されない問題を修正
|
||||
- Enhance: タイムラインの読み込みパフォーマンスを改善
|
||||
- Fix: 「画像を新しいタブで開く」が機能しなくなっていた問題を修正
|
||||
- Fix: デバイスタイプをスマートフォンに固定している状態で画面幅が広いとき、画面左上のアイコンが表示されない問題を修正
|
||||
- Fix: チャットでIMEの変換を確定するEnterでメッセージが送信されてしまうことがある問題を修正
|
||||
- Fix: 自分へのメンションに対する色分けで、判定が大文字/小文字を区別していた問題を修正
|
||||
- Fix: いくつかのイベントリスナーが正しく解除されない問題を修正(メモリ使用量の改善)
|
||||
- Fix: 非ログイン時トップページをスクロール操作できないことがある問題を修正
|
||||
|
||||
### Server
|
||||
- Feat: OpenTelemetryサポート
|
||||
- 詳細な設定はconfigファイルを参照してください。
|
||||
- Sentryとの併用も可能です。Sentry併用時は、PostgreSQL Query と Redis command は Sentry で計装されます。
|
||||
- 以下の自動計装をサポートしています。(計装対象にする項目は設定可能)
|
||||
- PostgreSQL query
|
||||
- Redis command
|
||||
- 全ての受信HTTPリクエスト
|
||||
- 全ての送信HTTPリクエスト
|
||||
- ジョブキュー(エンキュー元のトレースを含む)
|
||||
- Enhance: Sentry バックエンドの自動計装を `sentryForBackend.disabledIntegrations` で個別に無効化できるように
|
||||
- Enhance: センシティブメディアの判定を外部サービス ([sensitive-detector](https://github.com/misskey-dev/sensitive-detector)) に分離し、`nsfwjs` / `@tensorflow/tfjs(-node)` の同梱と NSFW 判定モデルを廃止 (#16804)
|
||||
|
||||
- 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内部エラーのログに構造化属性と正規化したエラー情報を付与し、認証情報を自動的に秘匿するように(従来形式の表示は維持)
|
||||
- Enhance: ログ全体の既定levelとlogger domainごとの出力levelを設定できるように
|
||||
- Enhance: バックエンドのログを1行JSON形式で出力できるように
|
||||
- Fix: `/stats` API のレスポンス型が正しくない問題を修正
|
||||
- Fix: ハッシュタグに関連するデータを更新する際のエラーハンドリングを修正
|
||||
- Fix: Sentry 使用環境下にて、Misskey が発行した SQL クエリが span に含まれない問題を修正
|
||||
- Fix: Sentry 使用環境下にて、外部送信リクエストへ `sentry-trace` / `baggage` ヘッダーが既定で付与されないように
|
||||
|
||||
## 2026.6.0
|
||||
|
||||
|
||||
@@ -600,6 +600,90 @@ TypeScriptでjsonをimportすると、tscでコンパイルするときにその
|
||||
コンポーネント自身がmarginを設定するのは問題の元となることはよく知られている
|
||||
marginはそのコンポーネントを使う側が設定する
|
||||
|
||||
### 命名規則
|
||||
|
||||
本来それが略称であっても、通常それでひとつのワードとして用いられるものは、略称として扱わない。
|
||||
|
||||
#### 例: IP address
|
||||
|
||||
Good: `ipAddress` / `IpAddress`
|
||||
|
||||
Bad: `IPAddress`
|
||||
|
||||
#### 例: User ID
|
||||
|
||||
Good: `userId` / `UserId`
|
||||
|
||||
Bad: `userID` / `UserID`
|
||||
|
||||
#### 例: XMLなHTTPのRequest
|
||||
|
||||
Good: `xmlHttpRequest` / `XmlHttpRequest`
|
||||
|
||||
Bad: `XMLHttpRequest` / `XMLHTTPRequest`
|
||||
|
||||
### 関数化の基準
|
||||
|
||||
汎用性が低く(例えばそれを関数化したとしてもその呼び出しが元の場所一か所しか存在しない)、内容も短い処理(例えば10行以下)は、かえって読みにくくなるため、関数化しない。
|
||||
|
||||
また、関数化する場合でも、呼び出しがある特定のスコープに限られる場合は、そのスコープ内に閉じ込めた方が分かりやすく簡潔になる場合がある(ただし本来その処理に不要であっても、構造上親のスコープにある関係のない変数や引数にもアクセスできるようになるため、必ずしもそうすれば設計上綺麗になるというわけでもない。状況に応じて判断すべし)。
|
||||
|
||||
Bad:
|
||||
|
||||
``` ts
|
||||
function withBrankets(x) {
|
||||
return `(${x})`;
|
||||
}
|
||||
|
||||
function formatPercent(x) {
|
||||
return `${x}%`;
|
||||
}
|
||||
|
||||
function formatValue(x) {
|
||||
return withBrankets(formatPercent(x));
|
||||
}
|
||||
|
||||
function showData(a, b) {
|
||||
console.log(formatValue(a));
|
||||
console.log(formatValue(b));
|
||||
}
|
||||
```
|
||||
|
||||
Good:
|
||||
|
||||
``` ts
|
||||
function formatValue(x) {
|
||||
return `(${x}%)`;
|
||||
}
|
||||
|
||||
function showData(a, b) {
|
||||
console.log(formatValue(a));
|
||||
console.log(formatValue(b));
|
||||
}
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
``` ts
|
||||
function showData(a, b) {
|
||||
function formatValue(x) {
|
||||
return `(${x}%)`;
|
||||
}
|
||||
|
||||
console.log(formatValue(a));
|
||||
console.log(formatValue(b));
|
||||
}
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
``` ts
|
||||
function showData(a, b) {
|
||||
console.log(`(${a}%)`);
|
||||
console.log(`(${b}%)`);
|
||||
}
|
||||
```
|
||||
|
||||
## その他
|
||||
### HTMLのクラス名で follow という単語は使わない
|
||||
広告ブロッカーで誤ってブロックされる
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# syntax = docker/dockerfile:1.23
|
||||
|
||||
ARG NODE_VERSION=22.22.2-bookworm
|
||||
ARG NODE_VERSION=26.4.0-trixie
|
||||
|
||||
# build assets & compile TypeScript
|
||||
|
||||
|
||||
@@ -224,9 +224,8 @@ id: "aidx"
|
||||
# Media Proxy
|
||||
#mediaProxy: https://example.com/proxy
|
||||
|
||||
#allowedPrivateNetworks: [
|
||||
# '127.0.0.1/32'
|
||||
#]
|
||||
allowedPrivateNetworks:
|
||||
- '127.0.0.1/32'
|
||||
|
||||
# Upload or download file size limits (bytes)
|
||||
#maxFileSize: 262144000
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineConfig } from 'cypress'
|
||||
|
||||
export default defineConfig({
|
||||
e2e: {
|
||||
baseUrl: 'http://localhost:61812',
|
||||
},
|
||||
})
|
||||
@@ -1,265 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
describe('Before setup instance', () => {
|
||||
beforeEach(() => {
|
||||
cy.resetState();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// テスト終了直前にページ遷移するようなテストケース(例えばアカウント作成)だと、たぶんCypressのバグでブラウザの内容が次のテストケースに引き継がれてしまう(例えばアカウントが作成し終わった段階からテストが始まる)。
|
||||
// waitを入れることでそれを防止できる
|
||||
cy.wait(1000);
|
||||
});
|
||||
|
||||
it('successfully loads', () => {
|
||||
cy.visitHome();
|
||||
});
|
||||
|
||||
it('setup instance', () => {
|
||||
cy.visitHome();
|
||||
|
||||
cy.intercept('POST', '/api/admin/accounts/create').as('signup');
|
||||
|
||||
cy.get('[data-cy-admin-initial-password] input').type('example_password_please_change_this_or_you_will_get_hacked');
|
||||
cy.get('[data-cy-admin-username] input').type('admin');
|
||||
cy.get('[data-cy-admin-password] input').type('admin1234');
|
||||
cy.get('[data-cy-admin-ok]').click();
|
||||
|
||||
// なぜか動かない
|
||||
//cy.wait('@signup').should('have.property', 'response.statusCode');
|
||||
cy.wait('@signup');
|
||||
|
||||
cy.intercept('POST', '/api/admin/update-meta').as('update-meta');
|
||||
|
||||
cy.get('[data-cy-next]').click();
|
||||
cy.get('[data-cy-server-name] input').type('Testskey');
|
||||
cy.get('[data-cy-server-setup-wizard-apply]').click();
|
||||
|
||||
cy.wait('@update-meta');
|
||||
});
|
||||
});
|
||||
|
||||
describe('After setup instance', () => {
|
||||
beforeEach(() => {
|
||||
cy.resetState();
|
||||
|
||||
// インスタンス初期セットアップ
|
||||
cy.registerUser('admin', 'pass', true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// テスト終了直前にページ遷移するようなテストケース(例えばアカウント作成)だと、たぶんCypressのバグでブラウザの内容が次のテストケースに引き継がれてしまう(例えばアカウントが作成し終わった段階からテストが始まる)。
|
||||
// waitを入れることでそれを防止できる
|
||||
cy.wait(1000);
|
||||
});
|
||||
|
||||
it('successfully loads', () => {
|
||||
cy.visitHome();
|
||||
});
|
||||
|
||||
it('signup', () => {
|
||||
cy.visitHome();
|
||||
|
||||
cy.intercept('POST', '/api/signup').as('signup');
|
||||
|
||||
cy.get('[data-cy-signup]').click();
|
||||
cy.get('[data-cy-signup-rules-continue]').should('be.disabled');
|
||||
cy.get('[data-cy-signup-rules-notes-agree] [data-cy-switch-toggle]').click();
|
||||
cy.get('[data-cy-modal-dialog-ok]').click();
|
||||
cy.get('[data-cy-signup-rules-continue]').should('not.be.disabled');
|
||||
cy.get('[data-cy-signup-rules-continue]').click();
|
||||
|
||||
cy.get('[data-cy-signup-submit]').should('be.disabled');
|
||||
cy.get('[data-cy-signup-username] input').type('alice');
|
||||
cy.get('[data-cy-signup-submit]').should('be.disabled');
|
||||
cy.get('[data-cy-signup-password] input').type('alice1234');
|
||||
cy.get('[data-cy-signup-submit]').should('be.disabled');
|
||||
cy.get('[data-cy-signup-password-retype] input').type('alice1234');
|
||||
cy.get('[data-cy-signup-submit]').should('be.disabled');
|
||||
cy.get('[data-cy-signup-invitation-code] input').type('test-invitation-code');
|
||||
cy.get('[data-cy-signup-submit]').should('not.be.disabled');
|
||||
cy.get('[data-cy-signup-submit]').click();
|
||||
|
||||
cy.wait('@signup');
|
||||
});
|
||||
|
||||
it('signup with duplicated username', () => {
|
||||
cy.registerUser('alice', 'alice1234');
|
||||
|
||||
cy.visitHome();
|
||||
|
||||
// ユーザー名が重複している場合の挙動確認
|
||||
cy.get('[data-cy-signup]').click();
|
||||
cy.get('[data-cy-signup-rules-continue]').should('be.disabled');
|
||||
cy.get('[data-cy-signup-rules-notes-agree] [data-cy-switch-toggle]').click();
|
||||
cy.get('[data-cy-modal-dialog-ok]').click();
|
||||
cy.get('[data-cy-signup-rules-continue]').should('not.be.disabled');
|
||||
cy.get('[data-cy-signup-rules-continue]').click();
|
||||
|
||||
cy.get('[data-cy-signup-username] input').type('alice');
|
||||
cy.get('[data-cy-signup-password] input').type('alice1234');
|
||||
cy.get('[data-cy-signup-password-retype] input').type('alice1234');
|
||||
cy.get('[data-cy-signup-submit]').should('be.disabled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('After user signup', () => {
|
||||
beforeEach(() => {
|
||||
cy.resetState();
|
||||
|
||||
// インスタンス初期セットアップ
|
||||
cy.registerUser('admin', 'pass', true);
|
||||
|
||||
// ユーザー作成
|
||||
cy.registerUser('alice', 'alice1234');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// テスト終了直前にページ遷移するようなテストケース(例えばアカウント作成)だと、たぶんCypressのバグでブラウザの内容が次のテストケースに引き継がれてしまう(例えばアカウントが作成し終わった段階からテストが始まる)。
|
||||
// waitを入れることでそれを防止できる
|
||||
cy.wait(1000);
|
||||
});
|
||||
|
||||
it('successfully loads', () => {
|
||||
cy.visitHome();
|
||||
});
|
||||
|
||||
it('signin', () => {
|
||||
cy.visitHome();
|
||||
|
||||
cy.intercept('POST', '/api/signin-flow').as('signin');
|
||||
|
||||
cy.get('[data-cy-signin]').click();
|
||||
|
||||
cy.get('[data-cy-signin-page-input]').should('be.visible', { timeout: 1000 });
|
||||
// Enterキーで続行できるかの確認も兼ねる
|
||||
cy.get('[data-cy-signin-username] input').type('alice{enter}');
|
||||
|
||||
cy.get('[data-cy-signin-page-password]').should('be.visible', { timeout: 10000 });
|
||||
// Enterキーで続行できるかの確認も兼ねる
|
||||
cy.get('[data-cy-signin-password] input').type('alice1234{enter}');
|
||||
|
||||
cy.wait('@signin');
|
||||
});
|
||||
|
||||
it('suspend', function() {
|
||||
cy.request('POST', '/api/admin/suspend-user', {
|
||||
i: this.admin.token,
|
||||
userId: this.alice.id,
|
||||
});
|
||||
|
||||
cy.visitHome();
|
||||
|
||||
cy.get('[data-cy-signin]').click();
|
||||
|
||||
cy.get('[data-cy-signin-page-input]').should('be.visible', { timeout: 1000 });
|
||||
cy.get('[data-cy-signin-username] input').type('alice{enter}');
|
||||
|
||||
// TODO: cypressにブラウザの言語指定できる機能が実装され次第英語のみテストするようにする
|
||||
cy.contains(/アカウントが凍結されています|This account has been suspended due to/gi);
|
||||
});
|
||||
});
|
||||
|
||||
describe('After user signed in', () => {
|
||||
beforeEach(() => {
|
||||
cy.resetState();
|
||||
|
||||
// インスタンス初期セットアップ
|
||||
cy.registerUser('admin', 'pass', true);
|
||||
|
||||
// ユーザー作成
|
||||
cy.registerUser('alice', 'alice1234');
|
||||
|
||||
cy.login('alice', 'alice1234');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// テスト終了直前にページ遷移するようなテストケース(例えばアカウント作成)だと、たぶんCypressのバグでブラウザの内容が次のテストケースに引き継がれてしまう(例えばアカウントが作成し終わった段階からテストが始まる)。
|
||||
// waitを入れることでそれを防止できる
|
||||
cy.wait(1000);
|
||||
});
|
||||
|
||||
it('successfully loads', () => {
|
||||
// 表示に時間がかかるのでデフォルト秒数だとタイムアウトする
|
||||
cy.get('[data-cy-user-setup-continue]', { timeout: 30000 }).should('be.visible');
|
||||
});
|
||||
|
||||
it('account setup wizard', () => {
|
||||
// 表示に時間がかかるのでデフォルト秒数だとタイムアウトする
|
||||
cy.get('[data-cy-user-setup-continue]', { timeout: 30000 }).click();
|
||||
|
||||
cy.get('[data-cy-user-setup-user-name] input').type('ありす');
|
||||
cy.get('[data-cy-user-setup-user-description] textarea').type('ほげ');
|
||||
// TODO: アイコン設定テスト
|
||||
|
||||
cy.get('[data-cy-user-setup-continue]').click();
|
||||
|
||||
// プライバシー設定
|
||||
|
||||
cy.get('[data-cy-user-setup-continue]').click();
|
||||
|
||||
// フォローはスキップ
|
||||
|
||||
cy.get('[data-cy-user-setup-continue]').click();
|
||||
|
||||
// プッシュ通知設定はスキップ
|
||||
|
||||
cy.get('[data-cy-user-setup-continue]').click();
|
||||
|
||||
cy.get('[data-cy-user-setup-continue]').click();
|
||||
});
|
||||
});
|
||||
|
||||
describe('After user setup', () => {
|
||||
beforeEach(() => {
|
||||
cy.resetState();
|
||||
|
||||
// インスタンス初期セットアップ
|
||||
cy.registerUser('admin', 'pass', true);
|
||||
|
||||
// ユーザー作成
|
||||
cy.registerUser('alice', 'alice1234');
|
||||
|
||||
cy.login('alice', 'alice1234');
|
||||
|
||||
// アカウント初期設定ウィザード
|
||||
// 表示に時間がかかるのでデフォルト秒数だとタイムアウトする
|
||||
cy.get('[data-cy-user-setup] [data-cy-modal-window-close]', { timeout: 30000 }).click();
|
||||
cy.get('[data-cy-modal-dialog-ok]').click();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// テスト終了直前にページ遷移するようなテストケース(例えばアカウント作成)だと、たぶんCypressのバグでブラウザの内容が次のテストケースに引き継がれてしまう(例えばアカウントが作成し終わった段階からテストが始まる)。
|
||||
// waitを入れることでそれを防止できる
|
||||
cy.wait(1000);
|
||||
});
|
||||
|
||||
it('note', () => {
|
||||
cy.get('[data-cy-open-post-form]').should('be.visible');
|
||||
cy.get('[data-cy-open-post-form]').click();
|
||||
cy.get('[data-cy-post-form-text]').type('Hello, Misskey!');
|
||||
cy.get('[data-cy-open-post-form-submit]').click();
|
||||
|
||||
cy.contains('Hello, Misskey!', { timeout: 15000 });
|
||||
});
|
||||
|
||||
it('open note form with hotkey', () => {
|
||||
// Wait until the page loads
|
||||
cy.get('[data-cy-open-post-form]').should('be.visible');
|
||||
// Use trigger() to give different `code` to test if hotkeys also work on non-QWERTY keyboards.
|
||||
cy.document().trigger("keydown", { eventConstructor: 'KeyboardEvent', key: "n", code: "KeyL" });
|
||||
// See if the form is opened
|
||||
cy.get('[data-cy-post-form-text]').should('be.visible');
|
||||
// Close it
|
||||
cy.focused().trigger("keydown", { eventConstructor: 'KeyboardEvent', key: "Escape", code: "Escape" });
|
||||
// See if the form is closed
|
||||
cy.get('[data-cy-post-form-text]').should('not.be.visible');
|
||||
});
|
||||
});
|
||||
|
||||
// TODO: 投稿フォームの公開範囲指定のテスト
|
||||
// TODO: 投稿フォームのファイル添付のテスト
|
||||
// TODO: 投稿フォームのハッシュタグ保持フィールドのテスト
|
||||
@@ -1,35 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
describe('Router transition', () => {
|
||||
describe('Redirect', () => {
|
||||
// サーバの初期化。ルートのテストに関しては各describeごとに1度だけ実行で十分だと思う(使いまわした方が早い)
|
||||
before(() => {
|
||||
cy.resetState();
|
||||
|
||||
// インスタンス初期セットアップ
|
||||
cy.registerUser('admin', 'pass', true);
|
||||
|
||||
// ユーザー作成
|
||||
cy.registerUser('alice', 'alice1234');
|
||||
|
||||
cy.login('alice', 'alice1234');
|
||||
|
||||
// アカウント初期設定ウィザード
|
||||
// 表示に時間がかかるのでデフォルト秒数だとタイムアウトする
|
||||
cy.get('[data-cy-user-setup] [data-cy-modal-window-close]', { timeout: 30000 }).click();
|
||||
cy.wait(500);
|
||||
cy.get('[data-cy-modal-dialog-ok]').click();
|
||||
});
|
||||
|
||||
it('redirect to user profile', () => {
|
||||
// テストのためだけに用意されたリダイレクト用ルートに飛ぶ
|
||||
cy.visit('/redirect-test');
|
||||
|
||||
// プロフィールページのURLであることを確認する
|
||||
cy.url().should('include', '/@alice')
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/* flaky
|
||||
describe('After user signed in', () => {
|
||||
beforeEach(() => {
|
||||
cy.resetState();
|
||||
cy.viewport('macbook-16');
|
||||
|
||||
// インスタンス初期セットアップ
|
||||
cy.registerUser('admin', 'pass', true);
|
||||
|
||||
// ユーザー作成
|
||||
cy.registerUser('alice', 'alice1234');
|
||||
|
||||
cy.login('alice', 'alice1234');
|
||||
|
||||
// アカウント初期設定ウィザード
|
||||
cy.get('[data-cy-user-setup] [data-cy-modal-window-close]').click();
|
||||
cy.get('[data-cy-modal-dialog-ok]').click();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// テスト終了直前にページ遷移するようなテストケース(例えばアカウント作成)だと、たぶんCypressのバグでブラウザの内容が次のテストケースに引き継がれてしまう(例えばアカウントが作成し終わった段階からテストが始まる)。
|
||||
// waitを入れることでそれを防止できる
|
||||
cy.wait(1000);
|
||||
});
|
||||
|
||||
it('widget edit toggle is visible', () => {
|
||||
cy.get('[data-cy-widget-edit]').should('be.visible');
|
||||
});
|
||||
|
||||
it('widget select should be visible in edit mode', () => {
|
||||
cy.get('[data-cy-widget-edit]').click();
|
||||
cy.get('[data-cy-widget-select]').should('be.visible');
|
||||
});
|
||||
|
||||
it('first widget should be removed', () => {
|
||||
cy.get('[data-cy-widget-edit]').click();
|
||||
cy.get('[data-cy-customize-container]:first-child [data-cy-customize-container-remove]._button').click();
|
||||
cy.get('[data-cy-customize-container]').should('have.length', 2);
|
||||
});
|
||||
|
||||
function buildWidgetTest(widgetName) {
|
||||
it(`${widgetName} widget should get added`, () => {
|
||||
cy.get('[data-cy-widget-edit]').click();
|
||||
cy.get('[data-cy-widget-select] select').select(widgetName, { force: true });
|
||||
cy.get('[data-cy-bg]._modalBg[data-cy-transparent]').click({ multiple: true, force: true });
|
||||
cy.get('[data-cy-widget-add]').click({ force: true });
|
||||
cy.get(`[data-cy-mkw-${widgetName}]`).should('exist');
|
||||
});
|
||||
}
|
||||
|
||||
buildWidgetTest('memo');
|
||||
buildWidgetTest('notifications');
|
||||
buildWidgetTest('timeline');
|
||||
buildWidgetTest('calendar');
|
||||
buildWidgetTest('rss');
|
||||
buildWidgetTest('trends');
|
||||
buildWidgetTest('clock');
|
||||
buildWidgetTest('activity');
|
||||
buildWidgetTest('photos');
|
||||
buildWidgetTest('digitalClock');
|
||||
buildWidgetTest('federation');
|
||||
buildWidgetTest('postForm');
|
||||
buildWidgetTest('slideshow');
|
||||
buildWidgetTest('serverMetric');
|
||||
buildWidgetTest('onlineUsers');
|
||||
buildWidgetTest('jobQueue');
|
||||
buildWidgetTest('button');
|
||||
buildWidgetTest('aiscript');
|
||||
buildWidgetTest('aichan');
|
||||
});
|
||||
*/
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"name": "Using fixtures to represent data",
|
||||
"email": "hello@cypress.io",
|
||||
"body": "Fixtures are a great way to mock data for responses to routes"
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
// ***********************************************
|
||||
// This example commands.js shows you how to
|
||||
// create various custom commands and overwrite
|
||||
// existing commands.
|
||||
//
|
||||
// For more comprehensive examples of custom
|
||||
// commands please read more here:
|
||||
// https://on.cypress.io/custom-commands
|
||||
// ***********************************************
|
||||
//
|
||||
//
|
||||
// -- This is a parent command --
|
||||
// Cypress.Commands.add('login', (email, password) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This is a child command --
|
||||
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This is a dual command --
|
||||
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This will overwrite an existing command --
|
||||
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
||||
|
||||
Cypress.Commands.add('visitHome', () => {
|
||||
cy.visit('/');
|
||||
cy.get('button', { timeout: 30000 }).should('be.visible');
|
||||
})
|
||||
|
||||
Cypress.Commands.add('resetState', () => {
|
||||
// iframe.contentWindow.indexedDB.deleteDatabase() がchromeのバグで使用できないため、indexedDBを無効化している。
|
||||
// see https://github.com/misskey-dev/misskey/issues/13605#issuecomment-2053652123
|
||||
/*
|
||||
cy.window().then(win => {
|
||||
win.indexedDB.deleteDatabase('keyval-store');
|
||||
});
|
||||
*/
|
||||
cy.request('POST', '/api/reset-db', {}).as('reset');
|
||||
cy.get('@reset').its('status').should('equal', 204);
|
||||
cy.reload(true);
|
||||
});
|
||||
|
||||
Cypress.Commands.add('registerUser', (username, password, isAdmin = false) => {
|
||||
const route = isAdmin ? '/api/admin/accounts/create' : '/api/signup';
|
||||
|
||||
cy.request('POST', route, {
|
||||
username: username,
|
||||
password: password,
|
||||
...(isAdmin ? { setupPassword: 'example_password_please_change_this_or_you_will_get_hacked' } : {}),
|
||||
}).its('body').as(username);
|
||||
});
|
||||
|
||||
Cypress.Commands.add('login', (username, password) => {
|
||||
cy.visitHome();
|
||||
|
||||
cy.intercept('POST', '/api/signin-flow').as('signin');
|
||||
|
||||
cy.get('[data-cy-signin]').click();
|
||||
cy.get('[data-cy-signin-page-input]').should('be.visible', { timeout: 1000 });
|
||||
cy.get('[data-cy-signin-username] input').type(`${username}{enter}`);
|
||||
cy.get('[data-cy-signin-page-password]').should('be.visible', { timeout: 10000 });
|
||||
cy.get('[data-cy-signin-password] input').type(`${password}{enter}`);
|
||||
|
||||
cy.wait('@signin').as('signedIn');
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
// ***********************************************************
|
||||
// This example support/index.js is processed and
|
||||
// loaded automatically before your test files.
|
||||
//
|
||||
// This is a great place to put global configuration and
|
||||
// behavior that modifies Cypress.
|
||||
//
|
||||
// You can change the location of this file or turn off
|
||||
// automatically serving support files with the
|
||||
// 'supportFile' configuration option.
|
||||
//
|
||||
// You can read more here:
|
||||
// https://on.cypress.io/configuration
|
||||
// ***********************************************************
|
||||
|
||||
// Import commands.js using ES2015 syntax:
|
||||
import './commands'
|
||||
|
||||
// Alternatively you can use CommonJS syntax:
|
||||
// require('./commands')
|
||||
|
||||
Cypress.on('uncaught:exception', (err, runnable) => {
|
||||
if ([
|
||||
'The source image cannot be decoded',
|
||||
|
||||
// Chrome
|
||||
'ResizeObserver loop limit exceeded',
|
||||
|
||||
// Firefox
|
||||
'ResizeObserver loop completed with undelivered notifications',
|
||||
].some(msg => err.message.includes(msg))) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
declare global {
|
||||
namespace Cypress {
|
||||
interface Chainable {
|
||||
login(username: string, password: string): Chainable<void>;
|
||||
|
||||
registerUser(
|
||||
username: string,
|
||||
password: string,
|
||||
isAdmin?: boolean
|
||||
): Chainable<void>;
|
||||
|
||||
resetState(): Chainable<void>;
|
||||
|
||||
visitHome(): Chainable<void>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom"],
|
||||
"target": "esnext",
|
||||
"types": ["cypress", "node"]
|
||||
},
|
||||
"include": ["./**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
# 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:<name>`, 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:<normalized source path>`.
|
||||
2. `chunk.name` is in an explicit allowlist of intentionally stable manual chunks: use `named:<name>`.
|
||||
|
||||
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. Generated chunks are rendered at the bottom of the table as 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. No additional generated-chunk count note is rendered below the table.
|
||||
|
||||
### 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. 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
|
||||
|
||||
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;
|
||||
- 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;
|
||||
- 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.
|
||||
|
||||
## 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.
|
||||
@@ -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 "
|
||||
@@ -1361,14 +1363,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ó"
|
||||
@@ -1420,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"
|
||||
@@ -2152,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 "
|
||||
@@ -2464,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"
|
||||
@@ -2979,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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
@@ -1361,14 +1363,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"
|
||||
@@ -1420,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"
|
||||
@@ -2152,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"
|
||||
@@ -2464,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"
|
||||
@@ -2979,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"
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -1219,7 +1221,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"
|
||||
@@ -1361,14 +1363,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"
|
||||
@@ -1420,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"
|
||||
@@ -2152,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."
|
||||
@@ -2464,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"
|
||||
@@ -2979,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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
@@ -1361,14 +1363,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"
|
||||
@@ -1420,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"
|
||||
@@ -2152,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"
|
||||
@@ -2464,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"
|
||||
@@ -2979,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"
|
||||
|
||||
@@ -1419,6 +1419,8 @@ addToEmojiPalette: "絵文字パレットに追加"
|
||||
emojiPaletteAlreadyAddedConfirm: "この絵文字はすでにこの絵文字パレットに含まれています。追加しなおしますか?"
|
||||
append: "末尾に追加"
|
||||
prepend: "先頭に追加"
|
||||
urlPreviewSensitiveList: "サムネイルの表示を制限するURL"
|
||||
urlPreviewSensitiveListDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります。スラッシュで囲むと正規表現になります。一致した場合、サムネイルが表示されなくなります。"
|
||||
|
||||
_imageEditing:
|
||||
_vars:
|
||||
|
||||
@@ -1332,9 +1332,6 @@ preferenceSyncConflictChoiceCancel: "同期の有効化はやめとくわ"
|
||||
postForm: "投稿フォーム"
|
||||
information: "情報"
|
||||
directMessage: "チャットしよか"
|
||||
migrateOldSettings: "旧設定情報をお引っ越し"
|
||||
migrateOldSettings_description: "通常これは自動で行われるはずなんやけど、なんかの理由で上手く移行できへんかったときは手動で移行処理をポチっとできるで。今の設定情報は上書きされるで。"
|
||||
settingsMigrating: "設定を移行しとるで。ちょっと待っとってな... (後で、設定→その他→旧設定情報を移行 で手動で移行することもできるで)"
|
||||
driveAboutTip: "ドライブでは、今までアップロードしたファイルがずらーっと表示されるで。<br>\nノートにファイルをもっかいのっけたり、あとで投稿するファイルをその辺に置いとくこともできるねん。<br>\n<b>ファイルをほかすと、前にそのファイルをのっけた全部の場所(ノート、ページ、アバター、バナー等)からも見えんくなるから気いつけてな。</b><br>\nフォルダを作って整理することもできるで。"
|
||||
turnItOn: "オンにしとこ"
|
||||
turnItOff: "オフでええわ"
|
||||
|
||||
@@ -619,6 +619,8 @@ output: "출력"
|
||||
script: "스크립트"
|
||||
disablePagesScript: "Pages 에서 AiScript 를 사용하지 않음"
|
||||
updateRemoteUser: "리모트 유저 정보 갱신"
|
||||
unsetMfa: "2단계 인증 해제"
|
||||
unsetMfaConfirm: "2단계 인증을 해제하시겠습니까?"
|
||||
unsetUserAvatar: "아바타 제거"
|
||||
unsetUserAvatarConfirm: "아바타를 제거할까요?"
|
||||
unsetUserBanner: "배너 제거"
|
||||
@@ -1361,14 +1363,11 @@ information: "정보"
|
||||
chat: "채팅"
|
||||
directMessage: "채팅하기"
|
||||
directMessage_short: "메시지"
|
||||
migrateOldSettings: "기존 설정 정보를 이전"
|
||||
migrateOldSettings_description: "보통은 자동으로 이루어지지만, 어떤 이유로 인해 성공적으로 이전이 이루어지지 않는 경우 수동으로 이전을 실행할 수 있습니다. 현재 설정 정보는 덮어쓰게 됩니다."
|
||||
compress: "압축"
|
||||
right: "오른쪽"
|
||||
bottom: "아래"
|
||||
top: "위"
|
||||
embed: "임베드"
|
||||
settingsMigrating: "설정을 이전하는 중입니다. 잠시 기다려주십시오... (나중에 '환경설정 → 기타 → 기존 설정 정보를 이전'에서 수동으로 이전할 수도 있습니다)"
|
||||
readonly: "읽기 전용"
|
||||
goToDeck: "덱으로 돌아가기"
|
||||
federationJobs: "연합 작업"
|
||||
@@ -1420,6 +1419,8 @@ addToEmojiPalette: "이모지 팔레트에 추가"
|
||||
emojiPaletteAlreadyAddedConfirm: "이 이모지는 이미 이 이모지 팔레트에 포함돼있습니다. 다시 추가하시겠습니까?"
|
||||
append: "맨뒤에 추가"
|
||||
prepend: "맨앞에 추가"
|
||||
urlPreviewSensitiveList: "썸네일 표시 제한 URL"
|
||||
urlPreviewSensitiveListDescription: "공백으로 구분하면 AND 지정으로 되고, 줄내림으로 구분하면 OR 지정으로 됩니다. 슬래시로 감싸면 정규 표현으로 됩니다. 일치한 경우에 썸네일이 표시되지 않게 됩니다."
|
||||
_imageEditing:
|
||||
_vars:
|
||||
caption: "파일 설명"
|
||||
@@ -2152,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: "형식이 올바르지 않습니다"
|
||||
@@ -2464,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": "유저 정지 해제하기"
|
||||
@@ -2979,6 +2990,7 @@ _moderationLogTypes:
|
||||
createAvatarDecoration: "아바타 장식 만들기"
|
||||
updateAvatarDecoration: "아바타 장식 수정"
|
||||
deleteAvatarDecoration: "아바타 장식 삭제"
|
||||
unsetMfa: "사용자의 2단계 인증 해제"
|
||||
unsetUserAvatar: "유저 아바타 제거"
|
||||
unsetUserBanner: "유저 배너 제거"
|
||||
createSystemWebhook: "SystemWebhook을 생성"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1358,14 +1358,11 @@ information: "เกี่ยวกับ"
|
||||
chat: "แชต"
|
||||
directMessage: "แชตเลย"
|
||||
directMessage_short: "ข้อความ"
|
||||
migrateOldSettings: "ย้ายข้อมูลการตั้งค่าเก่า"
|
||||
migrateOldSettings_description: "โดยปกติจะทำโดยอัตโนมัติ แต่หากด้วยเหตุผลบางประการที่ไม่สามารถย้ายได้สำเร็จ สามารถสั่งย้ายด้วยตนเองได้ การตั้งค่าปัจจุบันจะถูกเขียนทับ"
|
||||
compress: "บีบอัด"
|
||||
right: "ขวา"
|
||||
bottom: "ล่าง"
|
||||
top: "บน"
|
||||
embed: "ฝัง"
|
||||
settingsMigrating: "กำลังย้ายการตั้งค่า กรุณารอสักครู่... (สามารถย้ายด้วยตนเองภายหลังได้ที่ การตั้งค่า → อื่นๆ → ย้ายข้อมูลการตั้งค่าเก่า)"
|
||||
readonly: "อ่านได้อย่างเดียว"
|
||||
goToDeck: "กลับไปยังเด็ค"
|
||||
federationJobs: "งานสหพันธ์"
|
||||
|
||||
1
locales/tl-PH.yml
Normal file
1
locales/tl-PH.yml
Normal file
@@ -0,0 +1 @@
|
||||
---
|
||||
@@ -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"
|
||||
|
||||
@@ -1314,14 +1314,11 @@ information: "Інформація"
|
||||
chat: "Чат"
|
||||
directMessage: "Чат із користувачем"
|
||||
directMessage_short: "Повідомлення"
|
||||
migrateOldSettings: "Перенести минулі налаштування клієнта"
|
||||
migrateOldSettings_description: "Це має відбутися автоматично, але якщо з якоїсь причини перенесення не вдалося, ви можете запустити його вручну. Поточні дані конфігурації буде перезаписано."
|
||||
compress: "Стиснути"
|
||||
right: "Праворуч"
|
||||
bottom: "Зверху"
|
||||
top: "Знизу"
|
||||
embed: "Вбудувати"
|
||||
settingsMigrating: "Налаштування переносяться, зачекайте трохи... (Ви також можете перенести їх вручну пізніше: Налаштування → Інше → Перенести старі налаштування)"
|
||||
readonly: "Лише для читання"
|
||||
goToDeck: "Повернутися до Деки"
|
||||
federationJobs: "Завдання федерації"
|
||||
|
||||
@@ -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ị.<br>\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.<br>\n<b>Lư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.).</b><br>\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"
|
||||
|
||||
@@ -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: "清除横幅"
|
||||
@@ -1361,14 +1363,11 @@ information: "关于"
|
||||
chat: "聊天"
|
||||
directMessage: "私信"
|
||||
directMessage_short: "消息"
|
||||
migrateOldSettings: "迁移旧设置信息"
|
||||
migrateOldSettings_description: "通常设置信息将自动迁移。但如果由于某种原因迁移不成功,则可以手动触发迁移过程。当前的配置信息将被覆盖。"
|
||||
compress: "压缩"
|
||||
right: "右"
|
||||
bottom: "下"
|
||||
top: "上"
|
||||
embed: "嵌入"
|
||||
settingsMigrating: "正在迁移设置,请稍候。(之后也可以在设置 → 其它 → 迁移旧设置来手动迁移)"
|
||||
readonly: "只读"
|
||||
goToDeck: "返回至 Deck"
|
||||
federationJobs: "联邦作业"
|
||||
@@ -1420,6 +1419,8 @@ addToEmojiPalette: "添加至表情符号选择器"
|
||||
emojiPaletteAlreadyAddedConfirm: "此表情符号已存在于此表情符号选择器中。要再次添加吗?"
|
||||
append: "加到最后"
|
||||
prepend: "加到最前"
|
||||
urlPreviewSensitiveList: "限制显示缩略图的 URL"
|
||||
urlPreviewSensitiveListDescription: "AND 条件用空格分隔,OR 条件用换行符分隔,正则表达式用斜线包裹。成功匹配则不再显示缩略图。"
|
||||
_imageEditing:
|
||||
_vars:
|
||||
caption: "文件标题"
|
||||
@@ -2152,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: "无效的格式"
|
||||
@@ -2464,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": "解除用户冻结"
|
||||
@@ -2589,7 +2600,7 @@ _widgetOptions:
|
||||
_jobQueue:
|
||||
sound: "播放音效"
|
||||
_rss:
|
||||
url: "RSS feed 的 URL"
|
||||
url: "RSS 订阅源网址"
|
||||
refreshIntervalSec: "更新间隔(秒)"
|
||||
maxEntries: "最大显示个数"
|
||||
_rssTicker:
|
||||
@@ -2979,6 +2990,7 @@ _moderationLogTypes:
|
||||
createAvatarDecoration: "新建头像挂件"
|
||||
updateAvatarDecoration: "更新头像挂件"
|
||||
deleteAvatarDecoration: "删除头像挂件"
|
||||
unsetMfa: "解除用户的双重验证"
|
||||
unsetUserAvatar: "清除用户头像"
|
||||
unsetUserBanner: "清除用户横幅"
|
||||
createSystemWebhook: "新建了 SystemWebhook"
|
||||
|
||||
@@ -1361,14 +1361,11 @@ information: "關於"
|
||||
chat: "聊天"
|
||||
directMessage: "直接訊息"
|
||||
directMessage_short: "訊息"
|
||||
migrateOldSettings: "遷移舊設定資訊"
|
||||
migrateOldSettings_description: "通常情況下,這會自動進行,但若因某些原因未能順利遷移,您可以手動觸發遷移處理。請注意,當前的設定資訊將會被覆寫。"
|
||||
compress: "壓縮"
|
||||
right: "右"
|
||||
bottom: "下"
|
||||
top: "上"
|
||||
embed: "嵌入"
|
||||
settingsMigrating: "正在移轉設定。請稍候……(之後也可以到「設定 → 其他 → 舊設定資訊移轉」中手動進行移轉)"
|
||||
readonly: "唯讀"
|
||||
goToDeck: "回到多欄模式"
|
||||
federationJobs: "聯邦通訊作業"
|
||||
|
||||
32
package.json
32
package.json
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "misskey",
|
||||
"version": "2026.6.1-alpha.1",
|
||||
"version": "2026.7.0-alpha.6",
|
||||
"codename": "nasubi",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/misskey-dev/misskey.git"
|
||||
},
|
||||
"packageManager": "pnpm@11.8.0",
|
||||
"packageManager": "pnpm@11.9.0",
|
||||
"workspaces": [
|
||||
"packages/misskey-js",
|
||||
"packages/i18n",
|
||||
@@ -39,11 +39,10 @@
|
||||
"migrateandstart": "pnpm migrate && pnpm start",
|
||||
"watch": "pnpm dev",
|
||||
"dev": "node scripts/dev.mjs",
|
||||
"lint": "pnpm --no-bail -r lint",
|
||||
"cy:open": "pnpm cypress open --config-file=cypress.config.ts",
|
||||
"cy:run": "pnpm cypress run",
|
||||
"e2e": "pnpm start-server-and-test start:test http://localhost:61812 cy:run",
|
||||
"e2e-dev-container": "ncp ./.config/cypress-devcontainer.yml ./.config/test.yml && pnpm start-server-and-test start:test http://localhost:61812 cy:run",
|
||||
"check-dts": "node scripts/check-dts.mjs",
|
||||
"lint": "pnpm --no-bail -r lint && pnpm check-dts",
|
||||
"e2e": "pnpm start-server-and-test start:test http://localhost:61812 \"pnpm --filter frontend test:e2e\"",
|
||||
"e2e-dev-container": "ncp ./.config/playwright-devcontainer.yml ./.config/test.yml && pnpm start-server-and-test start:test http://localhost:61812 \"pnpm --filter frontend test:e2e\"",
|
||||
"backend-unit-test": "cd packages/backend && pnpm test",
|
||||
"backend-unit-test-and-coverage": "cd packages/backend && pnpm test-and-coverage",
|
||||
"test": "pnpm -r test",
|
||||
@@ -53,29 +52,24 @@
|
||||
"cleanall": "pnpm clean-all"
|
||||
},
|
||||
"dependencies": {
|
||||
"cssnano": "8.0.2",
|
||||
"esbuild": "0.28.1",
|
||||
"execa": "9.6.1",
|
||||
"ignore-walk": "9.0.0",
|
||||
"js-yaml": "4.2.0",
|
||||
"postcss": "8.5.15",
|
||||
"tar": "7.5.16",
|
||||
"terser": "5.48.0"
|
||||
"js-yaml": "5.2.0",
|
||||
"tar": "7.5.19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.39.4",
|
||||
"@misskey-dev/eslint-plugin": "2.1.0",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/node": "24.13.2",
|
||||
"@typescript-eslint/eslint-plugin": "8.61.1",
|
||||
"@typescript-eslint/parser": "8.61.1",
|
||||
"@types/node": "26.0.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.62.0",
|
||||
"@typescript-eslint/parser": "8.62.0",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260426.1",
|
||||
"cross-env": "10.1.0",
|
||||
"cypress": "15.17.0",
|
||||
"eslint": "9.39.4",
|
||||
"globals": "17.6.0",
|
||||
"globals": "17.7.0",
|
||||
"ncp": "2.0.0",
|
||||
"pnpm": "11.8.0",
|
||||
"pnpm": "11.9.0",
|
||||
"start-server-and-test": "3.0.11",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class UrlPreviewSensitiveList1782581064131 {
|
||||
name = 'UrlPreviewSensitiveList1782581064131'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" ADD "urlPreviewSensitiveList" character varying(3072) array NOT NULL DEFAULT '{}'`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "urlPreviewSensitiveList"`);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.10.0"
|
||||
"node": "^22.22.2 || ^24.17.0 || ^26.4.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "pnpm compile-config && node ./built/entry.js",
|
||||
@@ -51,35 +51,43 @@
|
||||
"utf-8-validate": "6.0.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.1073.0",
|
||||
"@aws-sdk/lib-storage": "3.1073.0",
|
||||
"@aws-sdk/client-s3": "3.1075.0",
|
||||
"@aws-sdk/lib-storage": "3.1075.0",
|
||||
"@fastify/accepts": "5.0.4",
|
||||
"@fastify/cors": "11.2.0",
|
||||
"@fastify/http-proxy": "11.5.0",
|
||||
"@fastify/multipart": "10.0.0",
|
||||
"@fastify/otel": "0.19.0",
|
||||
"@fastify/static": "9.1.3",
|
||||
"@kitajs/html": "4.2.13",
|
||||
"@misskey-dev/emoji-assets": "17.0.3",
|
||||
"@misskey-dev/emoji-data": "17.0.3",
|
||||
"@misskey-dev/sharp-read-bmp": "1.3.1",
|
||||
"@misskey-dev/summaly": "5.5.1",
|
||||
"@napi-rs/canvas": "1.0.0",
|
||||
"@napi-rs/canvas": "1.0.1",
|
||||
"@nestjs/common": "11.1.27",
|
||||
"@nestjs/core": "11.1.27",
|
||||
"@nestjs/testing": "11.1.27",
|
||||
"@opentelemetry/api": "1.9.1",
|
||||
"@opentelemetry/core": "2.8.0",
|
||||
"@opentelemetry/exporter-trace-otlp-proto": "0.214.0",
|
||||
"@opentelemetry/instrumentation-pg": "0.72.0",
|
||||
"@opentelemetry/resources": "2.7.1",
|
||||
"@opentelemetry/sdk-trace-base": "2.7.1",
|
||||
"@opentelemetry/sdk-trace-node": "2.7.1",
|
||||
"@opentelemetry/semantic-conventions": "1.40.0",
|
||||
"@oxc-project/runtime": "0.137.0",
|
||||
"@peertube/http-signature": "1.7.0",
|
||||
"@sentry/node": "10.59.0",
|
||||
"@sentry/profiling-node": "10.59.0",
|
||||
"@simplewebauthn/server": "13.3.1",
|
||||
"@sinonjs/fake-timers": "15.4.0",
|
||||
"@smithy/node-http-handler": "4.8.1",
|
||||
"@sentry/node": "10.62.0",
|
||||
"@sentry/profiling-node": "10.62.0",
|
||||
"@simplewebauthn/server": "13.3.2",
|
||||
"@smithy/node-http-handler": "4.8.2",
|
||||
"accepts": "1.3.8",
|
||||
"ajv": "8.20.0",
|
||||
"archiver": "8.0.0",
|
||||
"bcryptjs": "3.0.3",
|
||||
"blurhash": "2.0.5",
|
||||
"bullmq": "5.79.0",
|
||||
"bullmq": "5.79.2",
|
||||
"cacheable-lookup": "7.0.0",
|
||||
"chalk": "5.6.2",
|
||||
"chalk-template": "1.1.2",
|
||||
@@ -88,12 +96,12 @@
|
||||
"content-disposition": "2.0.1",
|
||||
"date-fns": "4.4.0",
|
||||
"deep-email-validator": "0.1.27",
|
||||
"fastify": "5.8.5",
|
||||
"fastify": "5.9.0",
|
||||
"fastify-raw-body": "5.0.0",
|
||||
"feed": "5.2.1",
|
||||
"file-type": "22.0.1",
|
||||
"fluent-ffmpeg": "2.1.3",
|
||||
"got": "15.0.5",
|
||||
"got": "15.0.7",
|
||||
"hpagent": "1.2.0",
|
||||
"http-link-header": "1.1.3",
|
||||
"i18n": "workspace:*",
|
||||
@@ -110,10 +118,10 @@
|
||||
"misskey-js": "workspace:*",
|
||||
"misskey-reversi": "workspace:*",
|
||||
"ms": "3.0.0-canary.202508261828",
|
||||
"nanoid": "5.1.14",
|
||||
"nanoid": "5.1.16",
|
||||
"nested-property": "4.0.0",
|
||||
"node-fetch": "3.3.2",
|
||||
"node-html-parser": "7.1.0",
|
||||
"node-html-parser": "8.0.3",
|
||||
"nodemailer": "9.0.1",
|
||||
"os-utils": "0.0.14",
|
||||
"otpauth": "9.5.1",
|
||||
@@ -128,7 +136,6 @@
|
||||
"reflect-metadata": "0.2.2",
|
||||
"rename": "1.0.4",
|
||||
"rss-parser": "3.13.0",
|
||||
"rxjs": "7.8.2",
|
||||
"sanitize-html": "2.17.5",
|
||||
"secure-json-parse": "4.1.0",
|
||||
"semver": "7.8.5",
|
||||
@@ -136,7 +143,7 @@
|
||||
"slacc": "0.1.5",
|
||||
"strict-event-emitter-types": "2.0.0",
|
||||
"stringz": "2.1.0",
|
||||
"systeminformation": "5.31.7",
|
||||
"systeminformation": "5.31.11",
|
||||
"tinycolor2": "1.6.0",
|
||||
"tmp": "0.2.7",
|
||||
"tsc-alias": "1.8.17",
|
||||
@@ -151,16 +158,16 @@
|
||||
"@kitajs/ts-html-plugin": "4.1.4",
|
||||
"@nestjs/platform-express": "11.1.27",
|
||||
"@rollup/plugin-esm-shim": "0.1.8",
|
||||
"@sentry/vue": "10.59.0",
|
||||
"@sentry/vue": "10.62.0",
|
||||
"@sinonjs/fake-timers": "15.4.0",
|
||||
"@types/accepts": "1.3.7",
|
||||
"@types/archiver": "8.0.0",
|
||||
"@types/fluent-ffmpeg": "2.1.28",
|
||||
"@types/http-link-header": "1.0.7",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/jsonld": "1.5.15",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/ms": "2.1.0",
|
||||
"@types/node": "24.13.2",
|
||||
"@types/node": "26.0.1",
|
||||
"@types/nodemailer": "8.0.1",
|
||||
"@types/pg": "8.20.0",
|
||||
"@types/qrcode": "1.5.6",
|
||||
@@ -171,26 +178,24 @@
|
||||
"@types/semver": "7.7.1",
|
||||
"@types/simple-oauth2": "5.0.8",
|
||||
"@types/sinonjs__fake-timers": "15.0.1",
|
||||
"@types/supertest": "7.2.0",
|
||||
"@types/tinycolor2": "1.4.6",
|
||||
"@types/tmp": "0.2.6",
|
||||
"@types/vary": "1.1.3",
|
||||
"@types/web-push": "3.6.4",
|
||||
"@types/ws": "8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.61.1",
|
||||
"@typescript-eslint/parser": "8.61.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.62.0",
|
||||
"@typescript-eslint/parser": "8.62.0",
|
||||
"@vitest/coverage-v8": "4.1.9",
|
||||
"aws-sdk-client-mock": "4.1.0",
|
||||
"cbor": "10.0.12",
|
||||
"cbor2": "2.3.0",
|
||||
"cross-env": "10.1.0",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"execa": "9.6.1",
|
||||
"fkill": "10.0.3",
|
||||
"js-yaml": "4.2.0",
|
||||
"js-yaml": "5.2.0",
|
||||
"pid-port": "2.1.1",
|
||||
"rolldown": "1.1.2",
|
||||
"rolldown": "1.1.3",
|
||||
"simple-oauth2": "5.1.0",
|
||||
"supertest": "7.2.2",
|
||||
"vite": "8.1.0",
|
||||
"vitest": "4.1.9",
|
||||
"vitest-mock-extended": "4.0.0"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { version as summalyVersion } from '@misskey-dev/summaly';
|
||||
import type { Plugin, ExternalOption } from 'rolldown';
|
||||
import { execa, execaNode } from 'execa';
|
||||
import type { ResultPromise } from 'execa';
|
||||
import fkill from 'fkill';
|
||||
import esmShim from '@rollup/plugin-esm-shim';
|
||||
|
||||
/**
|
||||
@@ -10,6 +11,7 @@ import esmShim from '@rollup/plugin-esm-shim';
|
||||
*/
|
||||
function backendDevServerPlugin(): Plugin {
|
||||
let backendProcess: ResultPromise | null = null;
|
||||
let backendShutdownPromise: Promise<void> | null = null;
|
||||
|
||||
async function runBuildAssets() {
|
||||
await execa('pnpm', ['run', 'build-assets'], {
|
||||
@@ -20,12 +22,31 @@ function backendDevServerPlugin(): Plugin {
|
||||
}
|
||||
|
||||
async function killBackendProcess() {
|
||||
if (backendProcess) {
|
||||
backendProcess.catch(() => {}); // backendProcess.kill()によって発生する例外を無視するためにcatch()を呼び出す
|
||||
backendProcess.kill();
|
||||
await new Promise(resolve => backendProcess!.on('exit', resolve));
|
||||
backendProcess = null;
|
||||
}
|
||||
if (backendShutdownPromise) return backendShutdownPromise;
|
||||
if (!backendProcess) return;
|
||||
|
||||
const processToKill = backendProcess;
|
||||
backendProcess = null;
|
||||
processToKill.catch(() => {}); // プロセスの終了によって発生する例外を無視するためにcatch()を呼び出す
|
||||
|
||||
backendShutdownPromise = (async () => {
|
||||
if (process.platform === 'win32' && processToKill.pid != null) {
|
||||
await fkill(processToKill.pid, {
|
||||
force: true,
|
||||
tree: true,
|
||||
silent: true,
|
||||
waitForExit: 5000,
|
||||
});
|
||||
} else {
|
||||
processToKill.kill();
|
||||
}
|
||||
|
||||
await processToKill.catch(() => {});
|
||||
})().finally(() => {
|
||||
backendShutdownPromise = null;
|
||||
});
|
||||
|
||||
return backendShutdownPromise;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -49,6 +70,9 @@ function backendDevServerPlugin(): Plugin {
|
||||
await runBuildAssets();
|
||||
}
|
||||
},
|
||||
async closeWatcher() {
|
||||
await killBackendProcess();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,6 +87,7 @@ export default defineConfig((args) => {
|
||||
'class-validator',
|
||||
/^@sentry\/.*/,
|
||||
/^@sentry-internal\/.*/,
|
||||
/^@opentelemetry\/.*/,
|
||||
'@nestjs/websockets/socket-module',
|
||||
'@nestjs/microservices/microservices-module',
|
||||
'@nestjs/microservices',
|
||||
@@ -75,6 +100,8 @@ export default defineConfig((args) => {
|
||||
're2',
|
||||
'ipaddr.js',
|
||||
'file-type',
|
||||
// バンドルするとSentryの自動計装が正しく行われなくなるため外しておく
|
||||
'pg',
|
||||
];
|
||||
|
||||
const define: Record<string, string> = {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import fs from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import yaml from 'js-yaml';
|
||||
import { load as loadYaml } from 'js-yaml';
|
||||
|
||||
const _filename = fileURLToPath(import.meta.url);
|
||||
const _dirname = dirname(_filename);
|
||||
@@ -34,7 +34,7 @@ function yamlToJson(ymlPath) {
|
||||
console.log(`${ymlPath} → ${OUTPUT_PATH}`);
|
||||
|
||||
const yamlContent = fs.readFileSync(ymlPath, 'utf-8');
|
||||
const jsonContent = yaml.load(yamlContent);
|
||||
const jsonContent = loadYaml(yamlContent);
|
||||
if (!fs.existsSync(dirname(OUTPUT_PATH))) {
|
||||
fs.mkdirSync(dirname(OUTPUT_PATH), { recursive: true });
|
||||
}
|
||||
|
||||
@@ -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 { heapSnapshotCategory, type HeapSnapshotData } from '../../../.github/scripts/heap-snapshot-util.mts';
|
||||
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,14 +32,11 @@ 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', 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;
|
||||
@@ -61,14 +57,14 @@ type HeapSnapshotErrorMessage = {
|
||||
};
|
||||
type HeapSnapshotResponseMessage = HeapSnapshotMessage | HeapSnapshotErrorMessage;
|
||||
|
||||
function parseMemoryFile<KS extends readonly string[]>(content: string, keys: KS, path: string, required: boolean): Record<KS[number], number> {
|
||||
function parseMemoryFile<KS extends readonly string[]>(content: string, keys: KS, path: string): Record<KS[number], number> {
|
||||
const result = {} as Record<KS[number], number>;
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
@@ -79,256 +75,16 @@ 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<string, Record<string, number>>) {
|
||||
const collapsed = {} as Record<string, Record<string, number>>;
|
||||
|
||||
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<keyof typeof heapSnapshotCategory, number>;
|
||||
}
|
||||
|
||||
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<string, number>, key: string, value: number) {
|
||||
map[key] = (map[key] ?? 0) + value;
|
||||
}
|
||||
|
||||
const edgeStartIndexes = new Map<number, number>();
|
||||
const retainerCounts = new Map<number, number>();
|
||||
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<number>();
|
||||
|
||||
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');
|
||||
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<string, unknown> {
|
||||
@@ -417,7 +173,7 @@ async function getHeapSnapshotStatistics(serverProcess: ChildProcess): Promise<H
|
||||
}
|
||||
|
||||
const snapshot = JSON.parse(await fs.readFile(writtenPath, 'utf-8'));
|
||||
return analyzeHeapSnapshot(snapshot);
|
||||
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`);
|
||||
@@ -427,17 +183,23 @@ async function getHeapSnapshotStatistics(serverProcess: ChildProcess): Promise<H
|
||||
|
||||
async function getAllMemoryUsage(serverProcess: ChildProcess) {
|
||||
const pid = serverProcess.pid!;
|
||||
const stableSmapsRollup = await measureMemoryUntilStable(
|
||||
() => 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',
|
||||
@@ -449,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}`);
|
||||
});
|
||||
@@ -467,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`);
|
||||
});
|
||||
@@ -485,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<void>(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<ReturnType<typeof measureMemory>>[];
|
||||
};
|
||||
export type MemorySample = Awaited<ReturnType<typeof measureMemory>>;
|
||||
|
||||
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) => {
|
||||
|
||||
@@ -26,7 +26,6 @@ declare module 'probe-image-size' {
|
||||
function probeImageSize(src: string | ReadStream, callback: (err: Error | null, result?: ProbeResult) => void): void;
|
||||
function probeImageSize(src: string | ReadStream, options: ProbeOptions, callback: (err: Error | null, result?: ProbeResult) => void): void;
|
||||
|
||||
namespace probeImageSize {} // Hack
|
||||
|
||||
export = probeImageSize;
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export { probeImageSize as default };
|
||||
}
|
||||
|
||||
@@ -10,10 +10,11 @@
|
||||
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';
|
||||
|
||||
import 'reflect-metadata';
|
||||
@@ -27,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
|
||||
@@ -41,27 +44,19 @@ cluster.on('online', worker => {
|
||||
|
||||
// Listen for dying workers
|
||||
cluster.on('exit', worker => {
|
||||
// Replace the dead worker,
|
||||
// we're not sentimental
|
||||
clusterLogger.error(chalk.red(`[${worker.id}] died :(`));
|
||||
if (isShutdownInProgress()) {
|
||||
clusterLogger.info(`Process exited during shutdown: [${worker.id}]`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 終了したワーカーは従来どおり再生成し、表示色は出力処理へ任せます。
|
||||
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;
|
||||
logger.info(`The process is going to exit with code ${code}`);
|
||||
});
|
||||
|
||||
@@ -69,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 {
|
||||
@@ -82,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();
|
||||
|
||||
@@ -11,16 +11,30 @@ 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 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';
|
||||
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');
|
||||
|
||||
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}`;
|
||||
@@ -51,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();
|
||||
@@ -66,26 +82,16 @@ export async function masterMain() {
|
||||
|
||||
initExtraThreadPool(config);
|
||||
|
||||
if (config.sentryForBackend) {
|
||||
const Sentry = await import('@sentry/node');
|
||||
const { nodeProfilingIntegration } = await import('@sentry/profiling-node');
|
||||
|
||||
Sentry.init({
|
||||
integrations: [
|
||||
...(config.sentryForBackend.enableNodeProfiling ? [nodeProfilingIntegration()] : []),
|
||||
],
|
||||
|
||||
// Performance Monitoring
|
||||
tracesSampleRate: 1.0, // Capture 100% of the transactions
|
||||
|
||||
// Set sampling rate for profiling - this is relative to tracesSampleRate
|
||||
profilesSampleRate: 1.0,
|
||||
|
||||
maxBreadcrumbs: 0,
|
||||
|
||||
...config.sentryForBackend.options,
|
||||
});
|
||||
try {
|
||||
await initTelemetry(config);
|
||||
} catch (e) {
|
||||
bootLogger.error(e instanceof Error ? e : new Error(String(e)), null, true);
|
||||
process.exit(1);
|
||||
}
|
||||
installShutdownSignalHandlers({
|
||||
shutdownTasks: [shutdownTelemetry, shutdownLogging],
|
||||
onRegistered: message => bootLogger.info(message),
|
||||
});
|
||||
|
||||
bootLogger.info(
|
||||
`mode: [disableClustering: ${envOption.disableClustering}, onlyServer: ${envOption.onlyServer}, onlyQueue: ${envOption.onlyQueue}]`,
|
||||
@@ -143,12 +149,14 @@ function showNodejsVersion(): void {
|
||||
nodejsLogger.info(`Version ${process.version} detected.`);
|
||||
}
|
||||
|
||||
/** 設定を読み込み、成功時に後続のログ出力形式を適用します。 */
|
||||
function loadConfigBoot(): Config {
|
||||
const configLogger = bootLogger.createSubLogger('config');
|
||||
let config;
|
||||
|
||||
try {
|
||||
config = loadConfig();
|
||||
configureLogging(config.logging);
|
||||
} catch (exception) {
|
||||
if (typeof exception === 'string') {
|
||||
configLogger.error(exception);
|
||||
@@ -160,8 +168,6 @@ function loadConfigBoot(): Config {
|
||||
throw exception;
|
||||
}
|
||||
|
||||
configLogger.succ('Loaded');
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
|
||||
59
packages/backend/src/boot/process-error-handler.ts
Normal file
59
packages/backend/src/boot/process-error-handler.ts
Normal file
@@ -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);
|
||||
});
|
||||
}
|
||||
100
packages/backend/src/boot/shutdown-handler.ts
Normal file
100
packages/backend/src/boot/shutdown-handler.ts
Normal file
@@ -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<void>): unknown;
|
||||
};
|
||||
|
||||
const SHUTDOWN_TIMEOUT_MS = 10_000;
|
||||
|
||||
export type ShutdownTask = () => Promise<void>;
|
||||
|
||||
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<void>(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;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user