1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-07-25 15:35:14 +02:00

frontend bundle diagnostics と frontend browser diagnostics を統合 (#17757)

* refactor(gh): unify frontend diagnostics

* refactor(gh): unify frontend diagnostics markdown rendering

* docs(gh): restore frontend diagnostics comments

* wip

* fix

* refactor: 呼称をbase/headに統一

* tweak

* Update types.ts

* fix
This commit is contained in:
syuilo
2026-07-21 11:26:53 +09:00
committed by GitHub
parent 159b1a4484
commit b51d5ba0a0
47 changed files with 693 additions and 810 deletions

View File

@@ -1,44 +0,0 @@
name: Frontend browser diagnostics (comment)
on:
workflow_run:
workflows:
- Frontend browser diagnostics (inspect)
types:
- completed
permissions:
actions: read
contents: read
issues: write
pull-requests: write
jobs:
comment:
name: Comment frontend browser diagnostics report
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
concurrency:
group: frontend-browser-diagnostics-report-${{ github.event.workflow_run.id }}
cancel-in-progress: true
steps:
- name: Download browser metrics report
uses: actions/download-artifact@v8
with:
name: frontend-browser-diagnostics
path: ${{ runner.temp }}/frontend-browser-diagnostics
github-token: ${{ github.token }}
repository: ${{ github.repository }}
run-id: ${{ github.event.workflow_run.id }}
- name: Load PR number
id: load-pr-number
shell: bash
run: echo "pr-number=$(cat "$RUNNER_TEMP/frontend-browser-diagnostics/pr-number.txt")" >> "$GITHUB_OUTPUT"
- name: Comment on pull request
uses: thollander/actions-comment-pull-request@v3
with:
pr-number: ${{ steps.load-pr-number.outputs.pr-number }}
comment-tag: frontend-browser-diagnostics
file-path: ${{ runner.temp }}/frontend-browser-diagnostics/frontend-browser-diagnostics.md

View File

@@ -1,44 +0,0 @@
name: Frontend bundle diagnostics (comment)
on:
workflow_run:
workflows:
- Frontend bundle diagnostics (inspect)
types:
- completed
permissions:
actions: read
contents: read
issues: write
pull-requests: write
jobs:
comment:
name: Comment frontend bundle report
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
concurrency:
group: frontend-bundle-diagnostics-report-${{ github.event.workflow_run.id }}
cancel-in-progress: true
steps:
- name: Download bundle report
uses: actions/download-artifact@v8
with:
name: frontend-bundle-report
path: ${{ runner.temp }}/frontend-bundle-report
github-token: ${{ github.token }}
repository: ${{ github.repository }}
run-id: ${{ github.event.workflow_run.id }}
- name: Load PR number
id: load-pr-number
shell: bash
run: echo "pr-number=$(cat "$RUNNER_TEMP/frontend-bundle-report/pr-number.txt")" >> "$GITHUB_OUTPUT"
- name: Comment on pull request
uses: thollander/actions-comment-pull-request@v3
with:
pr-number: ${{ steps.load-pr-number.outputs.pr-number }}
comment-tag: frontend-bundle-diagnostics
file-path: ${{ runner.temp }}/frontend-bundle-report/frontend-bundle-diagnostics-report.md

View File

@@ -1,148 +0,0 @@
name: Frontend bundle diagnostics (inspect)
on:
pull_request:
types:
- opened
- synchronize
- reopened
- ready_for_review
paths:
- packages/frontend/**
- packages/frontend-shared/**
- packages/frontend-builder/**
- packages/i18n/**
- packages/icons-subsetter/**
- packages/misskey-js/**
- packages/misskey-reversi/**
- packages/misskey-bubble-game/**
- package.json
- pnpm-lock.yaml
- pnpm-workspace.yaml
- .node-version
- packages-private/diagnostics-shared/**
- packages-private/diagnostics-frontend-bundle/**
- .github/workflows/frontend-bundle-diagnostics.inspect.yml
- .github/workflows/frontend-bundle-diagnostics.comment.yml
permissions:
contents: read
pull-requests: read
concurrency:
group: frontend-bundle-diagnostics-inspect-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
report:
name: Build frontend bundle report
runs-on: ubuntu-latest
steps:
- name: Checkout base
uses: actions/checkout@v6.0.3
with:
repository: ${{ github.event.pull_request.base.repo.full_name }}
ref: ${{ github.event.pull_request.base.sha }}
path: before
submodules: true
- name: Checkout pull request
uses: actions/checkout@v6.0.3
with:
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.sha }}
path: after
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
with:
package_json_file: after/package.json
- name: Setup Node.js
uses: actions/setup-node@v6.4.0
with:
node-version-file: after/.node-version
cache: pnpm
cache-dependency-path: |
before/pnpm-lock.yaml
after/pnpm-lock.yaml
- name: Install dependencies for base
working-directory: before
run: pnpm i --frozen-lockfile
- name: Build frontend dependencies for base
working-directory: before
run: pnpm --filter "frontend^..." run build
- name: Prepare report output
run: mkdir -p "$RUNNER_TEMP/frontend-bundle-report"
- name: Build frontend report for base
working-directory: before
env:
FRONTEND_BUNDLE_VISUALIZER: 'true'
FRONTEND_BUNDLE_VISUALIZER_FILE: ${{ runner.temp }}/frontend-bundle-report/before-stats.json
run: pnpm --filter frontend run build
- name: Install dependencies for pull request
working-directory: after
run: pnpm i --frozen-lockfile
- name: Build frontend dependencies for pull request
working-directory: after
run: pnpm --filter "frontend^..." run build
- name: Build frontend report for pull request
working-directory: after
env:
FRONTEND_BUNDLE_VISUALIZER: 'true'
FRONTEND_BUNDLE_VISUALIZER_FILE: ${{ runner.temp }}/frontend-bundle-report/after-stats.json
FRONTEND_BUNDLE_VISUALIZER_HTML_FILE: ${{ runner.temp }}/frontend-bundle-report/frontend-bundle-visualizer.html
run: pnpm --filter frontend run build
- name: Upload bundle visualizer
id: upload-bundle-visualizer
uses: actions/upload-artifact@v7
with:
name: frontend-bundle-visualizer
path: ${{ runner.temp }}/frontend-bundle-report/frontend-bundle-visualizer.html
if-no-files-found: error
archive: false
retention-days: 7
- name: Generate report markdown
shell: bash
working-directory: after
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_NUMBER: ${{ github.event.pull_request.number }}
FRONTEND_BUNDLE_REPORT_ARTIFACT_URL: ${{ steps.upload-bundle-visualizer.outputs.artifact-url }}
run: |
REPORT_DIR="$RUNNER_TEMP/frontend-bundle-report"
pnpm --filter diagnostics-frontend-bundle run render-md \
"$GITHUB_WORKSPACE/before" "$GITHUB_WORKSPACE/after" \
"$REPORT_DIR/before-stats.json" "$REPORT_DIR/after-stats.json" \
"$REPORT_DIR/frontend-bundle-diagnostics-report.md"
printf '%s\n' "$PR_NUMBER" > "$REPORT_DIR/pr-number.txt"
printf '%s\n' "$BASE_SHA" > "$REPORT_DIR/base-sha.txt"
printf '%s\n' "$HEAD_SHA" > "$REPORT_DIR/head-sha.txt"
printf '%s\n' "${{ github.event.pull_request.html_url }}" > "$REPORT_DIR/pr-url.txt"
- name: Check report
run: |
REPORT_DIR="$RUNNER_TEMP/frontend-bundle-report"
test -s "$REPORT_DIR/before-stats.json"
test -s "$REPORT_DIR/after-stats.json"
test -s "$REPORT_DIR/frontend-bundle-diagnostics-report.md"
cat "$REPORT_DIR/frontend-bundle-diagnostics-report.md" >> "$GITHUB_STEP_SUMMARY"
- name: Upload bundle report
uses: actions/upload-artifact@v7
with:
name: frontend-bundle-report
path: ${{ runner.temp }}/frontend-bundle-report/
if-no-files-found: error
retention-days: 7

View File

@@ -0,0 +1,75 @@
name: Frontend diagnostics (comment)
on:
workflow_run:
workflows:
- Frontend diagnostics (inspect)
types:
- completed
permissions:
actions: read
contents: read
issues: write
pull-requests: write
jobs:
comment:
name: Comment frontend diagnostics report
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
concurrency:
group: frontend-diagnostics-report-${{ github.event.workflow_run.id }}
cancel-in-progress: true
steps:
- name: Download frontend diagnostics report
uses: actions/download-artifact@v8
with:
name: frontend-diagnostics
path: ${{ runner.temp }}/frontend-diagnostics
github-token: ${{ github.token }}
repository: ${{ github.repository }}
run-id: ${{ github.event.workflow_run.id }}
- name: Resolve current pull request
id: resolve-pr
uses: actions/github-script@v9
env:
PR_NUMBER_FILE: ${{ runner.temp }}/frontend-diagnostics/pr-number.txt
with:
script: |
const fs = require('fs/promises');
const { owner, repo } = context.repo;
const workflowRun = context.payload.workflow_run;
const pullRequestNumberText = (await fs.readFile(process.env.PR_NUMBER_FILE, 'utf8')).trim();
if (!/^[1-9]\d*$/.test(pullRequestNumberText)) {
throw new Error('Invalid pull request number in frontend diagnostics artifact.');
}
const pullRequestNumber = Number(pullRequestNumberText);
if (!Number.isSafeInteger(pullRequestNumber)) {
throw new Error('Pull request number in frontend diagnostics artifact is not a safe integer.');
}
const { data: currentPullRequest } = await github.rest.pulls.get({
owner,
repo,
pull_number: pullRequestNumber,
});
if (currentPullRequest.state !== 'open' || currentPullRequest.head.sha !== workflowRun.head_sha) {
core.notice(`Skipping stale frontend diagnostics report for pull request #${pullRequestNumber}.`);
core.setOutput('is-current', 'false');
return;
}
core.setOutput('is-current', 'true');
core.setOutput('pr-number', String(pullRequestNumber));
- name: Comment on pull request
if: steps.resolve-pr.outputs.is-current == 'true'
uses: thollander/actions-comment-pull-request@v3
with:
pr-number: ${{ steps.resolve-pr.outputs.pr-number }}
comment-tag: frontend-diagnostics
file-path: ${{ runner.temp }}/frontend-diagnostics/frontend-diagnostics.md

View File

@@ -1,4 +1,4 @@
name: Frontend browser diagnostics (inspect)
name: Frontend diagnostics (inspect)
on:
pull_request:
@@ -23,21 +23,21 @@ on:
- .node-version
- .github/misskey/test.yml
- packages-private/diagnostics-shared/**
- packages-private/diagnostics-frontend-browser/**
- .github/workflows/frontend-browser-diagnostics.inspect.yml
- .github/workflows/frontend-browser-diagnostics.comment.yml
- packages-private/diagnostics-frontend/**
- .github/workflows/frontend-diagnostics.inspect.yml
- .github/workflows/frontend-diagnostics.comment.yml
permissions:
contents: read
pull-requests: read
concurrency:
group: frontend-browser-diagnostics-inspect-${{ github.event.pull_request.number }}
group: frontend-diagnostics-inspect-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
report:
name: Measure frontend browser metrics
name: Measure frontend diagnostics
runs-on: ubuntu-latest
timeout-minutes: 90
@@ -61,79 +61,100 @@ jobs:
persist-credentials: false
repository: ${{ github.event.pull_request.base.repo.full_name }}
ref: ${{ github.event.pull_request.base.sha }}
path: before
path: base
submodules: true
- name: Checkout pull request
- name: Checkout head
uses: actions/checkout@v6.0.3
with:
persist-credentials: false
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.sha }}
path: after
path: head
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
with:
package_json_file: after/package.json
package_json_file: head/package.json
- name: Setup Node.js
uses: actions/setup-node@v6.4.0
with:
node-version-file: after/.node-version
node-version-file: head/.node-version
cache: pnpm
cache-dependency-path: |
before/pnpm-lock.yaml
after/pnpm-lock.yaml
base/pnpm-lock.yaml
head/pnpm-lock.yaml
- name: Prepare report output
shell: bash
run: mkdir -p "$RUNNER_TEMP/frontend-diagnostics"
- name: Install dependencies for base
working-directory: before
working-directory: base
run: pnpm i --frozen-lockfile
- name: Configure base
working-directory: before
working-directory: base
run: cp .github/misskey/test.yml .config
- name: Build base
working-directory: before
working-directory: base
env:
FRONTEND_BUNDLE_VISUALIZER: 'true'
FRONTEND_BUNDLE_VISUALIZER_FILE: ${{ runner.temp }}/frontend-diagnostics/base-bundle-stats.json
run: pnpm build
- name: Install dependencies for pull request
working-directory: after
- name: Install dependencies for head
working-directory: head
run: pnpm i --frozen-lockfile
- name: Configure pull request
working-directory: after
- name: Configure head
working-directory: head
run: cp .github/misskey/test.yml .config
- name: Build pull request
working-directory: after
- name: Build head
working-directory: head
env:
FRONTEND_BUNDLE_VISUALIZER: 'true'
FRONTEND_BUNDLE_VISUALIZER_FILE: ${{ runner.temp }}/frontend-diagnostics/head-bundle-stats.json
FRONTEND_BUNDLE_VISUALIZER_HTML_FILE: ${{ runner.temp }}/frontend-diagnostics/frontend-bundle-visualizer.html
run: pnpm build
- name: Upload bundle visualizer
id: upload-bundle-visualizer
uses: actions/upload-artifact@v7
with:
name: frontend-bundle-visualizer
path: ${{ runner.temp }}/frontend-diagnostics/frontend-bundle-visualizer.html
if-no-files-found: error
archive: false
retention-days: 7
- name: Install Playwright browsers
working-directory: after/packages-private/diagnostics-frontend-browser
working-directory: head/packages-private/diagnostics-frontend
run: pnpm exec playwright install --with-deps --no-shell chromium
- name: Measure frontend browser metrics
shell: bash
working-directory: after
working-directory: head
env:
FRONTEND_BROWSER_METRICS_SAMPLE_COUNT: 5
MK_ENABLE_CROSS_ORIGIN_ISOLATION: "true"
# 計測ハーネスはafter側のものを両方に使うが、成果物はrunnerのworkspace直下に出す
# 計測ハーネスはhead側のものを両方に使うが、成果物はrunnerのworkspace直下に出す
FRONTEND_BROWSER_HEAP_SNAPSHOT_OUTPUT_DIR: ${{ github.workspace }}
run: |
REPORT_DIR="$RUNNER_TEMP/frontend-browser-diagnostics"
mkdir -p "$REPORT_DIR"
pnpm --filter diagnostics-frontend-browser run inspect \
"$GITHUB_WORKSPACE/before" "$GITHUB_WORKSPACE/after" \
"$REPORT_DIR/before-browser.json" "$REPORT_DIR/after-browser.json"
REPORT_DIR="$RUNNER_TEMP/frontend-diagnostics"
pnpm --filter diagnostics-frontend run inspect-browser \
"$GITHUB_WORKSPACE/base" "$GITHUB_WORKSPACE/head" \
"$REPORT_DIR/base-browser.json" "$REPORT_DIR/head-browser.json"
- name: Upload browser base heap snapshot
id: upload-browser-base-heap-snapshot
uses: actions/upload-artifact@v7
with:
name: frontend-browser-base-heap-snapshot
path: base-heap-snapshot.heapsnapshot
archive: false
if-no-files-found: error
@@ -143,6 +164,7 @@ jobs:
id: upload-browser-head-heap-snapshot
uses: actions/upload-artifact@v7
with:
name: frontend-browser-head-heap-snapshot
path: head-heap-snapshot.heapsnapshot
archive: false
if-no-files-found: error
@@ -150,13 +172,13 @@ jobs:
- name: Generate browser detailed html
shell: bash
working-directory: after
working-directory: head
run: |
REPORT_DIR="$RUNNER_TEMP/frontend-browser-diagnostics"
test -s "$REPORT_DIR/before-browser.json"
test -s "$REPORT_DIR/after-browser.json"
pnpm --filter diagnostics-frontend-browser run render-html \
"$REPORT_DIR/before-browser.json" "$REPORT_DIR/after-browser.json" \
REPORT_DIR="$RUNNER_TEMP/frontend-diagnostics"
test -s "$REPORT_DIR/base-browser.json"
test -s "$REPORT_DIR/head-browser.json"
pnpm --filter diagnostics-frontend run render-browser-html \
"$REPORT_DIR/base-browser.json" "$REPORT_DIR/head-browser.json" \
"$REPORT_DIR/frontend-browser-detailed-html.html"
- name: Upload browser detailed html
@@ -164,54 +186,61 @@ jobs:
uses: actions/upload-artifact@v7
with:
name: frontend-browser-metrics-detailed-html
path: ${{ runner.temp }}/frontend-browser-diagnostics/frontend-browser-detailed-html.html
path: ${{ runner.temp }}/frontend-diagnostics/frontend-browser-detailed-html.html
if-no-files-found: error
archive: false
retention-days: 7
- name: Generate browser metrics report
- name: Generate frontend diagnostics report
shell: bash
working-directory: after
working-directory: head
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_NUMBER: ${{ github.event.pull_request.number }}
FRONTEND_BUNDLE_REPORT_ARTIFACT_URL: ${{ steps.upload-bundle-visualizer.outputs.artifact-url }}
FRONTEND_BROWSER_BASE_HEAP_SNAPSHOT_ARTIFACT_URL: ${{ steps.upload-browser-base-heap-snapshot.outputs.artifact-url }}
FRONTEND_BROWSER_HEAD_HEAP_SNAPSHOT_ARTIFACT_URL: ${{ steps.upload-browser-head-heap-snapshot.outputs.artifact-url }}
FRONTEND_BROWSER_DETAILED_HTML_ARTIFACT_URL: ${{ steps.upload-browser-detailed-html.outputs.artifact-url }}
run: |
REPORT_DIR="$RUNNER_TEMP/frontend-browser-diagnostics"
test -s "$REPORT_DIR/before-browser.json"
test -s "$REPORT_DIR/after-browser.json"
pnpm --filter diagnostics-frontend-browser run render-md \
"$REPORT_DIR/before-browser.json" "$REPORT_DIR/after-browser.json" \
"$REPORT_DIR/frontend-browser-diagnostics.md"
REPORT_DIR="$RUNNER_TEMP/frontend-diagnostics"
test -s "$REPORT_DIR/base-bundle-stats.json"
test -s "$REPORT_DIR/head-bundle-stats.json"
test -s "$REPORT_DIR/base-browser.json"
test -s "$REPORT_DIR/head-browser.json"
pnpm --filter diagnostics-frontend run render-md \
"$GITHUB_WORKSPACE/base" "$GITHUB_WORKSPACE/head" \
"$REPORT_DIR/base-bundle-stats.json" "$REPORT_DIR/head-bundle-stats.json" \
"$REPORT_DIR/base-browser.json" "$REPORT_DIR/head-browser.json" \
"$REPORT_DIR/frontend-diagnostics.md"
printf '%s\n' "$PR_NUMBER" > "$REPORT_DIR/pr-number.txt"
printf '%s\n' "$BASE_SHA" > "$REPORT_DIR/base-sha.txt"
printf '%s\n' "$HEAD_SHA" > "$REPORT_DIR/head-sha.txt"
printf '%s\n' "${{ github.event.pull_request.html_url }}" > "$REPORT_DIR/pr-url.txt"
- name: Check browser metrics report
- name: Check frontend diagnostics report
shell: bash
run: |
REPORT_DIR="$RUNNER_TEMP/frontend-browser-diagnostics"
test -s "$REPORT_DIR/frontend-browser-diagnostics.md"
REPORT_DIR="$RUNNER_TEMP/frontend-diagnostics"
test -s "$REPORT_DIR/frontend-diagnostics.md"
test -s "$REPORT_DIR/frontend-browser-detailed-html.html"
test -s "$REPORT_DIR/pr-number.txt"
test -s "$REPORT_DIR/head-sha.txt"
cat "$REPORT_DIR/frontend-browser-diagnostics.md" >> "$GITHUB_STEP_SUMMARY"
cat "$REPORT_DIR/frontend-diagnostics.md" >> "$GITHUB_STEP_SUMMARY"
- name: Upload browser metrics report
- name: Upload frontend diagnostics report
uses: actions/upload-artifact@v7
with:
name: frontend-browser-diagnostics
name: frontend-diagnostics
path: |
${{ runner.temp }}/frontend-browser-diagnostics/before-browser.json
${{ runner.temp }}/frontend-browser-diagnostics/after-browser.json
${{ runner.temp }}/frontend-browser-diagnostics/frontend-browser-diagnostics.md
${{ runner.temp }}/frontend-browser-diagnostics/pr-number.txt
${{ runner.temp }}/frontend-browser-diagnostics/base-sha.txt
${{ runner.temp }}/frontend-browser-diagnostics/head-sha.txt
${{ runner.temp }}/frontend-browser-diagnostics/pr-url.txt
${{ runner.temp }}/frontend-diagnostics/base-bundle-stats.json
${{ runner.temp }}/frontend-diagnostics/head-bundle-stats.json
${{ runner.temp }}/frontend-diagnostics/base-browser.json
${{ runner.temp }}/frontend-diagnostics/head-browser.json
${{ runner.temp }}/frontend-diagnostics/frontend-diagnostics.md
${{ runner.temp }}/frontend-diagnostics/pr-number.txt
${{ runner.temp }}/frontend-diagnostics/base-sha.txt
${{ runner.temp }}/frontend-diagnostics/head-sha.txt
${{ runner.temp }}/frontend-diagnostics/pr-url.txt
if-no-files-found: error
retention-days: 7

View File

@@ -33,8 +33,7 @@ jobs:
workspace:
- diagnostics-shared
- diagnostics-backend
- diagnostics-frontend-browser
- diagnostics-frontend-bundle
- diagnostics-frontend
- changelog-checker
steps:
- uses: actions/checkout@v6.0.3

View File

@@ -8,8 +8,7 @@
| --- | --- |
| [`diagnostics-shared`](./diagnostics-shared) | 計測系パッケージが共有する統計・書式整形・V8 heap snapshot解析のユーティリティ |
| [`diagnostics-backend`](./diagnostics-backend) | バックエンドのメモリ使用量をbase/headで比較し、PRコメント用のMarkdownを生成する |
| [`diagnostics-frontend-browser`](./diagnostics-frontend-browser) | ヘッドレスChromeでフロントエンドを操作し、メモリ・ネットワーク等の指標をbase/headで比較する |
| [`diagnostics-frontend-bundle`](./diagnostics-frontend-bundle) | フロントエンドのビルド成果物のchunkサイズをbase/headで比較する |
| [`diagnostics-frontend`](./diagnostics-frontend) | フロントエンドをブラウザで起動した際のメトリクスと生成されたchunk情報等をbase/headで比較する |
| [`changelog-checker`](./changelog-checker) | `CHANGELOG.md` の追記内容を検証する |
## GitHub Actionsからの呼び出し方
@@ -20,11 +19,12 @@ CLIに渡すパスはすべて呼び出し側のカレントディレクトリ
```yaml
- name: Generate report
working-directory: after
working-directory: head
run: >-
pnpm --filter diagnostics-frontend-bundle run render-md
"$GITHUB_WORKSPACE/before" "$GITHUB_WORKSPACE/after"
"$REPORT_DIR/before-stats.json" "$REPORT_DIR/after-stats.json"
pnpm --filter diagnostics-frontend run render-md
"$GITHUB_WORKSPACE/base" "$GITHUB_WORKSPACE/head"
"$REPORT_DIR/base-bundle-stats.json" "$REPORT_DIR/head-bundle-stats.json"
"$REPORT_DIR/base-browser.json" "$REPORT_DIR/head-browser.json"
"$REPORT_DIR/report.md"
```

View File

@@ -1,31 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { readFile, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { readOptionalEnv, readRequiredEnv } from 'diagnostics-shared/env';
import { renderMarkdown } from './report/markdown';
import type { BrowserMetricsReport } from './types';
async function main() {
const [baseFileArg, headFileArg, outputFileArg] = process.argv.slice(2);
if (baseFileArg == null || headFileArg == null || outputFileArg == null) {
throw new Error('Usage: render-md <baseJson> <headJson> <outMd>');
}
const base = JSON.parse(await readFile(resolve(baseFileArg), 'utf8')) as BrowserMetricsReport;
const head = JSON.parse(await readFile(resolve(headFileArg), 'utf8')) as BrowserMetricsReport;
await writeFile(resolve(outputFileArg), renderMarkdown(base, head, {
baseHeapSnapshotUrl: readRequiredEnv('FRONTEND_BROWSER_BASE_HEAP_SNAPSHOT_ARTIFACT_URL'),
headHeapSnapshotUrl: readRequiredEnv('FRONTEND_BROWSER_HEAD_HEAP_SNAPSHOT_ARTIFACT_URL'),
detailedHtmlUrl: readOptionalEnv('FRONTEND_BROWSER_DETAILED_HTML_ARTIFACT_URL'),
}));
}
await main().catch(err => {
console.error(err);
process.exit(1);
});

View File

@@ -1,69 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import { renderHtml } from '../src/report/html';
import { renderMarkdown } from '../src/report/markdown';
import type { BrowserMetricsReport } from '../src/types';
const fixturesDir = join(import.meta.dirname, 'fixtures');
async function loadFixture(name: string) {
return JSON.parse(await readFile(join(fixturesDir, `${name}.json`), 'utf8')) as BrowserMetricsReport;
}
beforeEach(() => {
// renderHtml() が生成時刻を埋め込むので、スナップショットが揺れないよう時刻を固定する
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-07-18T00:00:00.000Z'));
});
afterEach(() => {
vi.useRealTimers();
});
/**
* 出力をゴールデンファイルで固定する。
* 意図的に変更したときは `vitest -u` で更新し、__snapshots__ の差分もレビューすること。
*/
test('renders the browser diagnostics markdown report', async () => {
const markdown = renderMarkdown(await loadFixture('base'), await loadFixture('head'), {
baseHeapSnapshotUrl: 'https://example.invalid/base',
headHeapSnapshotUrl: 'https://example.invalid/head',
detailedHtmlUrl: 'https://example.invalid/html',
});
await expect(markdown).toMatchFileSnapshot('./__snapshots__/render-md.md');
});
test('omits the details link when no detailed html artifact was uploaded', async () => {
const markdown = renderMarkdown(await loadFixture('base'), await loadFixture('head'), {
baseHeapSnapshotUrl: 'https://example.invalid/base',
headHeapSnapshotUrl: 'https://example.invalid/head',
detailedHtmlUrl: null,
});
expect(markdown).not.toContain('View details');
});
test('renders the network request diff html report', async () => {
const html = renderHtml(await loadFixture('base'), await loadFixture('head'));
await expect(html).toMatchFileSnapshot('./__snapshots__/render-html.html');
});
test('escapes html metacharacters coming from the browser session', async () => {
const base = await loadFixture('base');
const head = await loadFixture('head');
// CDP由来の値は原理的には任意の文字列になりうるので、生HTMLとして出ないことを確かめる
head.samples[0].networkRequests[0].url = 'http://127.0.0.1:61812/"><script>alert(1)</script>';
const html = renderHtml(base, head);
expect(html).not.toContain('<script>alert(1)</script>');
expect(html).toContain('&lt;script&gt;alert(1)&lt;/script&gt;');
});

View File

@@ -1,25 +0,0 @@
import tsParser from '@typescript-eslint/parser';
import sharedConfig from '../../packages/shared/eslint.config.js';
// eslint-disable-next-line import/no-default-export
export default [
...sharedConfig,
{
ignores: [
'**/node_modules',
'**/__snapshots__',
'test/fixtures',
],
},
{
files: ['**/*.ts', '**/*.tsx'],
languageOptions: {
parserOptions: {
parser: tsParser,
project: ['./tsconfig.json'],
sourceType: 'module',
tsconfigRootDir: import.meta.dirname,
},
},
},
];

View File

@@ -1,22 +0,0 @@
{
"name": "diagnostics-frontend-bundle",
"private": true,
"type": "module",
"scripts": {
"eslint": "eslint './**/*.{js,jsx,ts,tsx}'",
"lint": "pnpm typecheck && pnpm eslint",
"render-md": "tsx src/render-md.ts",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"diagnostics-shared": "workspace:*"
},
"devDependencies": {
"@types/node": "26.1.1",
"tsx": "4.23.1",
"typescript": "5.9.3",
"vite": "8.1.4",
"vitest": "4.1.10"
}
}

View File

@@ -1,32 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { readRequiredEnv } from 'diagnostics-shared/env';
import { collectReport } from './manifest';
import { renderBundleReportMarkdown } from './report';
import type { VisualizerReport } from './visualizer';
async function main() {
const [beforeDir, afterDir, beforeStatsFile, afterStatsFile, outFile] = process.argv.slice(2).map(arg => path.resolve(arg));
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (outFile == null) throw new Error('Usage: render-md <beforeDir> <afterDir> <beforeStatsJson> <afterStatsJson> <outMd>');
// 未設定のまま `undefined` という文字列をコメントに埋め込まないよう、ここで落とす
const visualizerArtifactUrl = readRequiredEnv('FRONTEND_BUNDLE_REPORT_ARTIFACT_URL');
const before = await collectReport(beforeDir);
const after = await collectReport(afterDir);
const beforeStats = JSON.parse(await fs.readFile(beforeStatsFile, 'utf8')) as VisualizerReport;
const afterStats = JSON.parse(await fs.readFile(afterStatsFile, 'utf8')) as VisualizerReport;
await fs.writeFile(outFile, renderBundleReportMarkdown(before, after, beforeStats, afterStats, { visualizerArtifactUrl }));
}
await main().catch(err => {
console.error(err);
process.exit(1);
});

View File

@@ -1,33 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { renderFrontendChunkReport } from './chunk-report';
import { collectVisualizerReport, renderVisualizerSummaryTable, type VisualizerReport } from './visualizer';
import type { CollectedReport } from './manifest';
export type RenderBundleReportOptions = {
/** rollup-plugin-visualizer が出力したtreemap HTMLのartifact URL */
visualizerArtifactUrl: string;
};
export function renderBundleReportMarkdown(
before: CollectedReport,
after: CollectedReport,
beforeStats: VisualizerReport,
afterStats: VisualizerReport,
options: RenderBundleReportOptions,
) {
return [
'## 📦 Frontend Bundle Report',
'',
renderFrontendChunkReport(before, after),
'',
'## Bundle Stats',
'',
renderVisualizerSummaryTable(collectVisualizerReport(beforeStats), collectVisualizerReport(afterStats)),
'',
`[Open treemap HTML](${options.visualizerArtifactUrl})`,
].join('\n');
}

View File

@@ -1,100 +0,0 @@
## 📦 Frontend Bundle Report
<details>
<summary>Chunk size diff (2 updated, 0 added, 0 removed)</summary>
| Chunk | Before | After | Δ | Δ (%) |
| --- | ---: | ---: | ---: | ---: |
| (total) | 120 KB | 127 KB | $\color{orange}{\text{+6.3 KB}}$ | $\color{orange}{\text{+5.2\\%}}$ |
| | | | | |
| <details><summary>`vue`</summary> `assets/vue-b2.js` </details> | 90 KB | 96 KB | $\color{orange}{\text{+6 KB}}$ | $\color{orange}{\text{+6.7\\%}}$ |
| (other generated chunks) | 1.2 KB | 1.5 KB | $\text{+300 B}$ | $\color{orange}{\text{+25\\%}}$ |
| (other) | 20 KB | 20 KB | $\text{+3 B}$ | $\text{+0\\%}$ |
</details>
<details>
<summary>Startup chunk size (2 updated, 0 added, 0 removed)</summary>
| Chunk | Before | After | Δ | Δ (%) |
| --- | ---: | ---: | ---: | ---: |
| (total) | 114 KB | 120 KB | $\color{orange}{\text{+6 KB}}$ | $\color{orange}{\text{+5.3\\%}}$ |
| | | | | |
| <details><summary>`vue`</summary> `assets/vue-b2.js` </details> | 90 KB | 96 KB | $\color{orange}{\text{+6 KB}}$ | $\color{orange}{\text{+6.7\\%}}$ |
| (other) | 24 KB | 24 KB | $\text{+3 B}$ | $\text{+0\\%}$ |
_Startup chunks are the Vite entry for `src/_boot_.ts` and its static imports._
</details>
## Bundle Stats
<table>
<thead>
<tr>
<th rowspan="2"></th>
<th rowspan="2">Bundles</th>
<th rowspan="2">Modules</th>
<th rowspan="2">Entries</th>
<th colspan="2">Imports</th>
<th colspan="3">Size</th>
</tr>
<tr>
<th>Static</th>
<th>Dynamic</th>
<th>Rendered</th>
<th>Gzip</th>
<th>Brotli</th>
</tr>
</thead>
<tbody>
<tr>
<th><b>Before</b></th>
<td>2</td>
<td>6</td>
<td>1</td>
<td>2</td>
<td>3</td>
<td>21 KB</td>
<td>6.3 KB</td>
<td>5.3 KB</td>
</tr>
<tr>
<th><b>After</b></th>
<td>2</td>
<td>7</td>
<td>1</td>
<td>3</td>
<td>3</td>
<td>31 KB</td>
<td>8.4 KB</td>
<td>7 KB</td>
</tr>
<tr><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td></tr>
<tr>
<th><b>Δ</b></th>
<td>0</td>
<td>$\color{orange}{\text{+1}}$</td>
<td>0</td>
<td>$\color{orange}{\text{+1}}$</td>
<td>0</td>
<td>$\color{orange}{\text{+9.8 KB}}$</td>
<td>$\color{orange}{\text{+2.1 KB}}$</td>
<td>$\color{orange}{\text{+1.8 KB}}$</td>
</tr>
<tr>
<th><b>Δ (%)</b></th>
<td>0%</td>
<td>$\color{orange}{\text{+16.7\%}}$</td>
<td>0%</td>
<td>$\color{orange}{\text{+50\%}}$</td>
<td>0%</td>
<td>$\color{orange}{\text{+46.7\%}}$</td>
<td>$\color{orange}{\text{+33.3\%}}$</td>
<td>$\color{orange}{\text{+33.3\%}}$</td>
</tr>
</tbody>
</table>
[Open treemap HTML](https://example.invalid/treemap)

View File

@@ -1,20 +0,0 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"strictFunctionTypes": true,
"strictNullChecks": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["node"]
},
"include": [
"src/**/*.ts",
"test/**/*.ts"
],
"exclude": []
}

View File

@@ -1,12 +1,12 @@
{
"name": "diagnostics-frontend-browser",
"name": "diagnostics-frontend",
"private": true,
"type": "module",
"scripts": {
"eslint": "eslint './**/*.{js,jsx,ts,tsx}'",
"inspect": "tsx src/inspect.ts",
"inspect-browser": "tsx src/inspect-browser.ts",
"lint": "pnpm typecheck && pnpm eslint",
"render-html": "tsx src/render-html.ts",
"render-browser-html": "tsx src/render-browser-html.ts",
"render-md": "tsx src/render-md.ts",
"test": "vitest run",
"typecheck": "tsc --noEmit"

View File

@@ -5,9 +5,9 @@
import { writeFile } from 'node:fs/promises';
import { chromium } from 'playwright';
import type { Browser, BrowserContext, CDPSession, Page } from 'playwright';
import { enableNetworkTracking, type NetworkTracker } from './network';
import type { BrowserDiagnostics, BrowserMeasurement, NetworkRequest, TabMemory, WebSocketConnection } from '../types';
import type { Browser, BrowserContext, CDPSession, Page } from 'playwright';
import type { BrowserDiagnostics, BrowserMeasurement, NetworkRequest, TabMemory, WebSocketConnection } from './types';
export type HeadlessChromeOptions = {
scenarioTimeoutMs: number;

View File

@@ -4,7 +4,7 @@
*/
import { median } from 'diagnostics-shared/stats';
import type { BrowserDiagnostics } from '../types';
import type { BrowserDiagnostics } from './types';
export function summarizeBrowserDiagnostics(samples: BrowserDiagnostics[]): BrowserDiagnostics {
const medianOf = (select: (sample: BrowserDiagnostics) => number) => median(samples.map(select));

View File

@@ -4,7 +4,7 @@
*/
import type { CDPSession } from 'playwright';
import type { NetworkRequest, NetworkSummary, WebSocketConnection } from '../types';
import type { NetworkRequest, NetworkSummary, WebSocketConnection } from './types';
function normalizeHeaders(headers: Record<string, unknown> | undefined) {
if (headers == null) return undefined;

View File

@@ -224,7 +224,7 @@ function renderRound(round: number, diffs: RequestDiff[]): Raw {
</section>`;
}
export function renderHtml(base: BrowserMetricsReport, head: BrowserMetricsReport) {
export function renderBrowserDiagnosticsHtml(base: BrowserMetricsReport, head: BrowserMetricsReport) {
const diffs = diffReports(base, head);
const rounds = [...new Set(diffs.map(diff => diff.round))].toSorted((a, b) => a - b);
const generatedAt = new Date().toISOString();

View File

@@ -3,9 +3,9 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { closeUserSetupDialog, postNote, registerUser, resetState, signupThroughUi, visitHome } from '../../../packages/frontend/test/e2e/shared';
import { closeUserSetupDialog, postNote, registerUser, resetState, signupThroughUi, visitHome } from '../../../../packages/frontend/test/e2e/shared';
import { sleep } from './server';
import type { HeadlessChromeController } from './browser/controller';
import type { HeadlessChromeController } from './controller';
export const scenarioDescription = 'fresh browser signup, first timeline note, after the note becomes visible';

View File

@@ -34,10 +34,15 @@ export function startServer(label: string, repoDir: string) {
const serverStartupTimeoutMs = 120_000;
function hasExited(child: ChildProcess) {
return child.exitCode != null || child.signalCode != null;
}
export async function waitForServer(baseUrl: string, child: ChildProcess) {
const startedAt = Date.now();
while (Date.now() - startedAt < serverStartupTimeoutMs) {
if (child.exitCode != null) throw new Error(`Misskey server exited early with code ${child.exitCode}`);
if (child.signalCode != null) throw new Error(`Misskey server exited early with signal ${child.signalCode}`);
try {
// 応答が返らないままだとfetchが待ち続け、外側の120秒の上限を超えてしまう
const remainingMs = serverStartupTimeoutMs - (Date.now() - startedAt);
@@ -55,7 +60,7 @@ export async function waitForServer(baseUrl: string, child: ChildProcess) {
}
export async function stopServer(child: ChildProcess) {
if (child.exitCode != null) return;
if (hasExited(child)) return;
if (process.platform === 'win32') {
spawnSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' });
@@ -69,14 +74,14 @@ export async function stopServer(child: ChildProcess) {
}
await new Promise<void>(resolvePromise => {
if (child.exitCode != null) {
if (hasExited(child)) {
resolvePromise();
return;
}
const forceKillTimer = setTimeout(() => {
// 猶予の間に終了していれば、PIDが再利用されて無関係のプロセスを撃つ恐れがある
if (child.exitCode == null && child.pid != null) {
if (!hasExited(child) && child.pid != null) {
try {
if (process.platform === 'win32') {
spawnSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' });

View File

@@ -5,7 +5,7 @@
import { finiteMedian } from 'diagnostics-shared/stats';
import { summarizeHeapSnapshotDataSamples } from 'diagnostics-shared/heap-snapshot';
import { summarizeBrowserDiagnostics } from './browser/diagnostics';
import { summarizeBrowserDiagnostics } from './diagnostics';
import type { BrowserMeasurement, BrowserMeasurementSample, BrowserMetricsReport, NetworkSummary } from './types';
export type SummarizeOptions = {

View File

@@ -5,6 +5,11 @@
import type { HeapSnapshotData } from 'diagnostics-shared/heap-snapshot';
// NOTE: (AI向け)
// NetworkRequest がリクエスト本文と要求・応答ヘッダを保持し、APIキーやパスワード、Cookieなどの認証情報も含まれ得るが、
// workflow上で作られるMisskeyアカウントはテスト用アカウントであり、誰かが実際に使用するプライベートなアカウントではないため、
// それらの情報が第三者から閲覧できても何の問題もなく、脆弱性ではない。
export type NetworkRequest = {
requestId: string;
url: string;

View File

@@ -9,7 +9,7 @@ import {
escapeMdTableCell,
formatBytes,
} from 'diagnostics-shared/format';
import type { CollectedReport, FileEntry } from './manifest';
import type { CollectedBundleReport, FileEntry } from './manifest';
/**
* `(other)`
@@ -25,21 +25,21 @@ function entryDisplayName(entry: FileEntry | undefined) {
return entry.displayName || entry.file;
}
export function getChunkComparisonRows(keys: string[], before: Partial<Record<string, FileEntry>>, after: Partial<Record<string, FileEntry>>) {
export function getChunkComparisonRows(keys: string[], base: Partial<Record<string, FileEntry>>, head: Partial<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;
const baseEntry = base[key];
const headEntry = head[key];
const baseSize = baseEntry?.size ?? 0;
const headSize = headEntry?.size ?? 0;
return {
key,
name: entryDisplayName(beforeEntry ?? afterEntry),
beforeFile: beforeEntry?.file,
afterFile: afterEntry?.file,
beforeSize,
afterSize,
changeType: beforeEntry == null ? 'added' : afterEntry == null ? 'removed' : beforeSize !== afterSize ? 'updated' : 'unchanged',
sortSize: Math.max(beforeSize, afterSize),
name: entryDisplayName(baseEntry ?? headEntry),
baseFile: baseEntry?.file,
headFile: headEntry?.file,
baseSize,
headSize,
changeType: baseEntry == null ? 'added' : headEntry == null ? 'removed' : baseSize !== headSize ? 'updated' : 'unchanged',
sortSize: Math.max(baseSize, headSize),
};
});
}
@@ -47,10 +47,10 @@ export function getChunkComparisonRows(keys: string[], before: Partial<Record<st
export type ChunkComparisonRow = ReturnType<typeof getChunkComparisonRows>[number];
export type ChunkAggregate = {
beforeSize: number;
afterSize: number;
beforeCount: number;
afterCount: number;
baseSize: number;
headSize: number;
baseCount: number;
headCount: number;
};
export function sumChunkSizes(chunks: FileEntry[]) {
@@ -58,29 +58,29 @@ export function sumChunkSizes(chunks: FileEntry[]) {
}
/**
* (= before/after )
* (= base/head )
*/
export function generatedAggregate(before: FileEntry[], after: FileEntry[]): ChunkAggregate {
const beforeGenerated = before.filter(chunk => chunk.comparisonKey == null);
const afterGenerated = after.filter(chunk => chunk.comparisonKey == null);
export function generatedAggregate(base: FileEntry[], head: FileEntry[]): ChunkAggregate {
const baseGenerated = base.filter(chunk => chunk.comparisonKey == null);
const headGenerated = head.filter(chunk => chunk.comparisonKey == null);
return {
beforeSize: sumChunkSizes(beforeGenerated),
afterSize: sumChunkSizes(afterGenerated),
beforeCount: beforeGenerated.length,
afterCount: afterGenerated.length,
baseSize: sumChunkSizes(baseGenerated),
headSize: sumChunkSizes(headGenerated),
baseCount: baseGenerated.length,
headCount: headGenerated.length,
};
}
export function hasSmallDelta(row: ChunkComparisonRow) {
return Math.abs(row.afterSize - row.beforeSize) <= smallDeltaThreshold;
return Math.abs(row.headSize - row.baseSize) <= smallDeltaThreshold;
}
export function comparisonRowsAggregate(rows: ChunkComparisonRow[]): ChunkAggregate {
return {
beforeSize: rows.reduce((sum, row) => sum + row.beforeSize, 0),
afterSize: rows.reduce((sum, row) => sum + row.afterSize, 0),
beforeCount: rows.filter(row => row.beforeFile != null).length,
afterCount: rows.filter(row => row.afterFile != null).length,
baseSize: rows.reduce((sum, row) => sum + row.baseSize, 0),
headSize: rows.reduce((sum, row) => sum + row.headSize, 0),
baseCount: rows.filter(row => row.baseFile != null).length,
headCount: rows.filter(row => row.headFile != null).length,
};
}
@@ -108,69 +108,69 @@ export function formatChunkChangeSummary(label: string, summary: ReturnType<type
*
*/
export function compareChunkComparisonRows(a: ChunkComparisonRow, b: ChunkComparisonRow) {
return Math.abs(b.afterSize - b.beforeSize) - Math.abs(a.afterSize - a.beforeSize)
|| (b.afterSize - b.beforeSize) - (a.afterSize - a.beforeSize)
return Math.abs(b.headSize - b.baseSize) - Math.abs(a.headSize - a.baseSize)
|| (b.headSize - b.baseSize) - (a.headSize - a.baseSize)
|| b.sortSize - a.sortSize
|| a.name.localeCompare(b.name);
}
export function chunkFileDisplay(row: ChunkComparisonRow) {
if (row.beforeFile == null) return row.afterFile ?? '';
if (row.afterFile == null || row.beforeFile === row.afterFile) return row.beforeFile;
return `${row.beforeFile}${row.afterFile}`;
if (row.baseFile == null) return row.headFile ?? '';
if (row.headFile == null || row.baseFile === row.headFile) return row.baseFile;
return `${row.baseFile}${row.headFile}`;
}
export function chunkMarkdownTable(
rows: ChunkComparisonRow[],
total?: { beforeSize: number; afterSize: number },
total?: { baseSize: number; headSize: 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);
const hasGenerated = generated != null && (generated.baseCount > 0 || generated.headCount > 0);
const hasOther = other != null && (other.baseCount > 0 || other.headCount > 0);
if (rows.length === 0 && total == null && !hasGenerated && !hasOther) return '_No data_';
const lines = [
'| Chunk | Before | After | Δ | Δ (%) |',
'| Chunk | Base | Head | Δ | Δ (%) |',
'| --- | ---: | ---: | ---: | ---: |',
];
if (total != null) {
lines.push(`| (total) | ${formatBytes(total.beforeSize)} | ${formatBytes(total.afterSize)} | ${calcAndFormatDeltaBytes(total.beforeSize, total.afterSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(total.beforeSize, total.afterSize, 0.1)} |`);
lines.push(`| (total) | ${formatBytes(total.baseSize)} | ${formatBytes(total.headSize)} | ${calcAndFormatDeltaBytes(total.baseSize, total.headSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(total.baseSize, total.headSize, 0.1)} |`);
lines.push('| | | | | |');
}
for (const row of rows) {
const chunkFile = chunkFileDisplay(row);
if (row.changeType === 'added') {
lines.push(`| <details><summary>\`${escapeMdTableCell(row.name)}\`</summary> \`${escapeMdTableCell(chunkFile)}\` </details> | ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{orange}{\\text{( + )}}$ |`);
lines.push(`| <details><summary>\`${escapeMdTableCell(row.name)}\`</summary> \`${escapeMdTableCell(chunkFile)}\` </details> | ${formatBytes(row.baseSize)} | ${formatBytes(row.headSize)} | ${calcAndFormatDeltaBytes(row.baseSize, row.headSize, 1000)} | $\\color{orange}{\\text{( + )}}$ |`);
} else if (row.changeType === 'removed') {
lines.push(`| <details><summary>\`${escapeMdTableCell(row.name)}\`</summary> \`${escapeMdTableCell(chunkFile)}\` </details> | ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{green}{\\text{( - )}}$ |`);
lines.push(`| <details><summary>\`${escapeMdTableCell(row.name)}\`</summary> \`${escapeMdTableCell(chunkFile)}\` </details> | ${formatBytes(row.baseSize)} | ${formatBytes(row.headSize)} | ${calcAndFormatDeltaBytes(row.baseSize, row.headSize, 1000)} | $\\color{green}{\\text{( - )}}$ |`);
} else {
lines.push(`| <details><summary>\`${escapeMdTableCell(row.name)}\`</summary> \`${escapeMdTableCell(chunkFile)}\` </details> | ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(row.beforeSize, row.afterSize, 0.1)} |`);
lines.push(`| <details><summary>\`${escapeMdTableCell(row.name)}\`</summary> \`${escapeMdTableCell(chunkFile)}\` </details> | ${formatBytes(row.baseSize)} | ${formatBytes(row.headSize)} | ${calcAndFormatDeltaBytes(row.baseSize, row.headSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(row.baseSize, row.headSize, 0.1)} |`);
}
}
if (hasGenerated) {
lines.push(`| (other generated chunks) | ${formatBytes(generated.beforeSize)} | ${formatBytes(generated.afterSize)} | ${calcAndFormatDeltaBytes(generated.beforeSize, generated.afterSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(generated.beforeSize, generated.afterSize, 0.1)} |`);
lines.push(`| (other generated chunks) | ${formatBytes(generated.baseSize)} | ${formatBytes(generated.headSize)} | ${calcAndFormatDeltaBytes(generated.baseSize, generated.headSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(generated.baseSize, generated.headSize, 0.1)} |`);
}
if (hasOther) {
lines.push(`| (other) | ${formatBytes(other.beforeSize)} | ${formatBytes(other.afterSize)} | ${calcAndFormatDeltaBytes(other.beforeSize, other.afterSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(other.beforeSize, other.afterSize, 0.1)} |`);
lines.push(`| (other) | ${formatBytes(other.baseSize)} | ${formatBytes(other.headSize)} | ${calcAndFormatDeltaBytes(other.baseSize, other.headSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(other.baseSize, other.headSize, 0.1)} |`);
}
return lines.join('\n');
}
export function renderFrontendChunkReport(before: CollectedReport, after: CollectedReport) {
const beforeComparable = before.comparableChunks;
const afterComparable = after.comparableChunks;
const allChunkKeys = [...new Set([...Object.keys(beforeComparable), ...Object.keys(afterComparable)])];
const allComparisonRows = getChunkComparisonRows(allChunkKeys, beforeComparable, afterComparable);
export function renderFrontendChunkReport(base: CollectedBundleReport, head: CollectedBundleReport) {
const baseComparable = base.comparableChunks;
const headComparable = head.comparableChunks;
const allChunkKeys = [...new Set([...Object.keys(baseComparable), ...Object.keys(headComparable)])];
const allComparisonRows = getChunkComparisonRows(allChunkKeys, baseComparable, headComparable);
const changedRows = allComparisonRows.filter((row) => row.changeType !== 'unchanged');
const diffSummary = summarizeChunkChanges(changedRows);
const diffTotal = {
beforeSize: sumChunkSizes(before.chunks),
afterSize: sumChunkSizes(after.chunks),
baseSize: sumChunkSizes(base.chunks),
headSize: sumChunkSizes(head.chunks),
};
const diffGenerated = generatedAggregate(before.chunks, after.chunks);
const diffGenerated = generatedAggregate(base.chunks, head.chunks);
const largeDeltaRows = changedRows.filter(row => !hasSmallDelta(row)).sort(compareChunkComparisonRows);
const diffRows = largeDeltaRows.slice(0, diffRowLimit);
// 表示上限で切り捨てた行も `(other)` に含める。落とすと合計が実際の変化量と合わなくなる
@@ -179,22 +179,22 @@ export function renderFrontendChunkReport(before: CollectedReport, after: Collec
...largeDeltaRows.slice(diffRowLimit),
]);
const beforeStartupFiles = new Set(before.startupFiles);
const afterStartupFiles = new Set(after.startupFiles);
const beforeStartupChunks = before.chunks.filter(chunk => beforeStartupFiles.has(chunk.file));
const afterStartupChunks = after.chunks.filter(chunk => afterStartupFiles.has(chunk.file));
const beforeStartupComparable = comparableMap(beforeStartupChunks);
const afterStartupComparable = comparableMap(afterStartupChunks);
const startupKeys = [...new Set([...Object.keys(beforeStartupComparable), ...Object.keys(afterStartupComparable)])];
const startupComparisonRows = getChunkComparisonRows(startupKeys, beforeStartupComparable, afterStartupComparable);
const baseStartupFiles = new Set(base.startupFiles);
const headStartupFiles = new Set(head.startupFiles);
const baseStartupChunks = base.chunks.filter(chunk => baseStartupFiles.has(chunk.file));
const headStartupChunks = head.chunks.filter(chunk => headStartupFiles.has(chunk.file));
const baseStartupComparable = comparableMap(baseStartupChunks);
const headStartupComparable = comparableMap(headStartupChunks);
const startupKeys = [...new Set([...Object.keys(baseStartupComparable), ...Object.keys(headStartupComparable)])];
const startupComparisonRows = getChunkComparisonRows(startupKeys, baseStartupComparable, headStartupComparable);
const startupSummary = summarizeChunkChanges(startupComparisonRows);
const startupOther = comparisonRowsAggregate(startupComparisonRows.filter(hasSmallDelta));
const startupRows = startupComparisonRows.filter(row => !hasSmallDelta(row)).sort(compareChunkComparisonRows);
const startupTotal = {
beforeSize: sumChunkSizes(beforeStartupChunks),
afterSize: sumChunkSizes(afterStartupChunks),
baseSize: sumChunkSizes(baseStartupChunks),
headSize: sumChunkSizes(headStartupChunks),
};
const startupGenerated = generatedAggregate(beforeStartupChunks, afterStartupChunks);
const startupGenerated = generatedAggregate(baseStartupChunks, headStartupChunks);
return [
'<details>',

View File

@@ -18,8 +18,9 @@ export async function fileExists(filePath: string) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false;
throw error;
}
}

View File

@@ -6,7 +6,16 @@
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { fileExists, fileSize, normalizePath, traverseDirectory } from './fs-utils';
import type { Manifest, ManifestChunk } from 'vite';
export type BundleManifestChunk = {
file: string;
src?: string;
name?: string;
isEntry?: boolean;
imports?: string[];
};
export type BundleManifest = Record<string, BundleManifestChunk>;
/**
*
@@ -27,15 +36,15 @@ export type FileEntry = {
size: number;
};
export type CollectedReport = {
manifest: Manifest;
export type CollectedBundleReport = {
manifest: BundleManifest;
chunks: FileEntry[];
comparableChunks: Record<string, FileEntry>;
chunksByManifestKey: Record<string, FileEntry>;
startupFiles: string[];
};
export function findEntryKey(manifest: Manifest) {
export function findEntryKey(manifest: BundleManifest) {
const entries = Object.entries(manifest);
return entries.find(([key, chunk]) => key === 'src/_boot_.ts' || chunk.src === 'src/_boot_.ts')?.[0]
?? entries.find(([, chunk]) => chunk.name === 'entry' && chunk.isEntry)?.[0]
@@ -45,9 +54,9 @@ export function findEntryKey(manifest: Manifest) {
/**
*
* before/after
* base/head
*/
export function stableChunkKey(chunk: ManifestChunk) {
export function stableChunkKey(chunk: BundleManifestChunk) {
if (chunk.src != null) return `src:${normalizePath(chunk.src)}`;
if (chunk.name != null && stableNamedChunks.has(chunk.name)) return `named:${chunk.name}`;
return null;
@@ -56,7 +65,7 @@ export function stableChunkKey(chunk: ManifestChunk) {
/**
* (entry import) manifest
*/
export function collectStartupManifestKeys(manifest: Manifest) {
export function collectStartupManifestKeys(manifest: BundleManifest) {
const entryKey = findEntryKey(manifest);
const keys = new Set<string>();
if (entryKey == null) throw new Error('Unable to find frontend startup entry in Vite manifest.');
@@ -101,10 +110,10 @@ export async function resolveBuiltFile(outDir: string, file: string) {
};
}
export async function collectReport(repoDir: string): Promise<CollectedReport> {
export async function collectBundleReport(repoDir: string): Promise<CollectedBundleReport> {
const outDir = path.join(repoDir, 'built/_frontend_vite_');
const manifestPath = path.join(outDir, 'manifest.json');
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as Manifest;
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as BundleManifest;
const chunksByFile = new Map<string, FileEntry>();
const comparableChunks = new Map<string, FileEntry>();
const chunksByManifestKey = new Map<string, FileEntry>();

View File

@@ -141,7 +141,7 @@ export function collectVisualizerReport(data: VisualizerReport) {
* 1
*
*/
export function renderVisualizerSummaryTable(before: ReturnType<typeof collectVisualizerReport>, after: ReturnType<typeof collectVisualizerReport>) {
export function renderVisualizerSummaryTable(base: ReturnType<typeof collectVisualizerReport>, head: ReturnType<typeof collectVisualizerReport>) {
const summary = [
'bundles',
'modules',
@@ -178,25 +178,25 @@ export function renderVisualizerSummaryTable(before: ReturnType<typeof collectVi
'</thead>',
'<tbody>',
'<tr>',
'<th><b>Before</b></th>',
...summary.map((key) => `<td>${formatNumber(before.summary[key])}</td>`),
...metrics.map((key) => `<td>${formatBytes(before.metrics[key])}</td>`),
'<th><b>Base</b></th>',
...summary.map((key) => `<td>${formatNumber(base.summary[key])}</td>`),
...metrics.map((key) => `<td>${formatBytes(base.metrics[key])}</td>`),
'</tr>',
'<tr>',
'<th><b>After</b></th>',
...summary.map((key) => `<td>${formatNumber(after.summary[key])}</td>`),
...metrics.map((key) => `<td>${formatBytes(after.metrics[key])}</td>`),
'<th><b>Head</b></th>',
...summary.map((key) => `<td>${formatNumber(head.summary[key])}</td>`),
...metrics.map((key) => `<td>${formatBytes(head.metrics[key])}</td>`),
'</tr>',
'<tr><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td></tr>',
'<tr>',
'<th><b>Δ</b></th>',
...summary.map((key) => `<td>${calcAndFormatDeltaNumber(before.summary[key], after.summary[key], 0)}</td>`),
...metrics.map((key) => `<td>${calcAndFormatDeltaBytes(before.metrics[key], after.metrics[key], 1000)}</td>`),
...summary.map((key) => `<td>${calcAndFormatDeltaNumber(base.summary[key], head.summary[key], 0)}</td>`),
...metrics.map((key) => `<td>${calcAndFormatDeltaBytes(base.metrics[key], head.metrics[key], 1000)}</td>`),
'</tr>',
'<tr>',
'<th><b>Δ (%)</b></th>',
...summary.map((key) => `<td>${calcAndFormatDeltaPercent(before.summary[key], after.summary[key], 0.1)}</td>`),
...metrics.map((key) => `<td>${calcAndFormatDeltaPercent(before.metrics[key], after.metrics[key], 0.1)}</td>`),
...summary.map((key) => `<td>${calcAndFormatDeltaPercent(base.summary[key], head.summary[key], 0.1)}</td>`),
...metrics.map((key) => `<td>${calcAndFormatDeltaPercent(base.metrics[key], head.metrics[key], 0.1)}</td>`),
'</tr>',
'</tbody>',
'</table>',

View File

@@ -9,10 +9,10 @@ import { readIntegerEnv, readOptionalEnv } from 'diagnostics-shared/env';
import { analyzeHeapSnapshot, defaultHeapSnapshotBreakdownTopN } from 'diagnostics-shared/heap-snapshot';
import { HeadlessChromeController } from './browser/controller';
import { summarizeNetwork } from './browser/network';
import { prepareInstance, runSignupAndPostScenario, scenarioDescription } from './scenario';
import { startServer, stopServer, waitForServer } from './server';
import { selectRepresentativeSample, summarizeSamples } from './summarize';
import type { BrowserMeasurementSample, BrowserMetricsReport } from './types';
import { prepareInstance, runSignupAndPostScenario, scenarioDescription } from './browser/scenario';
import { startServer, stopServer, waitForServer } from './browser/server';
import { selectRepresentativeSample, summarizeSamples } from './browser/summarize';
import type { BrowserMeasurementSample, BrowserMetricsReport } from './browser/types';
type Label = 'base' | 'head';
@@ -110,7 +110,7 @@ async function genReport(label: Label, repoDir: string, outputPath: string) {
async function main() {
const [baseDirArg, headDirArg, baseOutputArg, headOutputArg] = process.argv.slice(2);
if (baseDirArg == null || headDirArg == null || baseOutputArg == null || headOutputArg == null) {
throw new Error('Usage: inspect <baseDir> <headDir> <baseOutputJson> <headOutputJson>');
throw new Error('Usage: inspect-browser <baseDir> <headDir> <baseOutputJson> <headOutputJson>');
}
for (const label of labels) {

View File

@@ -5,19 +5,19 @@
import { readFile, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { renderHtml } from './report/html';
import type { BrowserMetricsReport } from './types';
import { renderBrowserDiagnosticsHtml } from './browser/report/html';
import type { BrowserMetricsReport } from './browser/types';
async function main() {
const [baseFileArg, headFileArg, outputFileArg] = process.argv.slice(2);
if (baseFileArg == null || headFileArg == null || outputFileArg == null) {
throw new Error('Usage: render-html <baseJson> <headJson> <outHtml>');
throw new Error('Usage: render-browser-html <baseJson> <headJson> <outHtml>');
}
const base = JSON.parse(await readFile(resolve(baseFileArg), 'utf8')) as BrowserMetricsReport;
const head = JSON.parse(await readFile(resolve(headFileArg), 'utf8')) as BrowserMetricsReport;
await writeFile(resolve(outputFileArg), renderHtml(base, head));
await writeFile(resolve(outputFileArg), renderBrowserDiagnosticsHtml(base, head));
}
await main().catch(err => {

View File

@@ -0,0 +1,62 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { readFile, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { readOptionalEnv, readRequiredEnv } from 'diagnostics-shared/env';
import { collectBundleReport } from './bundle/manifest';
import { renderFrontendDiagnosticsMarkdown } from './report';
import type { BrowserMetricsReport } from './browser/types';
import type { VisualizerReport } from './bundle/visualizer';
const args = process.argv.slice(2);
if (args.length !== 7) {
throw new Error('Usage: render-md <baseDir> <headDir> <baseBundleStatsJson> <headBundleStatsJson> <baseBrowserJson> <headBrowserJson> <outMd>');
}
const [
baseDirArg,
headDirArg,
baseBundleStatsFileArg,
headBundleStatsFileArg,
baseBrowserFileArg,
headBrowserFileArg,
outputFileArg,
] = args as [string, string, string, string, string, string, string];
const [
baseBundle,
headBundle,
baseBundleStatsJson,
headBundleStatsJson,
baseBrowserJson,
headBrowserJson,
] = await Promise.all([
collectBundleReport(resolve(baseDirArg)),
collectBundleReport(resolve(headDirArg)),
readFile(resolve(baseBundleStatsFileArg), 'utf8'),
readFile(resolve(headBundleStatsFileArg), 'utf8'),
readFile(resolve(baseBrowserFileArg), 'utf8'),
readFile(resolve(headBrowserFileArg), 'utf8'),
]);
await writeFile(
resolve(outputFileArg),
renderFrontendDiagnosticsMarkdown({
bundle: {
base: baseBundle,
head: headBundle,
baseStats: JSON.parse(baseBundleStatsJson) as VisualizerReport,
headStats: JSON.parse(headBundleStatsJson) as VisualizerReport,
visualizerArtifactUrl: readRequiredEnv('FRONTEND_BUNDLE_REPORT_ARTIFACT_URL'),
},
browser: {
base: JSON.parse(baseBrowserJson) as BrowserMetricsReport,
head: JSON.parse(headBrowserJson) as BrowserMetricsReport,
baseHeapSnapshotUrl: readRequiredEnv('FRONTEND_BROWSER_BASE_HEAP_SNAPSHOT_ARTIFACT_URL'),
headHeapSnapshotUrl: readRequiredEnv('FRONTEND_BROWSER_HEAD_HEAP_SNAPSHOT_ARTIFACT_URL'),
detailedHtmlUrl: readOptionalEnv('FRONTEND_BROWSER_DETAILED_HTML_ARTIFACT_URL'),
},
}),
);

View File

@@ -4,14 +4,29 @@
*/
import { formatBytes, formatColoredDelta, formatNumber } from 'diagnostics-shared/format';
import { pairedDeltaSummary, sampleSpread } from 'diagnostics-shared/stats';
import { renderHeapSnapshotTable, type HeapSnapshotReport } from 'diagnostics-shared/heap-snapshot';
import type { BrowserMeasurement, BrowserMeasurementSample, BrowserMetricsReport } from '../types';
import { pairedDeltaSummary } from 'diagnostics-shared/stats';
import { renderFrontendChunkReport } from './bundle/chunk-report';
import { collectVisualizerReport, renderVisualizerSummaryTable, type VisualizerReport } from './bundle/visualizer';
import type { CollectedBundleReport } from './bundle/manifest';
import type { BrowserMeasurement, BrowserMeasurementSample, BrowserMetricsReport } from './browser/types';
export type RenderMarkdownOptions = {
baseHeapSnapshotUrl: string;
headHeapSnapshotUrl: string;
detailedHtmlUrl?: string | null;
export type FrontendDiagnosticsMarkdownInput = {
bundle: {
base: CollectedBundleReport;
head: CollectedBundleReport;
baseStats: VisualizerReport;
headStats: VisualizerReport;
/** rollup-plugin-visualizer が出力したtreemap HTMLのartifact URL */
visualizerArtifactUrl: string;
};
browser: {
base: BrowserMetricsReport;
head: BrowserMetricsReport;
baseHeapSnapshotUrl: string;
headHeapSnapshotUrl: string;
detailedHtmlUrl?: string | null;
};
};
function renderMetricRow(
@@ -45,7 +60,7 @@ function resourceTypeSampleBytes(sample: BrowserMeasurementSample, resourceTypes
return resourceTypeBytes(sample, resourceTypes);
}
function renderSummaryTable(base: BrowserMetricsReport, head: BrowserMetricsReport, all = false) {
function renderBrowserSummaryTable(base: BrowserMetricsReport, head: BrowserMetricsReport, all = false) {
//function getMetric(report: BrowserMeasurement, key: string) {
// return report.performance.cdpMetrics[key];
//}
@@ -154,13 +169,14 @@ function toHeapSnapshotReport(report: BrowserMetricsReport): HeapSnapshotReport
};
}
export function renderMarkdown(base: BrowserMetricsReport, head: BrowserMetricsReport, options: RenderMarkdownOptions) {
const detailedHtmlUrl = options.detailedHtmlUrl;
const heapSnapshotTable = renderHeapSnapshotTable(toHeapSnapshotReport(base), toHeapSnapshotReport(head));
export function renderFrontendDiagnosticsMarkdown(input: FrontendDiagnosticsMarkdownInput) {
const { bundle, browser } = input;
const detailedHtmlUrl = browser.detailedHtmlUrl;
const heapSnapshotTable = renderHeapSnapshotTable(toHeapSnapshotReport(browser.base), toHeapSnapshotReport(browser.head));
const lines = [
'## 🖥 Frontend Browser Diagnostics Report',
'## 🖥 Frontend Diagnostics Report',
'',
renderSummaryTable(base, head),
renderBrowserSummaryTable(browser.base, browser.head),
'',
'<i>Only metrics showing significant changes are displayed.</i>',
'',
@@ -169,7 +185,7 @@ export function renderMarkdown(base: BrowserMetricsReport, head: BrowserMetricsR
'<details>',
'<summary>Requests by resource type</summary>',
'',
renderResourceTypeTable(base, head),
renderResourceTypeTable(browser.base, browser.head),
'',
'</details>',
'',
@@ -178,11 +194,19 @@ export function renderMarkdown(base: BrowserMetricsReport, head: BrowserMetricsR
'',
heapSnapshotTable ?? '_No V8 heap snapshot data._',
'',
//renderHeapSnapshotSankey(toHeapSnapshotReport(head), 'Head'),
//renderHeapSnapshotSankey(toHeapSnapshotReport(browser.head), 'Head'),
//'',
`Download representative heap snapshot: [base](${options.baseHeapSnapshotUrl}) / [head](${options.headHeapSnapshotUrl})`,
`Download representative heap snapshot: [base](${browser.baseHeapSnapshotUrl}) / [head](${browser.headHeapSnapshotUrl})`,
'</details>',
'',
'### 📦 Bundle Stats',
'',
renderFrontendChunkReport(bundle.base, bundle.head),
'',
renderVisualizerSummaryTable(collectVisualizerReport(bundle.baseStats), collectVisualizerReport(bundle.headStats)),
'',
`[Open treemap HTML](${bundle.visualizerArtifactUrl})`,
'',
];
return lines.filter(line => line != null).join('\n').trimEnd() + '\n';

View File

@@ -1,4 +1,4 @@
## 🖥 Frontend Browser Diagnostics Report
## 🖥 Frontend Diagnostics Report
| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
@@ -81,3 +81,102 @@
Download representative heap snapshot: [base](https://example.invalid/base) / [head](https://example.invalid/head)
</details>
### 📦 Bundle Stats
<details>
<summary>Chunk size diff (2 updated, 0 added, 0 removed)</summary>
| Chunk | Base | Head | Δ | Δ (%) |
| --- | ---: | ---: | ---: | ---: |
| (total) | 120 KB | 127 KB | $\color{orange}{\text{+6.3 KB}}$ | $\color{orange}{\text{+5.2\\%}}$ |
| | | | | |
| <details><summary>`vue`</summary> `assets/vue-b2.js` </details> | 90 KB | 96 KB | $\color{orange}{\text{+6 KB}}$ | $\color{orange}{\text{+6.7\\%}}$ |
| (other generated chunks) | 1.2 KB | 1.5 KB | $\text{+300 B}$ | $\color{orange}{\text{+25\\%}}$ |
| (other) | 20 KB | 20 KB | $\text{+3 B}$ | $\text{+0\\%}$ |
</details>
<details>
<summary>Startup chunk size (2 updated, 0 added, 0 removed)</summary>
| Chunk | Base | Head | Δ | Δ (%) |
| --- | ---: | ---: | ---: | ---: |
| (total) | 114 KB | 120 KB | $\color{orange}{\text{+6 KB}}$ | $\color{orange}{\text{+5.3\\%}}$ |
| | | | | |
| <details><summary>`vue`</summary> `assets/vue-b2.js` </details> | 90 KB | 96 KB | $\color{orange}{\text{+6 KB}}$ | $\color{orange}{\text{+6.7\\%}}$ |
| (other) | 24 KB | 24 KB | $\text{+3 B}$ | $\text{+0\\%}$ |
_Startup chunks are the Vite entry for `src/_boot_.ts` and its static imports._
</details>
<table>
<thead>
<tr>
<th rowspan="2"></th>
<th rowspan="2">Bundles</th>
<th rowspan="2">Modules</th>
<th rowspan="2">Entries</th>
<th colspan="2">Imports</th>
<th colspan="3">Size</th>
</tr>
<tr>
<th>Static</th>
<th>Dynamic</th>
<th>Rendered</th>
<th>Gzip</th>
<th>Brotli</th>
</tr>
</thead>
<tbody>
<tr>
<th><b>Base</b></th>
<td>2</td>
<td>6</td>
<td>1</td>
<td>2</td>
<td>3</td>
<td>21 KB</td>
<td>6.3 KB</td>
<td>5.3 KB</td>
</tr>
<tr>
<th><b>Head</b></th>
<td>2</td>
<td>7</td>
<td>1</td>
<td>3</td>
<td>3</td>
<td>31 KB</td>
<td>8.4 KB</td>
<td>7 KB</td>
</tr>
<tr><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td></tr>
<tr>
<th><b>Δ</b></th>
<td>0</td>
<td>$\color{orange}{\text{+1}}$</td>
<td>0</td>
<td>$\color{orange}{\text{+1}}$</td>
<td>0</td>
<td>$\color{orange}{\text{+9.8 KB}}$</td>
<td>$\color{orange}{\text{+2.1 KB}}$</td>
<td>$\color{orange}{\text{+1.8 KB}}$</td>
</tr>
<tr>
<th><b>Δ (%)</b></th>
<td>0%</td>
<td>$\color{orange}{\text{+16.7\%}}$</td>
<td>0%</td>
<td>$\color{orange}{\text{+50\%}}$</td>
<td>0%</td>
<td>$\color{orange}{\text{+46.7\%}}$</td>
<td>$\color{orange}{\text{+33.3\%}}$</td>
<td>$\color{orange}{\text{+33.3\%}}$</td>
</tr>
</tbody>
</table>
[Open treemap HTML](https://example.invalid/treemap)

View File

@@ -0,0 +1,44 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import { renderBrowserDiagnosticsHtml } from '../../src/browser/report/html';
import type { BrowserMetricsReport } from '../../src/browser/types';
const fixturesDir = join(import.meta.dirname, 'fixtures');
async function loadFixture(name: string) {
return JSON.parse(await readFile(join(fixturesDir, `${name}.json`), 'utf8')) as BrowserMetricsReport;
}
beforeEach(() => {
// renderBrowserDiagnosticsHtml() が生成時刻を埋め込むので、スナップショットが揺れないよう時刻を固定する
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-07-18T00:00:00.000Z'));
});
afterEach(() => {
vi.useRealTimers();
});
test('renders the network request diff html report', async () => {
const html = renderBrowserDiagnosticsHtml(await loadFixture('base'), await loadFixture('head'));
await expect(html).toMatchFileSnapshot('./__snapshots__/render-html.html');
});
test('escapes html metacharacters coming from the browser session', async () => {
const base = await loadFixture('base');
const head = await loadFixture('head');
// CDP由来の値は原理的には任意の文字列になりうるので、生HTMLとして出ないことを確かめる
head.samples[0].networkRequests[0].url = 'http://127.0.0.1:61812/"><script>alert(1)</script>';
const html = renderBrowserDiagnosticsHtml(base, head);
expect(html).not.toContain('<script>alert(1)</script>');
expect(html).toContain('&lt;script&gt;alert(1)&lt;/script&gt;');
});

View File

@@ -0,0 +1,47 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { EventEmitter } from 'node:events';
import { afterEach, expect, test, vi } from 'vitest';
import { stopServer, waitForServer } from '../../src/browser/server';
import type { ChildProcess } from 'node:child_process';
vi.mock('node:child_process', async () => {
const actual = await vi.importActual<typeof import('node:child_process')>('node:child_process');
return {
...actual,
spawnSync: vi.fn(),
};
});
function signaledChild() {
return Object.assign(new EventEmitter(), {
exitCode: null,
signalCode: 'SIGTERM' as NodeJS.Signals,
pid: undefined,
kill: vi.fn(),
}) as unknown as ChildProcess;
}
afterEach(() => {
vi.unstubAllGlobals();
});
test('waitForServer fails immediately when the server exited by signal', async () => {
const fetchMock = vi.fn().mockResolvedValue({ status: 200 });
vi.stubGlobal('fetch', fetchMock);
await expect(waitForServer('http://127.0.0.1:61812', signaledChild()))
.rejects.toThrow('Misskey server exited early with signal SIGTERM');
expect(fetchMock).not.toHaveBeenCalled();
});
test('stopServer returns immediately when the server already exited by signal', async () => {
const child = signaledChild();
const stopPromise = stopServer(child);
expect(child.listenerCount('exit')).toBe(0);
await stopPromise;
});

View File

@@ -1 +1 @@
{"nodeParts": {"p0": {"renderedLength": 1000, "gzipLength": 300, "brotliLength": 250}, "p1": {"renderedLength": 2000, "gzipLength": 600, "brotliLength": 500}, "p2": {"renderedLength": 3000, "gzipLength": 900, "brotliLength": 750}, "p3": {"renderedLength": 4000, "gzipLength": 1200, "brotliLength": 1000}, "p4": {"renderedLength": 5000, "gzipLength": 1500, "brotliLength": 1250}, "p5": {"renderedLength": 6000, "gzipLength": 1800, "brotliLength": 1500}}, "nodeMetas": {"m0": {"id": "/src/mod0.ts", "isEntry": true, "importedBy": [], "imported": [{"id": "m1", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p0"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m1": {"id": "/src/mod1.ts", "isEntry": false, "importedBy": ["m0"], "imported": [{"id": "m2", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p1"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m2": {"id": "/src/mod2.ts", "isEntry": false, "importedBy": ["m1"], "imported": [{"id": "m3", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p2"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m3": {"id": "/src/mod3.ts", "isEntry": false, "importedBy": ["m2"], "imported": [{"id": "m4", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p3"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m4": {"id": "/src/mod4.ts", "isEntry": false, "importedBy": ["m3"], "imported": [{"id": "m5", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p4"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m5": {"id": "/src/mod5.ts", "isEntry": false, "importedBy": ["m4"], "imported": [], "moduleParts": {"assets/vue-b2.js": "p5"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}}, "options": {}}
{"nodeParts": {"p0": {"renderedLength": 1000, "gzipLength": 300, "brotliLength": 250}, "p1": {"renderedLength": 2000, "gzipLength": 600, "brotliLength": 500}, "p2": {"renderedLength": 3000, "gzipLength": 900, "brotliLength": 750}, "p3": {"renderedLength": 4000, "gzipLength": 1200, "brotliLength": 1000}, "p4": {"renderedLength": 5000, "gzipLength": 1500, "brotliLength": 1250}, "p5": {"renderedLength": 6000, "gzipLength": 1800, "brotliLength": 1500}}, "nodeMetas": {"m0": {"id": "/src/mod0.ts", "isEntry": true, "importedBy": [], "imported": [{"id": "m1", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p0"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m1": {"id": "/src/mod1.ts", "isEntry": false, "importedBy": ["m0"], "imported": [{"id": "m2", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p1"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m2": {"id": "/src/mod2.ts", "isEntry": false, "importedBy": ["m1"], "imported": [{"id": "m3", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p2"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m3": {"id": "/src/mod3.ts", "isEntry": false, "importedBy": ["m2"], "imported": [{"id": "m4", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p3"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m4": {"id": "/src/mod4.ts", "isEntry": false, "importedBy": ["m3"], "imported": [{"id": "m5", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p4"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m5": {"id": "/src/mod5.ts", "isEntry": false, "importedBy": ["m4"], "imported": [], "moduleParts": {"assets/vue-b2.js": "p5"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}}, "options": {}}

View File

@@ -1 +1 @@
{"nodeParts": {"p0": {"renderedLength": 1100.0, "gzipLength": 300, "brotliLength": 250}, "p1": {"renderedLength": 2200.0, "gzipLength": 600, "brotliLength": 500}, "p2": {"renderedLength": 3300.0000000000005, "gzipLength": 900, "brotliLength": 750}, "p3": {"renderedLength": 4400.0, "gzipLength": 1200, "brotliLength": 1000}, "p4": {"renderedLength": 5500.0, "gzipLength": 1500, "brotliLength": 1250}, "p5": {"renderedLength": 6600.000000000001, "gzipLength": 1800, "brotliLength": 1500}, "p6": {"renderedLength": 7700.000000000001, "gzipLength": 2100, "brotliLength": 1750}}, "nodeMetas": {"m0": {"id": "/src/mod0.ts", "isEntry": true, "importedBy": [], "imported": [{"id": "m1", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p0"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m1": {"id": "/src/mod1.ts", "isEntry": false, "importedBy": ["m0"], "imported": [{"id": "m2", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p1"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m2": {"id": "/src/mod2.ts", "isEntry": false, "importedBy": ["m1"], "imported": [{"id": "m3", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p2"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m3": {"id": "/src/mod3.ts", "isEntry": false, "importedBy": ["m2"], "imported": [{"id": "m4", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p3"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m4": {"id": "/src/mod4.ts", "isEntry": false, "importedBy": ["m3"], "imported": [{"id": "m5", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p4"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m5": {"id": "/src/mod5.ts", "isEntry": false, "importedBy": ["m4"], "imported": [{"id": "m6", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p5"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m6": {"id": "/src/mod6.ts", "isEntry": false, "importedBy": ["m5"], "imported": [], "moduleParts": {"assets/boot-a1.js": "p6"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}}, "options": {}}
{"nodeParts": {"p0": {"renderedLength": 1100.0, "gzipLength": 300, "brotliLength": 250}, "p1": {"renderedLength": 2200.0, "gzipLength": 600, "brotliLength": 500}, "p2": {"renderedLength": 3300.0000000000005, "gzipLength": 900, "brotliLength": 750}, "p3": {"renderedLength": 4400.0, "gzipLength": 1200, "brotliLength": 1000}, "p4": {"renderedLength": 5500.0, "gzipLength": 1500, "brotliLength": 1250}, "p5": {"renderedLength": 6600.000000000001, "gzipLength": 1800, "brotliLength": 1500}, "p6": {"renderedLength": 7700.000000000001, "gzipLength": 2100, "brotliLength": 1750}}, "nodeMetas": {"m0": {"id": "/src/mod0.ts", "isEntry": true, "importedBy": [], "imported": [{"id": "m1", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p0"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m1": {"id": "/src/mod1.ts", "isEntry": false, "importedBy": ["m0"], "imported": [{"id": "m2", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p1"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m2": {"id": "/src/mod2.ts", "isEntry": false, "importedBy": ["m1"], "imported": [{"id": "m3", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p2"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m3": {"id": "/src/mod3.ts", "isEntry": false, "importedBy": ["m2"], "imported": [{"id": "m4", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p3"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m4": {"id": "/src/mod4.ts", "isEntry": false, "importedBy": ["m3"], "imported": [{"id": "m5", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p4"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m5": {"id": "/src/mod5.ts", "isEntry": false, "importedBy": ["m4"], "imported": [{"id": "m6", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p5"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m6": {"id": "/src/mod6.ts", "isEntry": false, "importedBy": ["m5"], "imported": [], "moduleParts": {"assets/boot-a1.js": "p6"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}}, "options": {}}

View File

@@ -0,0 +1,29 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { promises as fs } from 'node:fs';
import { afterEach, expect, test, vi } from 'vitest';
import { fileExists } from '../../src/bundle/fs-utils';
function fsError(code: string) {
return Object.assign(new Error(`fs error: ${code}`), { code }) as NodeJS.ErrnoException;
}
afterEach(() => {
vi.restoreAllMocks();
});
test('fileExists returns false for ENOENT', async () => {
vi.spyOn(fs, 'access').mockRejectedValueOnce(fsError('ENOENT'));
await expect(fileExists('missing')).resolves.toBe(false);
});
test('fileExists rethrows errors other than ENOENT', async () => {
const error = fsError('EACCES');
vi.spyOn(fs, 'access').mockRejectedValueOnce(error);
await expect(fileExists('restricted')).rejects.toBe(error);
});

View File

@@ -0,0 +1,24 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, beforeAll, expect, test } from 'vitest';
import { collectBundleReport } from '../../src/bundle/manifest';
let workDir: string;
beforeAll(async () => {
workDir = await mkdtemp(join(tmpdir(), 'diagnostics-frontend-test-'));
});
afterAll(async () => {
await rm(workDir, { recursive: true, force: true });
});
test('fails loudly when the built output is missing', async () => {
await expect(collectBundleReport(join(workDir, 'nonexistent'))).rejects.toThrow();
});

View File

@@ -7,16 +7,18 @@ import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { afterAll, beforeAll, expect, test } from 'vitest';
import { collectReport } from '../src/manifest';
import { renderBundleReportMarkdown } from '../src/report';
import type { VisualizerReport } from '../src/visualizer';
import { collectBundleReport } from '../src/bundle/manifest';
import { renderFrontendDiagnosticsMarkdown } from '../src/report';
import type { BrowserMetricsReport } from '../src/browser/types';
import type { VisualizerReport } from '../src/bundle/visualizer';
const fixturesDir = join(import.meta.dirname, 'fixtures');
const bundleFixturesDir = join(import.meta.dirname, 'bundle/fixtures');
const browserFixturesDir = join(import.meta.dirname, 'browser/fixtures');
/**
* fixture
*
* `collectReport` 使
* `collectBundleReport` 使
* `built`
* ( .gitignore )
*/
@@ -31,7 +33,7 @@ const manifest = {
};
const fileSizes = {
before: {
base: {
'assets/boot-a1.js': 20_000,
'assets/vue-b2.js': 90_000,
'assets/foo-d4.js': 5_000,
@@ -39,7 +41,7 @@ const fileSizes = {
'ja-JP/i18n-c3.js': 4_000,
'ja-JP/orphan.js': 1_200,
},
after: {
head: {
// 差が小さすぎる (閾値5バイト以下) ので「(other)」に集約される
'assets/boot-a1.js': 20_003,
// 明確に増えるので diff表に行として出る
@@ -50,15 +52,15 @@ const fileSizes = {
// manifestに載らない出力なので「(other generated chunks)」に集約される
'ja-JP/orphan.js': 1_500,
},
} as const satisfies Record<'before' | 'after', Record<string, number>>;
} as const satisfies Record<'base' | 'head', Record<string, number>>;
let repoDirs: { before: string; after: string };
let repoDirs: { base: string; head: string };
let workDir: string;
beforeAll(async () => {
workDir = await mkdtemp(join(tmpdir(), 'diagnostics-frontend-bundle-'));
workDir = await mkdtemp(join(tmpdir(), 'diagnostics-frontend-report-test-'));
for (const label of ['before', 'after'] as const) {
for (const label of ['base', 'head'] as const) {
const outDir = join(workDir, label, 'built/_frontend_vite_');
await mkdir(outDir, { recursive: true });
await writeFile(join(outDir, 'manifest.json'), JSON.stringify(manifest));
@@ -71,8 +73,8 @@ beforeAll(async () => {
}
repoDirs = {
before: join(workDir, 'before'),
after: join(workDir, 'after'),
base: join(workDir, 'base'),
head: join(workDir, 'head'),
};
});
@@ -80,26 +82,45 @@ afterAll(async () => {
await rm(workDir, { recursive: true, force: true });
});
async function loadStats(name: string) {
return JSON.parse(await readFile(join(fixturesDir, `${name}-stats.json`), 'utf8')) as VisualizerReport;
async function loadBundleStats(name: 'base' | 'head') {
return JSON.parse(await readFile(join(bundleFixturesDir, `${name}-stats.json`), 'utf8')) as VisualizerReport;
}
async function loadBrowserReport(name: 'base' | 'head') {
return JSON.parse(await readFile(join(browserFixturesDir, `${name}.json`), 'utf8')) as BrowserMetricsReport;
}
async function renderReport(detailedHtmlUrl: string | null) {
return renderFrontendDiagnosticsMarkdown({
bundle: {
base: await collectBundleReport(repoDirs.base),
head: await collectBundleReport(repoDirs.head),
baseStats: await loadBundleStats('base'),
headStats: await loadBundleStats('head'),
visualizerArtifactUrl: 'https://example.invalid/treemap',
},
browser: {
base: await loadBrowserReport('base'),
head: await loadBrowserReport('head'),
baseHeapSnapshotUrl: 'https://example.invalid/base',
headHeapSnapshotUrl: 'https://example.invalid/head',
detailedHtmlUrl,
},
});
}
/**
*
* `vitest -u` __snapshots__
*/
test('renders the frontend bundle report', async () => {
const markdown = renderBundleReportMarkdown(
await collectReport(repoDirs.before),
await collectReport(repoDirs.after),
await loadStats('before'),
await loadStats('after'),
{ visualizerArtifactUrl: 'https://example.invalid/treemap' },
);
test('renders one frontend diagnostics markdown report from bundle and browser data', async () => {
const markdown = await renderReport('https://example.invalid/html');
await expect(markdown).toMatchFileSnapshot('./__snapshots__/render-md.md');
await expect(markdown).toMatchFileSnapshot('./__snapshots__/report.md');
});
test('fails loudly when the built output is missing', async () => {
await expect(collectReport(join(workDir, 'nonexistent'))).rejects.toThrow();
test('omits the browser details link when no detailed html artifact was uploaded', async () => {
const markdown = await renderReport(null);
expect(markdown).not.toContain('View details');
});

24
pnpm-lock.yaml generated
View File

@@ -131,7 +131,7 @@ importers:
specifier: 4.1.10
version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1))
packages-private/diagnostics-frontend-browser:
packages-private/diagnostics-frontend:
dependencies:
diagnostics-shared:
specifier: workspace:*
@@ -156,28 +156,6 @@ importers:
specifier: 4.1.10
version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1))
packages-private/diagnostics-frontend-bundle:
dependencies:
diagnostics-shared:
specifier: workspace:*
version: link:../diagnostics-shared
devDependencies:
'@types/node':
specifier: 26.1.1
version: 26.1.1
tsx:
specifier: 4.23.1
version: 4.23.1
typescript:
specifier: 5.9.3
version: 5.9.3
vite:
specifier: 8.1.4
version: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)
vitest:
specifier: 4.1.10
version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1))
packages-private/diagnostics-shared:
devDependencies:
'@types/node':