From b51d5ba0a085f1b72aa206ef64d1acc890185ec5 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:26:53 +0900 Subject: [PATCH] =?UTF-8?q?frontend=20bundle=20diagnostics=20=E3=81=A8=20f?= =?UTF-8?q?rontend=20browser=20diagnostics=20=E3=82=92=E7=B5=B1=E5=90=88?= =?UTF-8?q?=20(#17757)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(gh): unify frontend diagnostics * refactor(gh): unify frontend diagnostics markdown rendering * docs(gh): restore frontend diagnostics comments * wip * fix * refactor: 呼称をbase/headに統一 * tweak * Update types.ts * fix --- .../frontend-browser-diagnostics.comment.yml | 44 ------ .../frontend-bundle-diagnostics.comment.yml | 44 ------ .../frontend-bundle-diagnostics.inspect.yml | 148 ------------------ .../frontend-diagnostics.comment.yml | 75 +++++++++ ...t.yml => frontend-diagnostics.inspect.yml} | 145 ++++++++++------- .github/workflows/packages-private.yml | 3 +- packages-private/README.md | 12 +- .../src/render-md.ts | 31 ---- .../test/render.test.ts | 69 -------- .../eslint.config.js | 25 --- .../diagnostics-frontend-bundle/package.json | 22 --- .../src/render-md.ts | 32 ---- .../diagnostics-frontend-bundle/src/report.ts | 33 ---- .../test/__snapshots__/render-md.md | 100 ------------ .../diagnostics-frontend-bundle/tsconfig.json | 20 --- .../eslint.config.js | 0 .../package.json | 6 +- .../src/browser/controller.ts | 4 +- .../src/browser/diagnostics.ts | 2 +- .../src/browser/network.ts | 2 +- .../src/browser}/report/html-styles.ts | 0 .../src/browser}/report/html.ts | 2 +- .../src/browser}/scenario.ts | 4 +- .../src/browser}/server.ts | 11 +- .../src/browser}/summarize.ts | 2 +- .../src/browser}/types.ts | 5 + .../src/bundle}/chunk-report.ts | 128 +++++++-------- .../src/bundle}/fs-utils.ts | 5 +- .../src/bundle}/manifest.ts | 27 ++-- .../src/bundle}/visualizer.ts | 22 +-- .../src/inspect-browser.ts} | 10 +- .../src/render-browser-html.ts} | 8 +- .../diagnostics-frontend/src/render-md.ts | 62 ++++++++ .../src/report.ts} | 54 +++++-- .../test/__snapshots__/report.md} | 101 +++++++++++- .../browser}/__snapshots__/render-html.html | 0 .../test/browser}/fixtures/base.json | 0 .../test/browser}/fixtures/head.json | 0 .../test/browser/render.test.ts | 44 ++++++ .../test/browser/server.test.ts | 47 ++++++ .../test/bundle/fixtures/base-stats.json} | 2 +- .../test/bundle/fixtures/head-stats.json} | 2 +- .../test/bundle/fs-utils.test.ts | 29 ++++ .../test/bundle/manifest.test.ts | 24 +++ .../test/report.test.ts} | 73 ++++++--- .../tsconfig.json | 0 pnpm-lock.yaml | 24 +-- 47 files changed, 693 insertions(+), 810 deletions(-) delete mode 100644 .github/workflows/frontend-browser-diagnostics.comment.yml delete mode 100644 .github/workflows/frontend-bundle-diagnostics.comment.yml delete mode 100644 .github/workflows/frontend-bundle-diagnostics.inspect.yml create mode 100644 .github/workflows/frontend-diagnostics.comment.yml rename .github/workflows/{frontend-browser-diagnostics.inspect.yml => frontend-diagnostics.inspect.yml} (51%) delete mode 100644 packages-private/diagnostics-frontend-browser/src/render-md.ts delete mode 100644 packages-private/diagnostics-frontend-browser/test/render.test.ts delete mode 100644 packages-private/diagnostics-frontend-bundle/eslint.config.js delete mode 100644 packages-private/diagnostics-frontend-bundle/package.json delete mode 100644 packages-private/diagnostics-frontend-bundle/src/render-md.ts delete mode 100644 packages-private/diagnostics-frontend-bundle/src/report.ts delete mode 100644 packages-private/diagnostics-frontend-bundle/test/__snapshots__/render-md.md delete mode 100644 packages-private/diagnostics-frontend-bundle/tsconfig.json rename packages-private/{diagnostics-frontend-browser => diagnostics-frontend}/eslint.config.js (100%) rename packages-private/{diagnostics-frontend-browser => diagnostics-frontend}/package.json (76%) rename packages-private/{diagnostics-frontend-browser => diagnostics-frontend}/src/browser/controller.ts (99%) rename packages-private/{diagnostics-frontend-browser => diagnostics-frontend}/src/browser/diagnostics.ts (92%) rename packages-private/{diagnostics-frontend-browser => diagnostics-frontend}/src/browser/network.ts (99%) rename packages-private/{diagnostics-frontend-browser/src => diagnostics-frontend/src/browser}/report/html-styles.ts (100%) rename packages-private/{diagnostics-frontend-browser/src => diagnostics-frontend/src/browser}/report/html.ts (98%) rename packages-private/{diagnostics-frontend-browser/src => diagnostics-frontend/src/browser}/scenario.ts (87%) rename packages-private/{diagnostics-frontend-browser/src => diagnostics-frontend/src/browser}/server.ts (90%) rename packages-private/{diagnostics-frontend-browser/src => diagnostics-frontend/src/browser}/summarize.ts (98%) rename packages-private/{diagnostics-frontend-browser/src => diagnostics-frontend/src/browser}/types.ts (87%) rename packages-private/{diagnostics-frontend-bundle/src => diagnostics-frontend/src/bundle}/chunk-report.ts (50%) rename packages-private/{diagnostics-frontend-bundle/src => diagnostics-frontend/src/bundle}/fs-utils.ts (90%) rename packages-private/{diagnostics-frontend-bundle/src => diagnostics-frontend/src/bundle}/manifest.ts (90%) rename packages-private/{diagnostics-frontend-bundle/src => diagnostics-frontend/src/bundle}/visualizer.ts (83%) rename packages-private/{diagnostics-frontend-browser/src/inspect.ts => diagnostics-frontend/src/inspect-browser.ts} (95%) rename packages-private/{diagnostics-frontend-browser/src/render-html.ts => diagnostics-frontend/src/render-browser-html.ts} (68%) create mode 100644 packages-private/diagnostics-frontend/src/render-md.ts rename packages-private/{diagnostics-frontend-browser/src/report/markdown.ts => diagnostics-frontend/src/report.ts} (86%) rename packages-private/{diagnostics-frontend-browser/test/__snapshots__/render-md.md => diagnostics-frontend/test/__snapshots__/report.md} (62%) rename packages-private/{diagnostics-frontend-browser/test => diagnostics-frontend/test/browser}/__snapshots__/render-html.html (100%) rename packages-private/{diagnostics-frontend-browser/test => diagnostics-frontend/test/browser}/fixtures/base.json (100%) rename packages-private/{diagnostics-frontend-browser/test => diagnostics-frontend/test/browser}/fixtures/head.json (100%) create mode 100644 packages-private/diagnostics-frontend/test/browser/render.test.ts create mode 100644 packages-private/diagnostics-frontend/test/browser/server.test.ts rename packages-private/{diagnostics-frontend-bundle/test/fixtures/before-stats.json => diagnostics-frontend/test/bundle/fixtures/base-stats.json} (99%) rename packages-private/{diagnostics-frontend-bundle/test/fixtures/after-stats.json => diagnostics-frontend/test/bundle/fixtures/head-stats.json} (98%) create mode 100644 packages-private/diagnostics-frontend/test/bundle/fs-utils.test.ts create mode 100644 packages-private/diagnostics-frontend/test/bundle/manifest.test.ts rename packages-private/{diagnostics-frontend-bundle/test/render-md.test.ts => diagnostics-frontend/test/report.test.ts} (53%) rename packages-private/{diagnostics-frontend-browser => diagnostics-frontend}/tsconfig.json (100%) diff --git a/.github/workflows/frontend-browser-diagnostics.comment.yml b/.github/workflows/frontend-browser-diagnostics.comment.yml deleted file mode 100644 index 5098e618ad..0000000000 --- a/.github/workflows/frontend-browser-diagnostics.comment.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Frontend browser diagnostics (comment) - -on: - workflow_run: - workflows: - - Frontend browser diagnostics (inspect) - types: - - completed - -permissions: - actions: read - contents: read - issues: write - pull-requests: write - -jobs: - comment: - name: Comment frontend browser diagnostics report - if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' - runs-on: ubuntu-latest - concurrency: - group: frontend-browser-diagnostics-report-${{ github.event.workflow_run.id }} - cancel-in-progress: true - steps: - - name: Download browser metrics report - uses: actions/download-artifact@v8 - with: - name: frontend-browser-diagnostics - path: ${{ runner.temp }}/frontend-browser-diagnostics - github-token: ${{ github.token }} - repository: ${{ github.repository }} - run-id: ${{ github.event.workflow_run.id }} - - - name: Load PR number - id: load-pr-number - shell: bash - run: echo "pr-number=$(cat "$RUNNER_TEMP/frontend-browser-diagnostics/pr-number.txt")" >> "$GITHUB_OUTPUT" - - - name: Comment on pull request - uses: thollander/actions-comment-pull-request@v3 - with: - pr-number: ${{ steps.load-pr-number.outputs.pr-number }} - comment-tag: frontend-browser-diagnostics - file-path: ${{ runner.temp }}/frontend-browser-diagnostics/frontend-browser-diagnostics.md diff --git a/.github/workflows/frontend-bundle-diagnostics.comment.yml b/.github/workflows/frontend-bundle-diagnostics.comment.yml deleted file mode 100644 index 709c735e69..0000000000 --- a/.github/workflows/frontend-bundle-diagnostics.comment.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Frontend bundle diagnostics (comment) - -on: - workflow_run: - workflows: - - Frontend bundle diagnostics (inspect) - types: - - completed - -permissions: - actions: read - contents: read - issues: write - pull-requests: write - -jobs: - comment: - name: Comment frontend bundle report - if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' - runs-on: ubuntu-latest - concurrency: - group: frontend-bundle-diagnostics-report-${{ github.event.workflow_run.id }} - cancel-in-progress: true - steps: - - name: Download bundle report - uses: actions/download-artifact@v8 - with: - name: frontend-bundle-report - path: ${{ runner.temp }}/frontend-bundle-report - github-token: ${{ github.token }} - repository: ${{ github.repository }} - run-id: ${{ github.event.workflow_run.id }} - - - name: Load PR number - id: load-pr-number - shell: bash - run: echo "pr-number=$(cat "$RUNNER_TEMP/frontend-bundle-report/pr-number.txt")" >> "$GITHUB_OUTPUT" - - - name: Comment on pull request - uses: thollander/actions-comment-pull-request@v3 - with: - pr-number: ${{ steps.load-pr-number.outputs.pr-number }} - comment-tag: frontend-bundle-diagnostics - file-path: ${{ runner.temp }}/frontend-bundle-report/frontend-bundle-diagnostics-report.md diff --git a/.github/workflows/frontend-bundle-diagnostics.inspect.yml b/.github/workflows/frontend-bundle-diagnostics.inspect.yml deleted file mode 100644 index 0332ea6a3e..0000000000 --- a/.github/workflows/frontend-bundle-diagnostics.inspect.yml +++ /dev/null @@ -1,148 +0,0 @@ -name: Frontend bundle diagnostics (inspect) - -on: - pull_request: - types: - - opened - - synchronize - - reopened - - ready_for_review - paths: - - packages/frontend/** - - packages/frontend-shared/** - - packages/frontend-builder/** - - packages/i18n/** - - packages/icons-subsetter/** - - packages/misskey-js/** - - packages/misskey-reversi/** - - packages/misskey-bubble-game/** - - package.json - - pnpm-lock.yaml - - pnpm-workspace.yaml - - .node-version - - packages-private/diagnostics-shared/** - - packages-private/diagnostics-frontend-bundle/** - - .github/workflows/frontend-bundle-diagnostics.inspect.yml - - .github/workflows/frontend-bundle-diagnostics.comment.yml - -permissions: - contents: read - pull-requests: read - -concurrency: - group: frontend-bundle-diagnostics-inspect-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - report: - name: Build frontend bundle report - runs-on: ubuntu-latest - steps: - - name: Checkout base - uses: actions/checkout@v6.0.3 - with: - repository: ${{ github.event.pull_request.base.repo.full_name }} - ref: ${{ github.event.pull_request.base.sha }} - path: before - submodules: true - - - name: Checkout pull request - uses: actions/checkout@v6.0.3 - with: - repository: ${{ github.event.pull_request.head.repo.full_name }} - ref: ${{ github.event.pull_request.head.sha }} - path: after - submodules: true - - - name: Setup pnpm - uses: pnpm/action-setup@v6.0.9 - with: - package_json_file: after/package.json - - - name: Setup Node.js - uses: actions/setup-node@v6.4.0 - with: - node-version-file: after/.node-version - cache: pnpm - cache-dependency-path: | - before/pnpm-lock.yaml - after/pnpm-lock.yaml - - - name: Install dependencies for base - working-directory: before - run: pnpm i --frozen-lockfile - - - name: Build frontend dependencies for base - working-directory: before - run: pnpm --filter "frontend^..." run build - - - name: Prepare report output - run: mkdir -p "$RUNNER_TEMP/frontend-bundle-report" - - - name: Build frontend report for base - working-directory: before - env: - FRONTEND_BUNDLE_VISUALIZER: 'true' - FRONTEND_BUNDLE_VISUALIZER_FILE: ${{ runner.temp }}/frontend-bundle-report/before-stats.json - run: pnpm --filter frontend run build - - - name: Install dependencies for pull request - working-directory: after - run: pnpm i --frozen-lockfile - - - name: Build frontend dependencies for pull request - working-directory: after - run: pnpm --filter "frontend^..." run build - - - name: Build frontend report for pull request - working-directory: after - env: - FRONTEND_BUNDLE_VISUALIZER: 'true' - FRONTEND_BUNDLE_VISUALIZER_FILE: ${{ runner.temp }}/frontend-bundle-report/after-stats.json - FRONTEND_BUNDLE_VISUALIZER_HTML_FILE: ${{ runner.temp }}/frontend-bundle-report/frontend-bundle-visualizer.html - run: pnpm --filter frontend run build - - - name: Upload bundle visualizer - id: upload-bundle-visualizer - uses: actions/upload-artifact@v7 - with: - name: frontend-bundle-visualizer - path: ${{ runner.temp }}/frontend-bundle-report/frontend-bundle-visualizer.html - if-no-files-found: error - archive: false - retention-days: 7 - - - name: Generate report markdown - shell: bash - working-directory: after - env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - PR_NUMBER: ${{ github.event.pull_request.number }} - FRONTEND_BUNDLE_REPORT_ARTIFACT_URL: ${{ steps.upload-bundle-visualizer.outputs.artifact-url }} - run: | - REPORT_DIR="$RUNNER_TEMP/frontend-bundle-report" - pnpm --filter diagnostics-frontend-bundle run render-md \ - "$GITHUB_WORKSPACE/before" "$GITHUB_WORKSPACE/after" \ - "$REPORT_DIR/before-stats.json" "$REPORT_DIR/after-stats.json" \ - "$REPORT_DIR/frontend-bundle-diagnostics-report.md" - printf '%s\n' "$PR_NUMBER" > "$REPORT_DIR/pr-number.txt" - printf '%s\n' "$BASE_SHA" > "$REPORT_DIR/base-sha.txt" - printf '%s\n' "$HEAD_SHA" > "$REPORT_DIR/head-sha.txt" - printf '%s\n' "${{ github.event.pull_request.html_url }}" > "$REPORT_DIR/pr-url.txt" - - - name: Check report - run: | - REPORT_DIR="$RUNNER_TEMP/frontend-bundle-report" - test -s "$REPORT_DIR/before-stats.json" - test -s "$REPORT_DIR/after-stats.json" - test -s "$REPORT_DIR/frontend-bundle-diagnostics-report.md" - cat "$REPORT_DIR/frontend-bundle-diagnostics-report.md" >> "$GITHUB_STEP_SUMMARY" - - - name: Upload bundle report - uses: actions/upload-artifact@v7 - with: - name: frontend-bundle-report - path: ${{ runner.temp }}/frontend-bundle-report/ - if-no-files-found: error - retention-days: 7 diff --git a/.github/workflows/frontend-diagnostics.comment.yml b/.github/workflows/frontend-diagnostics.comment.yml new file mode 100644 index 0000000000..96deddcc58 --- /dev/null +++ b/.github/workflows/frontend-diagnostics.comment.yml @@ -0,0 +1,75 @@ +name: Frontend diagnostics (comment) + +on: + workflow_run: + workflows: + - Frontend diagnostics (inspect) + types: + - completed + +permissions: + actions: read + contents: read + issues: write + pull-requests: write + +jobs: + comment: + name: Comment frontend diagnostics report + if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + concurrency: + group: frontend-diagnostics-report-${{ github.event.workflow_run.id }} + cancel-in-progress: true + steps: + - name: Download frontend diagnostics report + uses: actions/download-artifact@v8 + with: + name: frontend-diagnostics + path: ${{ runner.temp }}/frontend-diagnostics + github-token: ${{ github.token }} + repository: ${{ github.repository }} + run-id: ${{ github.event.workflow_run.id }} + + - name: Resolve current pull request + id: resolve-pr + uses: actions/github-script@v9 + env: + PR_NUMBER_FILE: ${{ runner.temp }}/frontend-diagnostics/pr-number.txt + with: + script: | + const fs = require('fs/promises'); + const { owner, repo } = context.repo; + const workflowRun = context.payload.workflow_run; + const pullRequestNumberText = (await fs.readFile(process.env.PR_NUMBER_FILE, 'utf8')).trim(); + if (!/^[1-9]\d*$/.test(pullRequestNumberText)) { + throw new Error('Invalid pull request number in frontend diagnostics artifact.'); + } + + const pullRequestNumber = Number(pullRequestNumberText); + if (!Number.isSafeInteger(pullRequestNumber)) { + throw new Error('Pull request number in frontend diagnostics artifact is not a safe integer.'); + } + + const { data: currentPullRequest } = await github.rest.pulls.get({ + owner, + repo, + pull_number: pullRequestNumber, + }); + + if (currentPullRequest.state !== 'open' || currentPullRequest.head.sha !== workflowRun.head_sha) { + core.notice(`Skipping stale frontend diagnostics report for pull request #${pullRequestNumber}.`); + core.setOutput('is-current', 'false'); + return; + } + + core.setOutput('is-current', 'true'); + core.setOutput('pr-number', String(pullRequestNumber)); + + - name: Comment on pull request + if: steps.resolve-pr.outputs.is-current == 'true' + uses: thollander/actions-comment-pull-request@v3 + with: + pr-number: ${{ steps.resolve-pr.outputs.pr-number }} + comment-tag: frontend-diagnostics + file-path: ${{ runner.temp }}/frontend-diagnostics/frontend-diagnostics.md diff --git a/.github/workflows/frontend-browser-diagnostics.inspect.yml b/.github/workflows/frontend-diagnostics.inspect.yml similarity index 51% rename from .github/workflows/frontend-browser-diagnostics.inspect.yml rename to .github/workflows/frontend-diagnostics.inspect.yml index 7b6238c81d..ecf06c3092 100644 --- a/.github/workflows/frontend-browser-diagnostics.inspect.yml +++ b/.github/workflows/frontend-diagnostics.inspect.yml @@ -1,4 +1,4 @@ -name: Frontend browser diagnostics (inspect) +name: Frontend diagnostics (inspect) on: pull_request: @@ -23,21 +23,21 @@ on: - .node-version - .github/misskey/test.yml - packages-private/diagnostics-shared/** - - packages-private/diagnostics-frontend-browser/** - - .github/workflows/frontend-browser-diagnostics.inspect.yml - - .github/workflows/frontend-browser-diagnostics.comment.yml + - packages-private/diagnostics-frontend/** + - .github/workflows/frontend-diagnostics.inspect.yml + - .github/workflows/frontend-diagnostics.comment.yml permissions: contents: read pull-requests: read concurrency: - group: frontend-browser-diagnostics-inspect-${{ github.event.pull_request.number }} + group: frontend-diagnostics-inspect-${{ github.event.pull_request.number }} cancel-in-progress: true jobs: report: - name: Measure frontend browser metrics + name: Measure frontend diagnostics runs-on: ubuntu-latest timeout-minutes: 90 @@ -61,79 +61,100 @@ jobs: persist-credentials: false repository: ${{ github.event.pull_request.base.repo.full_name }} ref: ${{ github.event.pull_request.base.sha }} - path: before + path: base submodules: true - - name: Checkout pull request + - name: Checkout head uses: actions/checkout@v6.0.3 with: persist-credentials: false repository: ${{ github.event.pull_request.head.repo.full_name }} ref: ${{ github.event.pull_request.head.sha }} - path: after + path: head submodules: true - name: Setup pnpm uses: pnpm/action-setup@v6.0.9 with: - package_json_file: after/package.json + package_json_file: head/package.json - name: Setup Node.js uses: actions/setup-node@v6.4.0 with: - node-version-file: after/.node-version + node-version-file: head/.node-version cache: pnpm cache-dependency-path: | - before/pnpm-lock.yaml - after/pnpm-lock.yaml + base/pnpm-lock.yaml + head/pnpm-lock.yaml + + - name: Prepare report output + shell: bash + run: mkdir -p "$RUNNER_TEMP/frontend-diagnostics" - name: Install dependencies for base - working-directory: before + working-directory: base run: pnpm i --frozen-lockfile - name: Configure base - working-directory: before + working-directory: base run: cp .github/misskey/test.yml .config - name: Build base - working-directory: before + working-directory: base + env: + FRONTEND_BUNDLE_VISUALIZER: 'true' + FRONTEND_BUNDLE_VISUALIZER_FILE: ${{ runner.temp }}/frontend-diagnostics/base-bundle-stats.json run: pnpm build - - name: Install dependencies for pull request - working-directory: after + - name: Install dependencies for head + working-directory: head run: pnpm i --frozen-lockfile - - name: Configure pull request - working-directory: after + - name: Configure head + working-directory: head run: cp .github/misskey/test.yml .config - - name: Build pull request - working-directory: after + - name: Build head + working-directory: head + env: + FRONTEND_BUNDLE_VISUALIZER: 'true' + FRONTEND_BUNDLE_VISUALIZER_FILE: ${{ runner.temp }}/frontend-diagnostics/head-bundle-stats.json + FRONTEND_BUNDLE_VISUALIZER_HTML_FILE: ${{ runner.temp }}/frontend-diagnostics/frontend-bundle-visualizer.html run: pnpm build + - name: Upload bundle visualizer + id: upload-bundle-visualizer + uses: actions/upload-artifact@v7 + with: + name: frontend-bundle-visualizer + path: ${{ runner.temp }}/frontend-diagnostics/frontend-bundle-visualizer.html + if-no-files-found: error + archive: false + retention-days: 7 + - name: Install Playwright browsers - working-directory: after/packages-private/diagnostics-frontend-browser + working-directory: head/packages-private/diagnostics-frontend run: pnpm exec playwright install --with-deps --no-shell chromium - name: Measure frontend browser metrics shell: bash - working-directory: after + working-directory: head env: FRONTEND_BROWSER_METRICS_SAMPLE_COUNT: 5 MK_ENABLE_CROSS_ORIGIN_ISOLATION: "true" - # 計測ハーネスはafter側のものを両方に使うが、成果物はrunnerのworkspace直下に出す + # 計測ハーネスはhead側のものを両方に使うが、成果物はrunnerのworkspace直下に出す FRONTEND_BROWSER_HEAP_SNAPSHOT_OUTPUT_DIR: ${{ github.workspace }} run: | - REPORT_DIR="$RUNNER_TEMP/frontend-browser-diagnostics" - mkdir -p "$REPORT_DIR" - pnpm --filter diagnostics-frontend-browser run inspect \ - "$GITHUB_WORKSPACE/before" "$GITHUB_WORKSPACE/after" \ - "$REPORT_DIR/before-browser.json" "$REPORT_DIR/after-browser.json" + REPORT_DIR="$RUNNER_TEMP/frontend-diagnostics" + pnpm --filter diagnostics-frontend run inspect-browser \ + "$GITHUB_WORKSPACE/base" "$GITHUB_WORKSPACE/head" \ + "$REPORT_DIR/base-browser.json" "$REPORT_DIR/head-browser.json" - name: Upload browser base heap snapshot id: upload-browser-base-heap-snapshot uses: actions/upload-artifact@v7 with: + name: frontend-browser-base-heap-snapshot path: base-heap-snapshot.heapsnapshot archive: false if-no-files-found: error @@ -143,6 +164,7 @@ jobs: id: upload-browser-head-heap-snapshot uses: actions/upload-artifact@v7 with: + name: frontend-browser-head-heap-snapshot path: head-heap-snapshot.heapsnapshot archive: false if-no-files-found: error @@ -150,13 +172,13 @@ jobs: - name: Generate browser detailed html shell: bash - working-directory: after + working-directory: head run: | - REPORT_DIR="$RUNNER_TEMP/frontend-browser-diagnostics" - test -s "$REPORT_DIR/before-browser.json" - test -s "$REPORT_DIR/after-browser.json" - pnpm --filter diagnostics-frontend-browser run render-html \ - "$REPORT_DIR/before-browser.json" "$REPORT_DIR/after-browser.json" \ + REPORT_DIR="$RUNNER_TEMP/frontend-diagnostics" + test -s "$REPORT_DIR/base-browser.json" + test -s "$REPORT_DIR/head-browser.json" + pnpm --filter diagnostics-frontend run render-browser-html \ + "$REPORT_DIR/base-browser.json" "$REPORT_DIR/head-browser.json" \ "$REPORT_DIR/frontend-browser-detailed-html.html" - name: Upload browser detailed html @@ -164,54 +186,61 @@ jobs: uses: actions/upload-artifact@v7 with: name: frontend-browser-metrics-detailed-html - path: ${{ runner.temp }}/frontend-browser-diagnostics/frontend-browser-detailed-html.html + path: ${{ runner.temp }}/frontend-diagnostics/frontend-browser-detailed-html.html if-no-files-found: error archive: false retention-days: 7 - - name: Generate browser metrics report + - name: Generate frontend diagnostics report shell: bash - working-directory: after + working-directory: head env: BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} PR_NUMBER: ${{ github.event.pull_request.number }} + FRONTEND_BUNDLE_REPORT_ARTIFACT_URL: ${{ steps.upload-bundle-visualizer.outputs.artifact-url }} FRONTEND_BROWSER_BASE_HEAP_SNAPSHOT_ARTIFACT_URL: ${{ steps.upload-browser-base-heap-snapshot.outputs.artifact-url }} FRONTEND_BROWSER_HEAD_HEAP_SNAPSHOT_ARTIFACT_URL: ${{ steps.upload-browser-head-heap-snapshot.outputs.artifact-url }} FRONTEND_BROWSER_DETAILED_HTML_ARTIFACT_URL: ${{ steps.upload-browser-detailed-html.outputs.artifact-url }} run: | - REPORT_DIR="$RUNNER_TEMP/frontend-browser-diagnostics" - test -s "$REPORT_DIR/before-browser.json" - test -s "$REPORT_DIR/after-browser.json" - pnpm --filter diagnostics-frontend-browser run render-md \ - "$REPORT_DIR/before-browser.json" "$REPORT_DIR/after-browser.json" \ - "$REPORT_DIR/frontend-browser-diagnostics.md" + REPORT_DIR="$RUNNER_TEMP/frontend-diagnostics" + test -s "$REPORT_DIR/base-bundle-stats.json" + test -s "$REPORT_DIR/head-bundle-stats.json" + test -s "$REPORT_DIR/base-browser.json" + test -s "$REPORT_DIR/head-browser.json" + pnpm --filter diagnostics-frontend run render-md \ + "$GITHUB_WORKSPACE/base" "$GITHUB_WORKSPACE/head" \ + "$REPORT_DIR/base-bundle-stats.json" "$REPORT_DIR/head-bundle-stats.json" \ + "$REPORT_DIR/base-browser.json" "$REPORT_DIR/head-browser.json" \ + "$REPORT_DIR/frontend-diagnostics.md" printf '%s\n' "$PR_NUMBER" > "$REPORT_DIR/pr-number.txt" printf '%s\n' "$BASE_SHA" > "$REPORT_DIR/base-sha.txt" printf '%s\n' "$HEAD_SHA" > "$REPORT_DIR/head-sha.txt" printf '%s\n' "${{ github.event.pull_request.html_url }}" > "$REPORT_DIR/pr-url.txt" - - name: Check browser metrics report + - name: Check frontend diagnostics report shell: bash run: | - REPORT_DIR="$RUNNER_TEMP/frontend-browser-diagnostics" - test -s "$REPORT_DIR/frontend-browser-diagnostics.md" + REPORT_DIR="$RUNNER_TEMP/frontend-diagnostics" + test -s "$REPORT_DIR/frontend-diagnostics.md" test -s "$REPORT_DIR/frontend-browser-detailed-html.html" test -s "$REPORT_DIR/pr-number.txt" test -s "$REPORT_DIR/head-sha.txt" - cat "$REPORT_DIR/frontend-browser-diagnostics.md" >> "$GITHUB_STEP_SUMMARY" + cat "$REPORT_DIR/frontend-diagnostics.md" >> "$GITHUB_STEP_SUMMARY" - - name: Upload browser metrics report + - name: Upload frontend diagnostics report uses: actions/upload-artifact@v7 with: - name: frontend-browser-diagnostics + name: frontend-diagnostics path: | - ${{ runner.temp }}/frontend-browser-diagnostics/before-browser.json - ${{ runner.temp }}/frontend-browser-diagnostics/after-browser.json - ${{ runner.temp }}/frontend-browser-diagnostics/frontend-browser-diagnostics.md - ${{ runner.temp }}/frontend-browser-diagnostics/pr-number.txt - ${{ runner.temp }}/frontend-browser-diagnostics/base-sha.txt - ${{ runner.temp }}/frontend-browser-diagnostics/head-sha.txt - ${{ runner.temp }}/frontend-browser-diagnostics/pr-url.txt + ${{ runner.temp }}/frontend-diagnostics/base-bundle-stats.json + ${{ runner.temp }}/frontend-diagnostics/head-bundle-stats.json + ${{ runner.temp }}/frontend-diagnostics/base-browser.json + ${{ runner.temp }}/frontend-diagnostics/head-browser.json + ${{ runner.temp }}/frontend-diagnostics/frontend-diagnostics.md + ${{ runner.temp }}/frontend-diagnostics/pr-number.txt + ${{ runner.temp }}/frontend-diagnostics/base-sha.txt + ${{ runner.temp }}/frontend-diagnostics/head-sha.txt + ${{ runner.temp }}/frontend-diagnostics/pr-url.txt if-no-files-found: error retention-days: 7 diff --git a/.github/workflows/packages-private.yml b/.github/workflows/packages-private.yml index e6a72b68c9..cf7e50670a 100644 --- a/.github/workflows/packages-private.yml +++ b/.github/workflows/packages-private.yml @@ -33,8 +33,7 @@ jobs: workspace: - diagnostics-shared - diagnostics-backend - - diagnostics-frontend-browser - - diagnostics-frontend-bundle + - diagnostics-frontend - changelog-checker steps: - uses: actions/checkout@v6.0.3 diff --git a/packages-private/README.md b/packages-private/README.md index faba8fa046..0c14dffdf5 100644 --- a/packages-private/README.md +++ b/packages-private/README.md @@ -8,8 +8,7 @@ | --- | --- | | [`diagnostics-shared`](./diagnostics-shared) | 計測系パッケージが共有する統計・書式整形・V8 heap snapshot解析のユーティリティ | | [`diagnostics-backend`](./diagnostics-backend) | バックエンドのメモリ使用量をbase/headで比較し、PRコメント用のMarkdownを生成する | -| [`diagnostics-frontend-browser`](./diagnostics-frontend-browser) | ヘッドレスChromeでフロントエンドを操作し、メモリ・ネットワーク等の指標をbase/headで比較する | -| [`diagnostics-frontend-bundle`](./diagnostics-frontend-bundle) | フロントエンドのビルド成果物のchunkサイズをbase/headで比較する | +| [`diagnostics-frontend`](./diagnostics-frontend) | フロントエンドをブラウザで起動した際のメトリクスと生成されたchunk情報等をbase/headで比較する | | [`changelog-checker`](./changelog-checker) | `CHANGELOG.md` の追記内容を検証する | ## GitHub Actionsからの呼び出し方 @@ -20,11 +19,12 @@ CLIに渡すパスはすべて呼び出し側のカレントディレクトリ ```yaml - name: Generate report - working-directory: after + working-directory: head run: >- - pnpm --filter diagnostics-frontend-bundle run render-md - "$GITHUB_WORKSPACE/before" "$GITHUB_WORKSPACE/after" - "$REPORT_DIR/before-stats.json" "$REPORT_DIR/after-stats.json" + pnpm --filter diagnostics-frontend run render-md + "$GITHUB_WORKSPACE/base" "$GITHUB_WORKSPACE/head" + "$REPORT_DIR/base-bundle-stats.json" "$REPORT_DIR/head-bundle-stats.json" + "$REPORT_DIR/base-browser.json" "$REPORT_DIR/head-browser.json" "$REPORT_DIR/report.md" ``` diff --git a/packages-private/diagnostics-frontend-browser/src/render-md.ts b/packages-private/diagnostics-frontend-browser/src/render-md.ts deleted file mode 100644 index 15c4924808..0000000000 --- a/packages-private/diagnostics-frontend-browser/src/render-md.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { readFile, writeFile } from 'node:fs/promises'; -import { resolve } from 'node:path'; -import { readOptionalEnv, readRequiredEnv } from 'diagnostics-shared/env'; -import { renderMarkdown } from './report/markdown'; -import type { BrowserMetricsReport } from './types'; - -async function main() { - const [baseFileArg, headFileArg, outputFileArg] = process.argv.slice(2); - if (baseFileArg == null || headFileArg == null || outputFileArg == null) { - throw new Error('Usage: render-md '); - } - - const base = JSON.parse(await readFile(resolve(baseFileArg), 'utf8')) as BrowserMetricsReport; - const head = JSON.parse(await readFile(resolve(headFileArg), 'utf8')) as BrowserMetricsReport; - - await writeFile(resolve(outputFileArg), renderMarkdown(base, head, { - baseHeapSnapshotUrl: readRequiredEnv('FRONTEND_BROWSER_BASE_HEAP_SNAPSHOT_ARTIFACT_URL'), - headHeapSnapshotUrl: readRequiredEnv('FRONTEND_BROWSER_HEAD_HEAP_SNAPSHOT_ARTIFACT_URL'), - detailedHtmlUrl: readOptionalEnv('FRONTEND_BROWSER_DETAILED_HTML_ARTIFACT_URL'), - })); -} - -await main().catch(err => { - console.error(err); - process.exit(1); -}); diff --git a/packages-private/diagnostics-frontend-browser/test/render.test.ts b/packages-private/diagnostics-frontend-browser/test/render.test.ts deleted file mode 100644 index 29e38f7b3e..0000000000 --- a/packages-private/diagnostics-frontend-browser/test/render.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { readFile } from 'node:fs/promises'; -import { join } from 'node:path'; -import { afterEach, beforeEach, expect, test, vi } from 'vitest'; -import { renderHtml } from '../src/report/html'; -import { renderMarkdown } from '../src/report/markdown'; -import type { BrowserMetricsReport } from '../src/types'; - -const fixturesDir = join(import.meta.dirname, 'fixtures'); - -async function loadFixture(name: string) { - return JSON.parse(await readFile(join(fixturesDir, `${name}.json`), 'utf8')) as BrowserMetricsReport; -} - -beforeEach(() => { - // renderHtml() が生成時刻を埋め込むので、スナップショットが揺れないよう時刻を固定する - vi.useFakeTimers(); - vi.setSystemTime(new Date('2026-07-18T00:00:00.000Z')); -}); - -afterEach(() => { - vi.useRealTimers(); -}); - -/** - * 出力をゴールデンファイルで固定する。 - * 意図的に変更したときは `vitest -u` で更新し、__snapshots__ の差分もレビューすること。 - */ -test('renders the browser diagnostics markdown report', async () => { - const markdown = renderMarkdown(await loadFixture('base'), await loadFixture('head'), { - baseHeapSnapshotUrl: 'https://example.invalid/base', - headHeapSnapshotUrl: 'https://example.invalid/head', - detailedHtmlUrl: 'https://example.invalid/html', - }); - - await expect(markdown).toMatchFileSnapshot('./__snapshots__/render-md.md'); -}); - -test('omits the details link when no detailed html artifact was uploaded', async () => { - const markdown = renderMarkdown(await loadFixture('base'), await loadFixture('head'), { - baseHeapSnapshotUrl: 'https://example.invalid/base', - headHeapSnapshotUrl: 'https://example.invalid/head', - detailedHtmlUrl: null, - }); - - expect(markdown).not.toContain('View details'); -}); - -test('renders the network request diff html report', async () => { - const html = renderHtml(await loadFixture('base'), await loadFixture('head')); - - await expect(html).toMatchFileSnapshot('./__snapshots__/render-html.html'); -}); - -test('escapes html metacharacters coming from the browser session', async () => { - const base = await loadFixture('base'); - const head = await loadFixture('head'); - // CDP由来の値は原理的には任意の文字列になりうるので、生HTMLとして出ないことを確かめる - head.samples[0].networkRequests[0].url = 'http://127.0.0.1:61812/">'; - - const html = renderHtml(base, head); - - expect(html).not.toContain(''); - expect(html).toContain('<script>alert(1)</script>'); -}); diff --git a/packages-private/diagnostics-frontend-bundle/eslint.config.js b/packages-private/diagnostics-frontend-bundle/eslint.config.js deleted file mode 100644 index 11bc648243..0000000000 --- a/packages-private/diagnostics-frontend-bundle/eslint.config.js +++ /dev/null @@ -1,25 +0,0 @@ -import tsParser from '@typescript-eslint/parser'; -import sharedConfig from '../../packages/shared/eslint.config.js'; - -// eslint-disable-next-line import/no-default-export -export default [ - ...sharedConfig, - { - ignores: [ - '**/node_modules', - '**/__snapshots__', - 'test/fixtures', - ], - }, - { - files: ['**/*.ts', '**/*.tsx'], - languageOptions: { - parserOptions: { - parser: tsParser, - project: ['./tsconfig.json'], - sourceType: 'module', - tsconfigRootDir: import.meta.dirname, - }, - }, - }, -]; diff --git a/packages-private/diagnostics-frontend-bundle/package.json b/packages-private/diagnostics-frontend-bundle/package.json deleted file mode 100644 index b0271a8101..0000000000 --- a/packages-private/diagnostics-frontend-bundle/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "diagnostics-frontend-bundle", - "private": true, - "type": "module", - "scripts": { - "eslint": "eslint './**/*.{js,jsx,ts,tsx}'", - "lint": "pnpm typecheck && pnpm eslint", - "render-md": "tsx src/render-md.ts", - "test": "vitest run", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "diagnostics-shared": "workspace:*" - }, - "devDependencies": { - "@types/node": "26.1.1", - "tsx": "4.23.1", - "typescript": "5.9.3", - "vite": "8.1.4", - "vitest": "4.1.10" - } -} diff --git a/packages-private/diagnostics-frontend-bundle/src/render-md.ts b/packages-private/diagnostics-frontend-bundle/src/render-md.ts deleted file mode 100644 index d8fe0997dd..0000000000 --- a/packages-private/diagnostics-frontend-bundle/src/render-md.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { promises as fs } from 'node:fs'; -import path from 'node:path'; -import { readRequiredEnv } from 'diagnostics-shared/env'; -import { collectReport } from './manifest'; -import { renderBundleReportMarkdown } from './report'; -import type { VisualizerReport } from './visualizer'; - -async function main() { - const [beforeDir, afterDir, beforeStatsFile, afterStatsFile, outFile] = process.argv.slice(2).map(arg => path.resolve(arg)); - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (outFile == null) throw new Error('Usage: render-md '); - - // 未設定のまま `undefined` という文字列をコメントに埋め込まないよう、ここで落とす - const visualizerArtifactUrl = readRequiredEnv('FRONTEND_BUNDLE_REPORT_ARTIFACT_URL'); - - const before = await collectReport(beforeDir); - const after = await collectReport(afterDir); - const beforeStats = JSON.parse(await fs.readFile(beforeStatsFile, 'utf8')) as VisualizerReport; - const afterStats = JSON.parse(await fs.readFile(afterStatsFile, 'utf8')) as VisualizerReport; - - await fs.writeFile(outFile, renderBundleReportMarkdown(before, after, beforeStats, afterStats, { visualizerArtifactUrl })); -} - -await main().catch(err => { - console.error(err); - process.exit(1); -}); diff --git a/packages-private/diagnostics-frontend-bundle/src/report.ts b/packages-private/diagnostics-frontend-bundle/src/report.ts deleted file mode 100644 index db2e1cf6e3..0000000000 --- a/packages-private/diagnostics-frontend-bundle/src/report.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { renderFrontendChunkReport } from './chunk-report'; -import { collectVisualizerReport, renderVisualizerSummaryTable, type VisualizerReport } from './visualizer'; -import type { CollectedReport } from './manifest'; - -export type RenderBundleReportOptions = { - /** rollup-plugin-visualizer が出力したtreemap HTMLのartifact URL */ - visualizerArtifactUrl: string; -}; - -export function renderBundleReportMarkdown( - before: CollectedReport, - after: CollectedReport, - beforeStats: VisualizerReport, - afterStats: VisualizerReport, - options: RenderBundleReportOptions, -) { - return [ - '## 📦 Frontend Bundle Report', - '', - renderFrontendChunkReport(before, after), - '', - '## Bundle Stats', - '', - renderVisualizerSummaryTable(collectVisualizerReport(beforeStats), collectVisualizerReport(afterStats)), - '', - `[Open treemap HTML](${options.visualizerArtifactUrl})`, - ].join('\n'); -} diff --git a/packages-private/diagnostics-frontend-bundle/test/__snapshots__/render-md.md b/packages-private/diagnostics-frontend-bundle/test/__snapshots__/render-md.md deleted file mode 100644 index 867cbf3b75..0000000000 --- a/packages-private/diagnostics-frontend-bundle/test/__snapshots__/render-md.md +++ /dev/null @@ -1,100 +0,0 @@ -## 📦 Frontend Bundle Report - -
-Chunk size diff (2 updated, 0 added, 0 removed) - -| Chunk | Before | After | Δ | Δ (%) | -| --- | ---: | ---: | ---: | ---: | -| (total) | 120 KB | 127 KB | $\color{orange}{\text{+6.3 KB}}$ | $\color{orange}{\text{+5.2\\%}}$ | -| | | | | | -|
`vue` `assets/vue-b2.js`
| 90 KB | 96 KB | $\color{orange}{\text{+6 KB}}$ | $\color{orange}{\text{+6.7\\%}}$ | -| (other generated chunks) | 1.2 KB | 1.5 KB | $\text{+300 B}$ | $\color{orange}{\text{+25\\%}}$ | -| (other) | 20 KB | 20 KB | $\text{+3 B}$ | $\text{+0\\%}$ | - -
- -
-Startup chunk size (2 updated, 0 added, 0 removed) - -| Chunk | Before | After | Δ | Δ (%) | -| --- | ---: | ---: | ---: | ---: | -| (total) | 114 KB | 120 KB | $\color{orange}{\text{+6 KB}}$ | $\color{orange}{\text{+5.3\\%}}$ | -| | | | | | -|
`vue` `assets/vue-b2.js`
| 90 KB | 96 KB | $\color{orange}{\text{+6 KB}}$ | $\color{orange}{\text{+6.7\\%}}$ | -| (other) | 24 KB | 24 KB | $\text{+3 B}$ | $\text{+0\\%}$ | - -_Startup chunks are the Vite entry for `src/_boot_.ts` and its static imports._ - -
- - -## Bundle Stats - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
BundlesModulesEntriesImportsSize
StaticDynamicRenderedGzipBrotli
Before2612321 KB6.3 KB5.3 KB
After2713331 KB8.4 KB7 KB
Δ0$\color{orange}{\text{+1}}$0$\color{orange}{\text{+1}}$0$\color{orange}{\text{+9.8 KB}}$$\color{orange}{\text{+2.1 KB}}$$\color{orange}{\text{+1.8 KB}}$
Δ (%)0%$\color{orange}{\text{+16.7\%}}$0%$\color{orange}{\text{+50\%}}$0%$\color{orange}{\text{+46.7\%}}$$\color{orange}{\text{+33.3\%}}$$\color{orange}{\text{+33.3\%}}$
- -[Open treemap HTML](https://example.invalid/treemap) \ No newline at end of file diff --git a/packages-private/diagnostics-frontend-bundle/tsconfig.json b/packages-private/diagnostics-frontend-bundle/tsconfig.json deleted file mode 100644 index 6672c40acb..0000000000 --- a/packages-private/diagnostics-frontend-bundle/tsconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "target": "ESNext", - "module": "ESNext", - "moduleResolution": "bundler", - "strict": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "esModuleInterop": true, - "skipLibCheck": true, - "noEmit": true, - "types": ["node"] - }, - "include": [ - "src/**/*.ts", - "test/**/*.ts" - ], - "exclude": [] -} diff --git a/packages-private/diagnostics-frontend-browser/eslint.config.js b/packages-private/diagnostics-frontend/eslint.config.js similarity index 100% rename from packages-private/diagnostics-frontend-browser/eslint.config.js rename to packages-private/diagnostics-frontend/eslint.config.js diff --git a/packages-private/diagnostics-frontend-browser/package.json b/packages-private/diagnostics-frontend/package.json similarity index 76% rename from packages-private/diagnostics-frontend-browser/package.json rename to packages-private/diagnostics-frontend/package.json index 9264dc49f9..8d3d2d248c 100644 --- a/packages-private/diagnostics-frontend-browser/package.json +++ b/packages-private/diagnostics-frontend/package.json @@ -1,12 +1,12 @@ { - "name": "diagnostics-frontend-browser", + "name": "diagnostics-frontend", "private": true, "type": "module", "scripts": { "eslint": "eslint './**/*.{js,jsx,ts,tsx}'", - "inspect": "tsx src/inspect.ts", + "inspect-browser": "tsx src/inspect-browser.ts", "lint": "pnpm typecheck && pnpm eslint", - "render-html": "tsx src/render-html.ts", + "render-browser-html": "tsx src/render-browser-html.ts", "render-md": "tsx src/render-md.ts", "test": "vitest run", "typecheck": "tsc --noEmit" diff --git a/packages-private/diagnostics-frontend-browser/src/browser/controller.ts b/packages-private/diagnostics-frontend/src/browser/controller.ts similarity index 99% rename from packages-private/diagnostics-frontend-browser/src/browser/controller.ts rename to packages-private/diagnostics-frontend/src/browser/controller.ts index 756245c636..dd17be2a62 100644 --- a/packages-private/diagnostics-frontend-browser/src/browser/controller.ts +++ b/packages-private/diagnostics-frontend/src/browser/controller.ts @@ -5,9 +5,9 @@ import { writeFile } from 'node:fs/promises'; import { chromium } from 'playwright'; -import type { Browser, BrowserContext, CDPSession, Page } from 'playwright'; import { enableNetworkTracking, type NetworkTracker } from './network'; -import type { BrowserDiagnostics, BrowserMeasurement, NetworkRequest, TabMemory, WebSocketConnection } from '../types'; +import type { Browser, BrowserContext, CDPSession, Page } from 'playwright'; +import type { BrowserDiagnostics, BrowserMeasurement, NetworkRequest, TabMemory, WebSocketConnection } from './types'; export type HeadlessChromeOptions = { scenarioTimeoutMs: number; diff --git a/packages-private/diagnostics-frontend-browser/src/browser/diagnostics.ts b/packages-private/diagnostics-frontend/src/browser/diagnostics.ts similarity index 92% rename from packages-private/diagnostics-frontend-browser/src/browser/diagnostics.ts rename to packages-private/diagnostics-frontend/src/browser/diagnostics.ts index 8365038bb2..a4c95bf842 100644 --- a/packages-private/diagnostics-frontend-browser/src/browser/diagnostics.ts +++ b/packages-private/diagnostics-frontend/src/browser/diagnostics.ts @@ -4,7 +4,7 @@ */ import { median } from 'diagnostics-shared/stats'; -import type { BrowserDiagnostics } from '../types'; +import type { BrowserDiagnostics } from './types'; export function summarizeBrowserDiagnostics(samples: BrowserDiagnostics[]): BrowserDiagnostics { const medianOf = (select: (sample: BrowserDiagnostics) => number) => median(samples.map(select)); diff --git a/packages-private/diagnostics-frontend-browser/src/browser/network.ts b/packages-private/diagnostics-frontend/src/browser/network.ts similarity index 99% rename from packages-private/diagnostics-frontend-browser/src/browser/network.ts rename to packages-private/diagnostics-frontend/src/browser/network.ts index 538294ec3d..77095ca689 100644 --- a/packages-private/diagnostics-frontend-browser/src/browser/network.ts +++ b/packages-private/diagnostics-frontend/src/browser/network.ts @@ -4,7 +4,7 @@ */ import type { CDPSession } from 'playwright'; -import type { NetworkRequest, NetworkSummary, WebSocketConnection } from '../types'; +import type { NetworkRequest, NetworkSummary, WebSocketConnection } from './types'; function normalizeHeaders(headers: Record | undefined) { if (headers == null) return undefined; diff --git a/packages-private/diagnostics-frontend-browser/src/report/html-styles.ts b/packages-private/diagnostics-frontend/src/browser/report/html-styles.ts similarity index 100% rename from packages-private/diagnostics-frontend-browser/src/report/html-styles.ts rename to packages-private/diagnostics-frontend/src/browser/report/html-styles.ts diff --git a/packages-private/diagnostics-frontend-browser/src/report/html.ts b/packages-private/diagnostics-frontend/src/browser/report/html.ts similarity index 98% rename from packages-private/diagnostics-frontend-browser/src/report/html.ts rename to packages-private/diagnostics-frontend/src/browser/report/html.ts index 7627f99729..ff1ca47839 100644 --- a/packages-private/diagnostics-frontend-browser/src/report/html.ts +++ b/packages-private/diagnostics-frontend/src/browser/report/html.ts @@ -224,7 +224,7 @@ function renderRound(round: number, diffs: RequestDiff[]): Raw { `; } -export function renderHtml(base: BrowserMetricsReport, head: BrowserMetricsReport) { +export function renderBrowserDiagnosticsHtml(base: BrowserMetricsReport, head: BrowserMetricsReport) { const diffs = diffReports(base, head); const rounds = [...new Set(diffs.map(diff => diff.round))].toSorted((a, b) => a - b); const generatedAt = new Date().toISOString(); diff --git a/packages-private/diagnostics-frontend-browser/src/scenario.ts b/packages-private/diagnostics-frontend/src/browser/scenario.ts similarity index 87% rename from packages-private/diagnostics-frontend-browser/src/scenario.ts rename to packages-private/diagnostics-frontend/src/browser/scenario.ts index 1ad513e93e..80df8532ec 100644 --- a/packages-private/diagnostics-frontend-browser/src/scenario.ts +++ b/packages-private/diagnostics-frontend/src/browser/scenario.ts @@ -3,9 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { closeUserSetupDialog, postNote, registerUser, resetState, signupThroughUi, visitHome } from '../../../packages/frontend/test/e2e/shared'; +import { closeUserSetupDialog, postNote, registerUser, resetState, signupThroughUi, visitHome } from '../../../../packages/frontend/test/e2e/shared'; import { sleep } from './server'; -import type { HeadlessChromeController } from './browser/controller'; +import type { HeadlessChromeController } from './controller'; export const scenarioDescription = 'fresh browser signup, first timeline note, after the note becomes visible'; diff --git a/packages-private/diagnostics-frontend-browser/src/server.ts b/packages-private/diagnostics-frontend/src/browser/server.ts similarity index 90% rename from packages-private/diagnostics-frontend-browser/src/server.ts rename to packages-private/diagnostics-frontend/src/browser/server.ts index c2690c5777..72f7a6f79e 100644 --- a/packages-private/diagnostics-frontend-browser/src/server.ts +++ b/packages-private/diagnostics-frontend/src/browser/server.ts @@ -34,10 +34,15 @@ export function startServer(label: string, repoDir: string) { const serverStartupTimeoutMs = 120_000; +function hasExited(child: ChildProcess) { + return child.exitCode != null || child.signalCode != null; +} + export async function waitForServer(baseUrl: string, child: ChildProcess) { const startedAt = Date.now(); while (Date.now() - startedAt < serverStartupTimeoutMs) { if (child.exitCode != null) throw new Error(`Misskey server exited early with code ${child.exitCode}`); + if (child.signalCode != null) throw new Error(`Misskey server exited early with signal ${child.signalCode}`); try { // 応答が返らないままだとfetchが待ち続け、外側の120秒の上限を超えてしまう const remainingMs = serverStartupTimeoutMs - (Date.now() - startedAt); @@ -55,7 +60,7 @@ export async function waitForServer(baseUrl: string, child: ChildProcess) { } export async function stopServer(child: ChildProcess) { - if (child.exitCode != null) return; + if (hasExited(child)) return; if (process.platform === 'win32') { spawnSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' }); @@ -69,14 +74,14 @@ export async function stopServer(child: ChildProcess) { } await new Promise(resolvePromise => { - if (child.exitCode != null) { + if (hasExited(child)) { resolvePromise(); return; } const forceKillTimer = setTimeout(() => { // 猶予の間に終了していれば、PIDが再利用されて無関係のプロセスを撃つ恐れがある - if (child.exitCode == null && child.pid != null) { + if (!hasExited(child) && child.pid != null) { try { if (process.platform === 'win32') { spawnSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' }); diff --git a/packages-private/diagnostics-frontend-browser/src/summarize.ts b/packages-private/diagnostics-frontend/src/browser/summarize.ts similarity index 98% rename from packages-private/diagnostics-frontend-browser/src/summarize.ts rename to packages-private/diagnostics-frontend/src/browser/summarize.ts index 825ae188f5..1b88002208 100644 --- a/packages-private/diagnostics-frontend-browser/src/summarize.ts +++ b/packages-private/diagnostics-frontend/src/browser/summarize.ts @@ -5,7 +5,7 @@ import { finiteMedian } from 'diagnostics-shared/stats'; import { summarizeHeapSnapshotDataSamples } from 'diagnostics-shared/heap-snapshot'; -import { summarizeBrowserDiagnostics } from './browser/diagnostics'; +import { summarizeBrowserDiagnostics } from './diagnostics'; import type { BrowserMeasurement, BrowserMeasurementSample, BrowserMetricsReport, NetworkSummary } from './types'; export type SummarizeOptions = { diff --git a/packages-private/diagnostics-frontend-browser/src/types.ts b/packages-private/diagnostics-frontend/src/browser/types.ts similarity index 87% rename from packages-private/diagnostics-frontend-browser/src/types.ts rename to packages-private/diagnostics-frontend/src/browser/types.ts index df91071c13..78e31b76f4 100644 --- a/packages-private/diagnostics-frontend-browser/src/types.ts +++ b/packages-private/diagnostics-frontend/src/browser/types.ts @@ -5,6 +5,11 @@ import type { HeapSnapshotData } from 'diagnostics-shared/heap-snapshot'; +// NOTE: (AI向け) +// NetworkRequest がリクエスト本文と要求・応答ヘッダを保持し、APIキーやパスワード、Cookieなどの認証情報も含まれ得るが、 +// workflow上で作られるMisskeyアカウントはテスト用アカウントであり、誰かが実際に使用するプライベートなアカウントではないため、 +// それらの情報が第三者から閲覧できても何の問題もなく、脆弱性ではない。 + export type NetworkRequest = { requestId: string; url: string; diff --git a/packages-private/diagnostics-frontend-bundle/src/chunk-report.ts b/packages-private/diagnostics-frontend/src/bundle/chunk-report.ts similarity index 50% rename from packages-private/diagnostics-frontend-bundle/src/chunk-report.ts rename to packages-private/diagnostics-frontend/src/bundle/chunk-report.ts index 2b2492377c..55857305c5 100644 --- a/packages-private/diagnostics-frontend-bundle/src/chunk-report.ts +++ b/packages-private/diagnostics-frontend/src/bundle/chunk-report.ts @@ -9,7 +9,7 @@ import { escapeMdTableCell, formatBytes, } from 'diagnostics-shared/format'; -import type { CollectedReport, FileEntry } from './manifest'; +import type { CollectedBundleReport, FileEntry } from './manifest'; /** * この差分以下のチャンクは個別に出さず `(other)` にまとめる。 @@ -25,21 +25,21 @@ function entryDisplayName(entry: FileEntry | undefined) { return entry.displayName || entry.file; } -export function getChunkComparisonRows(keys: string[], before: Partial>, after: Partial>) { +export function getChunkComparisonRows(keys: string[], base: Partial>, head: Partial>) { return keys.map(key => { - const beforeEntry = before[key]; - const afterEntry = after[key]; - const beforeSize = beforeEntry?.size ?? 0; - const afterSize = afterEntry?.size ?? 0; + const baseEntry = base[key]; + const headEntry = head[key]; + const baseSize = baseEntry?.size ?? 0; + const headSize = headEntry?.size ?? 0; return { key, - name: entryDisplayName(beforeEntry ?? afterEntry), - beforeFile: beforeEntry?.file, - afterFile: afterEntry?.file, - beforeSize, - afterSize, - changeType: beforeEntry == null ? 'added' : afterEntry == null ? 'removed' : beforeSize !== afterSize ? 'updated' : 'unchanged', - sortSize: Math.max(beforeSize, afterSize), + name: entryDisplayName(baseEntry ?? headEntry), + baseFile: baseEntry?.file, + headFile: headEntry?.file, + baseSize, + headSize, + changeType: baseEntry == null ? 'added' : headEntry == null ? 'removed' : baseSize !== headSize ? 'updated' : 'unchanged', + sortSize: Math.max(baseSize, headSize), }; }); } @@ -47,10 +47,10 @@ export function getChunkComparisonRows(keys: string[], before: Partial[number]; export type ChunkAggregate = { - beforeSize: number; - afterSize: number; - beforeCount: number; - afterCount: number; + baseSize: number; + headSize: number; + baseCount: number; + headCount: number; }; export function sumChunkSizes(chunks: FileEntry[]) { @@ -58,29 +58,29 @@ export function sumChunkSizes(chunks: FileEntry[]) { } /** - * 比較キーを持たない (= before/after で対応付けできない) チャンクの合計。 + * 比較キーを持たない (= base/head で対応付けできない) チャンクの合計。 */ -export function generatedAggregate(before: FileEntry[], after: FileEntry[]): ChunkAggregate { - const beforeGenerated = before.filter(chunk => chunk.comparisonKey == null); - const afterGenerated = after.filter(chunk => chunk.comparisonKey == null); +export function generatedAggregate(base: FileEntry[], head: FileEntry[]): ChunkAggregate { + const baseGenerated = base.filter(chunk => chunk.comparisonKey == null); + const headGenerated = head.filter(chunk => chunk.comparisonKey == null); return { - beforeSize: sumChunkSizes(beforeGenerated), - afterSize: sumChunkSizes(afterGenerated), - beforeCount: beforeGenerated.length, - afterCount: afterGenerated.length, + baseSize: sumChunkSizes(baseGenerated), + headSize: sumChunkSizes(headGenerated), + baseCount: baseGenerated.length, + headCount: headGenerated.length, }; } export function hasSmallDelta(row: ChunkComparisonRow) { - return Math.abs(row.afterSize - row.beforeSize) <= smallDeltaThreshold; + return Math.abs(row.headSize - row.baseSize) <= smallDeltaThreshold; } export function comparisonRowsAggregate(rows: ChunkComparisonRow[]): ChunkAggregate { return { - beforeSize: rows.reduce((sum, row) => sum + row.beforeSize, 0), - afterSize: rows.reduce((sum, row) => sum + row.afterSize, 0), - beforeCount: rows.filter(row => row.beforeFile != null).length, - afterCount: rows.filter(row => row.afterFile != null).length, + baseSize: rows.reduce((sum, row) => sum + row.baseSize, 0), + headSize: rows.reduce((sum, row) => sum + row.headSize, 0), + baseCount: rows.filter(row => row.baseFile != null).length, + headCount: rows.filter(row => row.headFile != null).length, }; } @@ -108,69 +108,69 @@ export function formatChunkChangeSummary(label: string, summary: ReturnType 0 || generated.afterCount > 0); - const hasOther = other != null && (other.beforeCount > 0 || other.afterCount > 0); + const hasGenerated = generated != null && (generated.baseCount > 0 || generated.headCount > 0); + const hasOther = other != null && (other.baseCount > 0 || other.headCount > 0); if (rows.length === 0 && total == null && !hasGenerated && !hasOther) return '_No data_'; const lines = [ - '| Chunk | Before | After | Δ | Δ (%) |', + '| Chunk | Base | Head | Δ | Δ (%) |', '| --- | ---: | ---: | ---: | ---: |', ]; if (total != null) { - lines.push(`| (total) | ${formatBytes(total.beforeSize)} | ${formatBytes(total.afterSize)} | ${calcAndFormatDeltaBytes(total.beforeSize, total.afterSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(total.beforeSize, total.afterSize, 0.1)} |`); + lines.push(`| (total) | ${formatBytes(total.baseSize)} | ${formatBytes(total.headSize)} | ${calcAndFormatDeltaBytes(total.baseSize, total.headSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(total.baseSize, total.headSize, 0.1)} |`); lines.push('| | | | | |'); } for (const row of rows) { const chunkFile = chunkFileDisplay(row); if (row.changeType === 'added') { - lines.push(`|
\`${escapeMdTableCell(row.name)}\` \`${escapeMdTableCell(chunkFile)}\`
| ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{orange}{\\text{( + )}}$ |`); + lines.push(`|
\`${escapeMdTableCell(row.name)}\` \`${escapeMdTableCell(chunkFile)}\`
| ${formatBytes(row.baseSize)} | ${formatBytes(row.headSize)} | ${calcAndFormatDeltaBytes(row.baseSize, row.headSize, 1000)} | $\\color{orange}{\\text{( + )}}$ |`); } else if (row.changeType === 'removed') { - lines.push(`|
\`${escapeMdTableCell(row.name)}\` \`${escapeMdTableCell(chunkFile)}\`
| ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{green}{\\text{( - )}}$ |`); + lines.push(`|
\`${escapeMdTableCell(row.name)}\` \`${escapeMdTableCell(chunkFile)}\`
| ${formatBytes(row.baseSize)} | ${formatBytes(row.headSize)} | ${calcAndFormatDeltaBytes(row.baseSize, row.headSize, 1000)} | $\\color{green}{\\text{( - )}}$ |`); } else { - lines.push(`|
\`${escapeMdTableCell(row.name)}\` \`${escapeMdTableCell(chunkFile)}\`
| ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(row.beforeSize, row.afterSize, 0.1)} |`); + lines.push(`|
\`${escapeMdTableCell(row.name)}\` \`${escapeMdTableCell(chunkFile)}\`
| ${formatBytes(row.baseSize)} | ${formatBytes(row.headSize)} | ${calcAndFormatDeltaBytes(row.baseSize, row.headSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(row.baseSize, row.headSize, 0.1)} |`); } } if (hasGenerated) { - lines.push(`| (other generated chunks) | ${formatBytes(generated.beforeSize)} | ${formatBytes(generated.afterSize)} | ${calcAndFormatDeltaBytes(generated.beforeSize, generated.afterSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(generated.beforeSize, generated.afterSize, 0.1)} |`); + lines.push(`| (other generated chunks) | ${formatBytes(generated.baseSize)} | ${formatBytes(generated.headSize)} | ${calcAndFormatDeltaBytes(generated.baseSize, generated.headSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(generated.baseSize, generated.headSize, 0.1)} |`); } if (hasOther) { - lines.push(`| (other) | ${formatBytes(other.beforeSize)} | ${formatBytes(other.afterSize)} | ${calcAndFormatDeltaBytes(other.beforeSize, other.afterSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(other.beforeSize, other.afterSize, 0.1)} |`); + lines.push(`| (other) | ${formatBytes(other.baseSize)} | ${formatBytes(other.headSize)} | ${calcAndFormatDeltaBytes(other.baseSize, other.headSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(other.baseSize, other.headSize, 0.1)} |`); } return lines.join('\n'); } -export function renderFrontendChunkReport(before: CollectedReport, after: CollectedReport) { - const beforeComparable = before.comparableChunks; - const afterComparable = after.comparableChunks; - const allChunkKeys = [...new Set([...Object.keys(beforeComparable), ...Object.keys(afterComparable)])]; - const allComparisonRows = getChunkComparisonRows(allChunkKeys, beforeComparable, afterComparable); +export function renderFrontendChunkReport(base: CollectedBundleReport, head: CollectedBundleReport) { + const baseComparable = base.comparableChunks; + const headComparable = head.comparableChunks; + const allChunkKeys = [...new Set([...Object.keys(baseComparable), ...Object.keys(headComparable)])]; + const allComparisonRows = getChunkComparisonRows(allChunkKeys, baseComparable, headComparable); const changedRows = allComparisonRows.filter((row) => row.changeType !== 'unchanged'); const diffSummary = summarizeChunkChanges(changedRows); const diffTotal = { - beforeSize: sumChunkSizes(before.chunks), - afterSize: sumChunkSizes(after.chunks), + baseSize: sumChunkSizes(base.chunks), + headSize: sumChunkSizes(head.chunks), }; - const diffGenerated = generatedAggregate(before.chunks, after.chunks); + const diffGenerated = generatedAggregate(base.chunks, head.chunks); const largeDeltaRows = changedRows.filter(row => !hasSmallDelta(row)).sort(compareChunkComparisonRows); const diffRows = largeDeltaRows.slice(0, diffRowLimit); // 表示上限で切り捨てた行も `(other)` に含める。落とすと合計が実際の変化量と合わなくなる @@ -179,22 +179,22 @@ export function renderFrontendChunkReport(before: CollectedReport, after: Collec ...largeDeltaRows.slice(diffRowLimit), ]); - const beforeStartupFiles = new Set(before.startupFiles); - const afterStartupFiles = new Set(after.startupFiles); - const beforeStartupChunks = before.chunks.filter(chunk => beforeStartupFiles.has(chunk.file)); - const afterStartupChunks = after.chunks.filter(chunk => afterStartupFiles.has(chunk.file)); - const beforeStartupComparable = comparableMap(beforeStartupChunks); - const afterStartupComparable = comparableMap(afterStartupChunks); - const startupKeys = [...new Set([...Object.keys(beforeStartupComparable), ...Object.keys(afterStartupComparable)])]; - const startupComparisonRows = getChunkComparisonRows(startupKeys, beforeStartupComparable, afterStartupComparable); + const baseStartupFiles = new Set(base.startupFiles); + const headStartupFiles = new Set(head.startupFiles); + const baseStartupChunks = base.chunks.filter(chunk => baseStartupFiles.has(chunk.file)); + const headStartupChunks = head.chunks.filter(chunk => headStartupFiles.has(chunk.file)); + const baseStartupComparable = comparableMap(baseStartupChunks); + const headStartupComparable = comparableMap(headStartupChunks); + const startupKeys = [...new Set([...Object.keys(baseStartupComparable), ...Object.keys(headStartupComparable)])]; + const startupComparisonRows = getChunkComparisonRows(startupKeys, baseStartupComparable, headStartupComparable); const startupSummary = summarizeChunkChanges(startupComparisonRows); const startupOther = comparisonRowsAggregate(startupComparisonRows.filter(hasSmallDelta)); const startupRows = startupComparisonRows.filter(row => !hasSmallDelta(row)).sort(compareChunkComparisonRows); const startupTotal = { - beforeSize: sumChunkSizes(beforeStartupChunks), - afterSize: sumChunkSizes(afterStartupChunks), + baseSize: sumChunkSizes(baseStartupChunks), + headSize: sumChunkSizes(headStartupChunks), }; - const startupGenerated = generatedAggregate(beforeStartupChunks, afterStartupChunks); + const startupGenerated = generatedAggregate(baseStartupChunks, headStartupChunks); return [ '
', diff --git a/packages-private/diagnostics-frontend-bundle/src/fs-utils.ts b/packages-private/diagnostics-frontend/src/bundle/fs-utils.ts similarity index 90% rename from packages-private/diagnostics-frontend-bundle/src/fs-utils.ts rename to packages-private/diagnostics-frontend/src/bundle/fs-utils.ts index 74ed757b53..c6b1d3d2e6 100644 --- a/packages-private/diagnostics-frontend-bundle/src/fs-utils.ts +++ b/packages-private/diagnostics-frontend/src/bundle/fs-utils.ts @@ -18,8 +18,9 @@ export async function fileExists(filePath: string) { try { await fs.access(filePath); return true; - } catch { - return false; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; + throw error; } } diff --git a/packages-private/diagnostics-frontend-bundle/src/manifest.ts b/packages-private/diagnostics-frontend/src/bundle/manifest.ts similarity index 90% rename from packages-private/diagnostics-frontend-bundle/src/manifest.ts rename to packages-private/diagnostics-frontend/src/bundle/manifest.ts index d35a5cfbfb..05b00c2a09 100644 --- a/packages-private/diagnostics-frontend-bundle/src/manifest.ts +++ b/packages-private/diagnostics-frontend/src/bundle/manifest.ts @@ -6,7 +6,16 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; import { fileExists, fileSize, normalizePath, traverseDirectory } from './fs-utils'; -import type { Manifest, ManifestChunk } from 'vite'; + +export type BundleManifestChunk = { + file: string; + src?: string; + name?: string; + isEntry?: boolean; + imports?: string[]; +}; + +export type BundleManifest = Record; /** * 比較対象とするロケール。ロケール別チャンクは全ロケール分だと数が多すぎるため、 @@ -27,15 +36,15 @@ export type FileEntry = { size: number; }; -export type CollectedReport = { - manifest: Manifest; +export type CollectedBundleReport = { + manifest: BundleManifest; chunks: FileEntry[]; comparableChunks: Record; chunksByManifestKey: Record; startupFiles: string[]; }; -export function findEntryKey(manifest: Manifest) { +export function findEntryKey(manifest: BundleManifest) { const entries = Object.entries(manifest); return entries.find(([key, chunk]) => key === 'src/_boot_.ts' || chunk.src === 'src/_boot_.ts')?.[0] ?? entries.find(([, chunk]) => chunk.name === 'entry' && chunk.isEntry)?.[0] @@ -45,9 +54,9 @@ export function findEntryKey(manifest: Manifest) { /** * ビルド間で安定するチャンク識別子。出力ファイル名はハッシュ付きで毎回変わるため、 - * これが取れないチャンクは before/after の対応付けができない。 + * これが取れないチャンクは base/head の対応付けができない。 */ -export function stableChunkKey(chunk: ManifestChunk) { +export function stableChunkKey(chunk: BundleManifestChunk) { if (chunk.src != null) return `src:${normalizePath(chunk.src)}`; if (chunk.name != null && stableNamedChunks.has(chunk.name)) return `named:${chunk.name}`; return null; @@ -56,7 +65,7 @@ export function stableChunkKey(chunk: ManifestChunk) { /** * 起動時に必ず読み込まれるチャンク (entry とその静的 import) の manifest キーを集める。 */ -export function collectStartupManifestKeys(manifest: Manifest) { +export function collectStartupManifestKeys(manifest: BundleManifest) { const entryKey = findEntryKey(manifest); const keys = new Set(); if (entryKey == null) throw new Error('Unable to find frontend startup entry in Vite manifest.'); @@ -101,10 +110,10 @@ export async function resolveBuiltFile(outDir: string, file: string) { }; } -export async function collectReport(repoDir: string): Promise { +export async function collectBundleReport(repoDir: string): Promise { const outDir = path.join(repoDir, 'built/_frontend_vite_'); const manifestPath = path.join(outDir, 'manifest.json'); - const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as Manifest; + const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as BundleManifest; const chunksByFile = new Map(); const comparableChunks = new Map(); const chunksByManifestKey = new Map(); diff --git a/packages-private/diagnostics-frontend-bundle/src/visualizer.ts b/packages-private/diagnostics-frontend/src/bundle/visualizer.ts similarity index 83% rename from packages-private/diagnostics-frontend-bundle/src/visualizer.ts rename to packages-private/diagnostics-frontend/src/bundle/visualizer.ts index 17e4f190b6..199accf2d7 100644 --- a/packages-private/diagnostics-frontend-bundle/src/visualizer.ts +++ b/packages-private/diagnostics-frontend/src/bundle/visualizer.ts @@ -141,7 +141,7 @@ export function collectVisualizerReport(data: VisualizerReport) { * 要素として配列のまま埋め込まれていたため、テーブルがカンマ区切りの1行に潰れていた。 * 呼び出し側で意識しなくて済むよう、ここで文字列にして返す。 */ -export function renderVisualizerSummaryTable(before: ReturnType, after: ReturnType) { +export function renderVisualizerSummaryTable(base: ReturnType, head: ReturnType) { const summary = [ 'bundles', 'modules', @@ -178,25 +178,25 @@ export function renderVisualizerSummaryTable(before: ReturnType', '', '', - 'Before', - ...summary.map((key) => `${formatNumber(before.summary[key])}`), - ...metrics.map((key) => `${formatBytes(before.metrics[key])}`), + 'Base', + ...summary.map((key) => `${formatNumber(base.summary[key])}`), + ...metrics.map((key) => `${formatBytes(base.metrics[key])}`), '', '', - 'After', - ...summary.map((key) => `${formatNumber(after.summary[key])}`), - ...metrics.map((key) => `${formatBytes(after.metrics[key])}`), + 'Head', + ...summary.map((key) => `${formatNumber(head.summary[key])}`), + ...metrics.map((key) => `${formatBytes(head.metrics[key])}`), '', '', '', 'Δ', - ...summary.map((key) => `${calcAndFormatDeltaNumber(before.summary[key], after.summary[key], 0)}`), - ...metrics.map((key) => `${calcAndFormatDeltaBytes(before.metrics[key], after.metrics[key], 1000)}`), + ...summary.map((key) => `${calcAndFormatDeltaNumber(base.summary[key], head.summary[key], 0)}`), + ...metrics.map((key) => `${calcAndFormatDeltaBytes(base.metrics[key], head.metrics[key], 1000)}`), '', '', 'Δ (%)', - ...summary.map((key) => `${calcAndFormatDeltaPercent(before.summary[key], after.summary[key], 0.1)}`), - ...metrics.map((key) => `${calcAndFormatDeltaPercent(before.metrics[key], after.metrics[key], 0.1)}`), + ...summary.map((key) => `${calcAndFormatDeltaPercent(base.summary[key], head.summary[key], 0.1)}`), + ...metrics.map((key) => `${calcAndFormatDeltaPercent(base.metrics[key], head.metrics[key], 0.1)}`), '', '', '', diff --git a/packages-private/diagnostics-frontend-browser/src/inspect.ts b/packages-private/diagnostics-frontend/src/inspect-browser.ts similarity index 95% rename from packages-private/diagnostics-frontend-browser/src/inspect.ts rename to packages-private/diagnostics-frontend/src/inspect-browser.ts index 9cf08664e9..25d2703e99 100644 --- a/packages-private/diagnostics-frontend-browser/src/inspect.ts +++ b/packages-private/diagnostics-frontend/src/inspect-browser.ts @@ -9,10 +9,10 @@ import { readIntegerEnv, readOptionalEnv } from 'diagnostics-shared/env'; import { analyzeHeapSnapshot, defaultHeapSnapshotBreakdownTopN } from 'diagnostics-shared/heap-snapshot'; import { HeadlessChromeController } from './browser/controller'; import { summarizeNetwork } from './browser/network'; -import { prepareInstance, runSignupAndPostScenario, scenarioDescription } from './scenario'; -import { startServer, stopServer, waitForServer } from './server'; -import { selectRepresentativeSample, summarizeSamples } from './summarize'; -import type { BrowserMeasurementSample, BrowserMetricsReport } from './types'; +import { prepareInstance, runSignupAndPostScenario, scenarioDescription } from './browser/scenario'; +import { startServer, stopServer, waitForServer } from './browser/server'; +import { selectRepresentativeSample, summarizeSamples } from './browser/summarize'; +import type { BrowserMeasurementSample, BrowserMetricsReport } from './browser/types'; type Label = 'base' | 'head'; @@ -110,7 +110,7 @@ async function genReport(label: Label, repoDir: string, outputPath: string) { async function main() { const [baseDirArg, headDirArg, baseOutputArg, headOutputArg] = process.argv.slice(2); if (baseDirArg == null || headDirArg == null || baseOutputArg == null || headOutputArg == null) { - throw new Error('Usage: inspect '); + throw new Error('Usage: inspect-browser '); } for (const label of labels) { diff --git a/packages-private/diagnostics-frontend-browser/src/render-html.ts b/packages-private/diagnostics-frontend/src/render-browser-html.ts similarity index 68% rename from packages-private/diagnostics-frontend-browser/src/render-html.ts rename to packages-private/diagnostics-frontend/src/render-browser-html.ts index d3ed90927a..c6567e088e 100644 --- a/packages-private/diagnostics-frontend-browser/src/render-html.ts +++ b/packages-private/diagnostics-frontend/src/render-browser-html.ts @@ -5,19 +5,19 @@ import { readFile, writeFile } from 'node:fs/promises'; import { resolve } from 'node:path'; -import { renderHtml } from './report/html'; -import type { BrowserMetricsReport } from './types'; +import { renderBrowserDiagnosticsHtml } from './browser/report/html'; +import type { BrowserMetricsReport } from './browser/types'; async function main() { const [baseFileArg, headFileArg, outputFileArg] = process.argv.slice(2); if (baseFileArg == null || headFileArg == null || outputFileArg == null) { - throw new Error('Usage: render-html '); + throw new Error('Usage: render-browser-html '); } const base = JSON.parse(await readFile(resolve(baseFileArg), 'utf8')) as BrowserMetricsReport; const head = JSON.parse(await readFile(resolve(headFileArg), 'utf8')) as BrowserMetricsReport; - await writeFile(resolve(outputFileArg), renderHtml(base, head)); + await writeFile(resolve(outputFileArg), renderBrowserDiagnosticsHtml(base, head)); } await main().catch(err => { diff --git a/packages-private/diagnostics-frontend/src/render-md.ts b/packages-private/diagnostics-frontend/src/render-md.ts new file mode 100644 index 0000000000..a9cfbc1cd1 --- /dev/null +++ b/packages-private/diagnostics-frontend/src/render-md.ts @@ -0,0 +1,62 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { readOptionalEnv, readRequiredEnv } from 'diagnostics-shared/env'; +import { collectBundleReport } from './bundle/manifest'; +import { renderFrontendDiagnosticsMarkdown } from './report'; +import type { BrowserMetricsReport } from './browser/types'; +import type { VisualizerReport } from './bundle/visualizer'; + +const args = process.argv.slice(2); +if (args.length !== 7) { + throw new Error('Usage: render-md '); +} +const [ + baseDirArg, + headDirArg, + baseBundleStatsFileArg, + headBundleStatsFileArg, + baseBrowserFileArg, + headBrowserFileArg, + outputFileArg, +] = args as [string, string, string, string, string, string, string]; + +const [ + baseBundle, + headBundle, + baseBundleStatsJson, + headBundleStatsJson, + baseBrowserJson, + headBrowserJson, +] = await Promise.all([ + collectBundleReport(resolve(baseDirArg)), + collectBundleReport(resolve(headDirArg)), + readFile(resolve(baseBundleStatsFileArg), 'utf8'), + readFile(resolve(headBundleStatsFileArg), 'utf8'), + readFile(resolve(baseBrowserFileArg), 'utf8'), + readFile(resolve(headBrowserFileArg), 'utf8'), +]); + +await writeFile( + resolve(outputFileArg), + renderFrontendDiagnosticsMarkdown({ + bundle: { + base: baseBundle, + head: headBundle, + baseStats: JSON.parse(baseBundleStatsJson) as VisualizerReport, + headStats: JSON.parse(headBundleStatsJson) as VisualizerReport, + visualizerArtifactUrl: readRequiredEnv('FRONTEND_BUNDLE_REPORT_ARTIFACT_URL'), + }, + browser: { + base: JSON.parse(baseBrowserJson) as BrowserMetricsReport, + head: JSON.parse(headBrowserJson) as BrowserMetricsReport, + baseHeapSnapshotUrl: readRequiredEnv('FRONTEND_BROWSER_BASE_HEAP_SNAPSHOT_ARTIFACT_URL'), + headHeapSnapshotUrl: readRequiredEnv('FRONTEND_BROWSER_HEAD_HEAP_SNAPSHOT_ARTIFACT_URL'), + detailedHtmlUrl: readOptionalEnv('FRONTEND_BROWSER_DETAILED_HTML_ARTIFACT_URL'), + }, + }), +); diff --git a/packages-private/diagnostics-frontend-browser/src/report/markdown.ts b/packages-private/diagnostics-frontend/src/report.ts similarity index 86% rename from packages-private/diagnostics-frontend-browser/src/report/markdown.ts rename to packages-private/diagnostics-frontend/src/report.ts index 370cb86892..e605b0f931 100644 --- a/packages-private/diagnostics-frontend-browser/src/report/markdown.ts +++ b/packages-private/diagnostics-frontend/src/report.ts @@ -4,14 +4,29 @@ */ import { formatBytes, formatColoredDelta, formatNumber } from 'diagnostics-shared/format'; -import { pairedDeltaSummary, sampleSpread } from 'diagnostics-shared/stats'; import { renderHeapSnapshotTable, type HeapSnapshotReport } from 'diagnostics-shared/heap-snapshot'; -import type { BrowserMeasurement, BrowserMeasurementSample, BrowserMetricsReport } from '../types'; +import { pairedDeltaSummary } from 'diagnostics-shared/stats'; +import { renderFrontendChunkReport } from './bundle/chunk-report'; +import { collectVisualizerReport, renderVisualizerSummaryTable, type VisualizerReport } from './bundle/visualizer'; +import type { CollectedBundleReport } from './bundle/manifest'; +import type { BrowserMeasurement, BrowserMeasurementSample, BrowserMetricsReport } from './browser/types'; -export type RenderMarkdownOptions = { - baseHeapSnapshotUrl: string; - headHeapSnapshotUrl: string; - detailedHtmlUrl?: string | null; +export type FrontendDiagnosticsMarkdownInput = { + bundle: { + base: CollectedBundleReport; + head: CollectedBundleReport; + baseStats: VisualizerReport; + headStats: VisualizerReport; + /** rollup-plugin-visualizer が出力したtreemap HTMLのartifact URL */ + visualizerArtifactUrl: string; + }; + browser: { + base: BrowserMetricsReport; + head: BrowserMetricsReport; + baseHeapSnapshotUrl: string; + headHeapSnapshotUrl: string; + detailedHtmlUrl?: string | null; + }; }; function renderMetricRow( @@ -45,7 +60,7 @@ function resourceTypeSampleBytes(sample: BrowserMeasurementSample, resourceTypes return resourceTypeBytes(sample, resourceTypes); } -function renderSummaryTable(base: BrowserMetricsReport, head: BrowserMetricsReport, all = false) { +function renderBrowserSummaryTable(base: BrowserMetricsReport, head: BrowserMetricsReport, all = false) { //function getMetric(report: BrowserMeasurement, key: string) { // return report.performance.cdpMetrics[key]; //} @@ -154,13 +169,14 @@ function toHeapSnapshotReport(report: BrowserMetricsReport): HeapSnapshotReport }; } -export function renderMarkdown(base: BrowserMetricsReport, head: BrowserMetricsReport, options: RenderMarkdownOptions) { - const detailedHtmlUrl = options.detailedHtmlUrl; - const heapSnapshotTable = renderHeapSnapshotTable(toHeapSnapshotReport(base), toHeapSnapshotReport(head)); +export function renderFrontendDiagnosticsMarkdown(input: FrontendDiagnosticsMarkdownInput) { + const { bundle, browser } = input; + const detailedHtmlUrl = browser.detailedHtmlUrl; + const heapSnapshotTable = renderHeapSnapshotTable(toHeapSnapshotReport(browser.base), toHeapSnapshotReport(browser.head)); const lines = [ - '## 🖥 Frontend Browser Diagnostics Report', + '## 🖥 Frontend Diagnostics Report', '', - renderSummaryTable(base, head), + renderBrowserSummaryTable(browser.base, browser.head), '', 'Only metrics showing significant changes are displayed.', '', @@ -169,7 +185,7 @@ export function renderMarkdown(base: BrowserMetricsReport, head: BrowserMetricsR '
', 'Requests by resource type', '', - renderResourceTypeTable(base, head), + renderResourceTypeTable(browser.base, browser.head), '', '
', '', @@ -178,11 +194,19 @@ export function renderMarkdown(base: BrowserMetricsReport, head: BrowserMetricsR '', heapSnapshotTable ?? '_No V8 heap snapshot data._', '', - //renderHeapSnapshotSankey(toHeapSnapshotReport(head), 'Head'), + //renderHeapSnapshotSankey(toHeapSnapshotReport(browser.head), 'Head'), //'', - `Download representative heap snapshot: [base](${options.baseHeapSnapshotUrl}) / [head](${options.headHeapSnapshotUrl})`, + `Download representative heap snapshot: [base](${browser.baseHeapSnapshotUrl}) / [head](${browser.headHeapSnapshotUrl})`, '
', '', + '### 📦 Bundle Stats', + '', + renderFrontendChunkReport(bundle.base, bundle.head), + '', + renderVisualizerSummaryTable(collectVisualizerReport(bundle.baseStats), collectVisualizerReport(bundle.headStats)), + '', + `[Open treemap HTML](${bundle.visualizerArtifactUrl})`, + '', ]; return lines.filter(line => line != null).join('\n').trimEnd() + '\n'; diff --git a/packages-private/diagnostics-frontend-browser/test/__snapshots__/render-md.md b/packages-private/diagnostics-frontend/test/__snapshots__/report.md similarity index 62% rename from packages-private/diagnostics-frontend-browser/test/__snapshots__/render-md.md rename to packages-private/diagnostics-frontend/test/__snapshots__/report.md index 0f791057c2..41d3aae727 100644 --- a/packages-private/diagnostics-frontend-browser/test/__snapshots__/render-md.md +++ b/packages-private/diagnostics-frontend/test/__snapshots__/report.md @@ -1,4 +1,4 @@ -## 🖥 Frontend Browser Diagnostics Report +## 🖥 Frontend Diagnostics Report | Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max | | --- | ---: | ---: | ---: | ---: | ---: | ---: | @@ -81,3 +81,102 @@ Download representative heap snapshot: [base](https://example.invalid/base) / [head](https://example.invalid/head) + +### 📦 Bundle Stats + +
+Chunk size diff (2 updated, 0 added, 0 removed) + +| Chunk | Base | Head | Δ | Δ (%) | +| --- | ---: | ---: | ---: | ---: | +| (total) | 120 KB | 127 KB | $\color{orange}{\text{+6.3 KB}}$ | $\color{orange}{\text{+5.2\\%}}$ | +| | | | | | +|
`vue` `assets/vue-b2.js`
| 90 KB | 96 KB | $\color{orange}{\text{+6 KB}}$ | $\color{orange}{\text{+6.7\\%}}$ | +| (other generated chunks) | 1.2 KB | 1.5 KB | $\text{+300 B}$ | $\color{orange}{\text{+25\\%}}$ | +| (other) | 20 KB | 20 KB | $\text{+3 B}$ | $\text{+0\\%}$ | + +
+ +
+Startup chunk size (2 updated, 0 added, 0 removed) + +| Chunk | Base | Head | Δ | Δ (%) | +| --- | ---: | ---: | ---: | ---: | +| (total) | 114 KB | 120 KB | $\color{orange}{\text{+6 KB}}$ | $\color{orange}{\text{+5.3\\%}}$ | +| | | | | | +|
`vue` `assets/vue-b2.js`
| 90 KB | 96 KB | $\color{orange}{\text{+6 KB}}$ | $\color{orange}{\text{+6.7\\%}}$ | +| (other) | 24 KB | 24 KB | $\text{+3 B}$ | $\text{+0\\%}$ | + +_Startup chunks are the Vite entry for `src/_boot_.ts` and its static imports._ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BundlesModulesEntriesImportsSize
StaticDynamicRenderedGzipBrotli
Base2612321 KB6.3 KB5.3 KB
Head2713331 KB8.4 KB7 KB
Δ0$\color{orange}{\text{+1}}$0$\color{orange}{\text{+1}}$0$\color{orange}{\text{+9.8 KB}}$$\color{orange}{\text{+2.1 KB}}$$\color{orange}{\text{+1.8 KB}}$
Δ (%)0%$\color{orange}{\text{+16.7\%}}$0%$\color{orange}{\text{+50\%}}$0%$\color{orange}{\text{+46.7\%}}$$\color{orange}{\text{+33.3\%}}$$\color{orange}{\text{+33.3\%}}$
+ +[Open treemap HTML](https://example.invalid/treemap) diff --git a/packages-private/diagnostics-frontend-browser/test/__snapshots__/render-html.html b/packages-private/diagnostics-frontend/test/browser/__snapshots__/render-html.html similarity index 100% rename from packages-private/diagnostics-frontend-browser/test/__snapshots__/render-html.html rename to packages-private/diagnostics-frontend/test/browser/__snapshots__/render-html.html diff --git a/packages-private/diagnostics-frontend-browser/test/fixtures/base.json b/packages-private/diagnostics-frontend/test/browser/fixtures/base.json similarity index 100% rename from packages-private/diagnostics-frontend-browser/test/fixtures/base.json rename to packages-private/diagnostics-frontend/test/browser/fixtures/base.json diff --git a/packages-private/diagnostics-frontend-browser/test/fixtures/head.json b/packages-private/diagnostics-frontend/test/browser/fixtures/head.json similarity index 100% rename from packages-private/diagnostics-frontend-browser/test/fixtures/head.json rename to packages-private/diagnostics-frontend/test/browser/fixtures/head.json diff --git a/packages-private/diagnostics-frontend/test/browser/render.test.ts b/packages-private/diagnostics-frontend/test/browser/render.test.ts new file mode 100644 index 0000000000..9def362ff6 --- /dev/null +++ b/packages-private/diagnostics-frontend/test/browser/render.test.ts @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; +import { renderBrowserDiagnosticsHtml } from '../../src/browser/report/html'; +import type { BrowserMetricsReport } from '../../src/browser/types'; + +const fixturesDir = join(import.meta.dirname, 'fixtures'); + +async function loadFixture(name: string) { + return JSON.parse(await readFile(join(fixturesDir, `${name}.json`), 'utf8')) as BrowserMetricsReport; +} + +beforeEach(() => { + // renderBrowserDiagnosticsHtml() が生成時刻を埋め込むので、スナップショットが揺れないよう時刻を固定する + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-18T00:00:00.000Z')); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +test('renders the network request diff html report', async () => { + const html = renderBrowserDiagnosticsHtml(await loadFixture('base'), await loadFixture('head')); + + await expect(html).toMatchFileSnapshot('./__snapshots__/render-html.html'); +}); + +test('escapes html metacharacters coming from the browser session', async () => { + const base = await loadFixture('base'); + const head = await loadFixture('head'); + // CDP由来の値は原理的には任意の文字列になりうるので、生HTMLとして出ないことを確かめる + head.samples[0].networkRequests[0].url = 'http://127.0.0.1:61812/">'; + + const html = renderBrowserDiagnosticsHtml(base, head); + + expect(html).not.toContain(''); + expect(html).toContain('<script>alert(1)</script>'); +}); diff --git a/packages-private/diagnostics-frontend/test/browser/server.test.ts b/packages-private/diagnostics-frontend/test/browser/server.test.ts new file mode 100644 index 0000000000..24f3e62444 --- /dev/null +++ b/packages-private/diagnostics-frontend/test/browser/server.test.ts @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { EventEmitter } from 'node:events'; +import { afterEach, expect, test, vi } from 'vitest'; +import { stopServer, waitForServer } from '../../src/browser/server'; +import type { ChildProcess } from 'node:child_process'; + +vi.mock('node:child_process', async () => { + const actual = await vi.importActual('node:child_process'); + return { + ...actual, + spawnSync: vi.fn(), + }; +}); + +function signaledChild() { + return Object.assign(new EventEmitter(), { + exitCode: null, + signalCode: 'SIGTERM' as NodeJS.Signals, + pid: undefined, + kill: vi.fn(), + }) as unknown as ChildProcess; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +test('waitForServer fails immediately when the server exited by signal', async () => { + const fetchMock = vi.fn().mockResolvedValue({ status: 200 }); + vi.stubGlobal('fetch', fetchMock); + + await expect(waitForServer('http://127.0.0.1:61812', signaledChild())) + .rejects.toThrow('Misskey server exited early with signal SIGTERM'); + expect(fetchMock).not.toHaveBeenCalled(); +}); + +test('stopServer returns immediately when the server already exited by signal', async () => { + const child = signaledChild(); + const stopPromise = stopServer(child); + + expect(child.listenerCount('exit')).toBe(0); + await stopPromise; +}); diff --git a/packages-private/diagnostics-frontend-bundle/test/fixtures/before-stats.json b/packages-private/diagnostics-frontend/test/bundle/fixtures/base-stats.json similarity index 99% rename from packages-private/diagnostics-frontend-bundle/test/fixtures/before-stats.json rename to packages-private/diagnostics-frontend/test/bundle/fixtures/base-stats.json index 0a54b93148..27f6b2d6b4 100644 --- a/packages-private/diagnostics-frontend-bundle/test/fixtures/before-stats.json +++ b/packages-private/diagnostics-frontend/test/bundle/fixtures/base-stats.json @@ -1 +1 @@ -{"nodeParts": {"p0": {"renderedLength": 1000, "gzipLength": 300, "brotliLength": 250}, "p1": {"renderedLength": 2000, "gzipLength": 600, "brotliLength": 500}, "p2": {"renderedLength": 3000, "gzipLength": 900, "brotliLength": 750}, "p3": {"renderedLength": 4000, "gzipLength": 1200, "brotliLength": 1000}, "p4": {"renderedLength": 5000, "gzipLength": 1500, "brotliLength": 1250}, "p5": {"renderedLength": 6000, "gzipLength": 1800, "brotliLength": 1500}}, "nodeMetas": {"m0": {"id": "/src/mod0.ts", "isEntry": true, "importedBy": [], "imported": [{"id": "m1", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p0"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m1": {"id": "/src/mod1.ts", "isEntry": false, "importedBy": ["m0"], "imported": [{"id": "m2", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p1"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m2": {"id": "/src/mod2.ts", "isEntry": false, "importedBy": ["m1"], "imported": [{"id": "m3", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p2"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m3": {"id": "/src/mod3.ts", "isEntry": false, "importedBy": ["m2"], "imported": [{"id": "m4", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p3"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m4": {"id": "/src/mod4.ts", "isEntry": false, "importedBy": ["m3"], "imported": [{"id": "m5", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p4"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m5": {"id": "/src/mod5.ts", "isEntry": false, "importedBy": ["m4"], "imported": [], "moduleParts": {"assets/vue-b2.js": "p5"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}}, "options": {}} \ No newline at end of file +{"nodeParts": {"p0": {"renderedLength": 1000, "gzipLength": 300, "brotliLength": 250}, "p1": {"renderedLength": 2000, "gzipLength": 600, "brotliLength": 500}, "p2": {"renderedLength": 3000, "gzipLength": 900, "brotliLength": 750}, "p3": {"renderedLength": 4000, "gzipLength": 1200, "brotliLength": 1000}, "p4": {"renderedLength": 5000, "gzipLength": 1500, "brotliLength": 1250}, "p5": {"renderedLength": 6000, "gzipLength": 1800, "brotliLength": 1500}}, "nodeMetas": {"m0": {"id": "/src/mod0.ts", "isEntry": true, "importedBy": [], "imported": [{"id": "m1", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p0"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m1": {"id": "/src/mod1.ts", "isEntry": false, "importedBy": ["m0"], "imported": [{"id": "m2", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p1"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m2": {"id": "/src/mod2.ts", "isEntry": false, "importedBy": ["m1"], "imported": [{"id": "m3", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p2"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m3": {"id": "/src/mod3.ts", "isEntry": false, "importedBy": ["m2"], "imported": [{"id": "m4", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p3"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m4": {"id": "/src/mod4.ts", "isEntry": false, "importedBy": ["m3"], "imported": [{"id": "m5", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p4"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m5": {"id": "/src/mod5.ts", "isEntry": false, "importedBy": ["m4"], "imported": [], "moduleParts": {"assets/vue-b2.js": "p5"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}}, "options": {}} diff --git a/packages-private/diagnostics-frontend-bundle/test/fixtures/after-stats.json b/packages-private/diagnostics-frontend/test/bundle/fixtures/head-stats.json similarity index 98% rename from packages-private/diagnostics-frontend-bundle/test/fixtures/after-stats.json rename to packages-private/diagnostics-frontend/test/bundle/fixtures/head-stats.json index b69e3f4cea..ecddcae812 100644 --- a/packages-private/diagnostics-frontend-bundle/test/fixtures/after-stats.json +++ b/packages-private/diagnostics-frontend/test/bundle/fixtures/head-stats.json @@ -1 +1 @@ -{"nodeParts": {"p0": {"renderedLength": 1100.0, "gzipLength": 300, "brotliLength": 250}, "p1": {"renderedLength": 2200.0, "gzipLength": 600, "brotliLength": 500}, "p2": {"renderedLength": 3300.0000000000005, "gzipLength": 900, "brotliLength": 750}, "p3": {"renderedLength": 4400.0, "gzipLength": 1200, "brotliLength": 1000}, "p4": {"renderedLength": 5500.0, "gzipLength": 1500, "brotliLength": 1250}, "p5": {"renderedLength": 6600.000000000001, "gzipLength": 1800, "brotliLength": 1500}, "p6": {"renderedLength": 7700.000000000001, "gzipLength": 2100, "brotliLength": 1750}}, "nodeMetas": {"m0": {"id": "/src/mod0.ts", "isEntry": true, "importedBy": [], "imported": [{"id": "m1", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p0"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m1": {"id": "/src/mod1.ts", "isEntry": false, "importedBy": ["m0"], "imported": [{"id": "m2", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p1"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m2": {"id": "/src/mod2.ts", "isEntry": false, "importedBy": ["m1"], "imported": [{"id": "m3", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p2"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m3": {"id": "/src/mod3.ts", "isEntry": false, "importedBy": ["m2"], "imported": [{"id": "m4", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p3"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m4": {"id": "/src/mod4.ts", "isEntry": false, "importedBy": ["m3"], "imported": [{"id": "m5", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p4"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m5": {"id": "/src/mod5.ts", "isEntry": false, "importedBy": ["m4"], "imported": [{"id": "m6", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p5"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m6": {"id": "/src/mod6.ts", "isEntry": false, "importedBy": ["m5"], "imported": [], "moduleParts": {"assets/boot-a1.js": "p6"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}}, "options": {}} \ No newline at end of file +{"nodeParts": {"p0": {"renderedLength": 1100.0, "gzipLength": 300, "brotliLength": 250}, "p1": {"renderedLength": 2200.0, "gzipLength": 600, "brotliLength": 500}, "p2": {"renderedLength": 3300.0000000000005, "gzipLength": 900, "brotliLength": 750}, "p3": {"renderedLength": 4400.0, "gzipLength": 1200, "brotliLength": 1000}, "p4": {"renderedLength": 5500.0, "gzipLength": 1500, "brotliLength": 1250}, "p5": {"renderedLength": 6600.000000000001, "gzipLength": 1800, "brotliLength": 1500}, "p6": {"renderedLength": 7700.000000000001, "gzipLength": 2100, "brotliLength": 1750}}, "nodeMetas": {"m0": {"id": "/src/mod0.ts", "isEntry": true, "importedBy": [], "imported": [{"id": "m1", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p0"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m1": {"id": "/src/mod1.ts", "isEntry": false, "importedBy": ["m0"], "imported": [{"id": "m2", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p1"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m2": {"id": "/src/mod2.ts", "isEntry": false, "importedBy": ["m1"], "imported": [{"id": "m3", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p2"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m3": {"id": "/src/mod3.ts", "isEntry": false, "importedBy": ["m2"], "imported": [{"id": "m4", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p3"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m4": {"id": "/src/mod4.ts", "isEntry": false, "importedBy": ["m3"], "imported": [{"id": "m5", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p4"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m5": {"id": "/src/mod5.ts", "isEntry": false, "importedBy": ["m4"], "imported": [{"id": "m6", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p5"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m6": {"id": "/src/mod6.ts", "isEntry": false, "importedBy": ["m5"], "imported": [], "moduleParts": {"assets/boot-a1.js": "p6"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}}, "options": {}} diff --git a/packages-private/diagnostics-frontend/test/bundle/fs-utils.test.ts b/packages-private/diagnostics-frontend/test/bundle/fs-utils.test.ts new file mode 100644 index 0000000000..12ec8d7bb1 --- /dev/null +++ b/packages-private/diagnostics-frontend/test/bundle/fs-utils.test.ts @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { promises as fs } from 'node:fs'; +import { afterEach, expect, test, vi } from 'vitest'; +import { fileExists } from '../../src/bundle/fs-utils'; + +function fsError(code: string) { + return Object.assign(new Error(`fs error: ${code}`), { code }) as NodeJS.ErrnoException; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +test('fileExists returns false for ENOENT', async () => { + vi.spyOn(fs, 'access').mockRejectedValueOnce(fsError('ENOENT')); + + await expect(fileExists('missing')).resolves.toBe(false); +}); + +test('fileExists rethrows errors other than ENOENT', async () => { + const error = fsError('EACCES'); + vi.spyOn(fs, 'access').mockRejectedValueOnce(error); + + await expect(fileExists('restricted')).rejects.toBe(error); +}); diff --git a/packages-private/diagnostics-frontend/test/bundle/manifest.test.ts b/packages-private/diagnostics-frontend/test/bundle/manifest.test.ts new file mode 100644 index 0000000000..ed91120f5f --- /dev/null +++ b/packages-private/diagnostics-frontend/test/bundle/manifest.test.ts @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, expect, test } from 'vitest'; +import { collectBundleReport } from '../../src/bundle/manifest'; + +let workDir: string; + +beforeAll(async () => { + workDir = await mkdtemp(join(tmpdir(), 'diagnostics-frontend-test-')); +}); + +afterAll(async () => { + await rm(workDir, { recursive: true, force: true }); +}); + +test('fails loudly when the built output is missing', async () => { + await expect(collectBundleReport(join(workDir, 'nonexistent'))).rejects.toThrow(); +}); diff --git a/packages-private/diagnostics-frontend-bundle/test/render-md.test.ts b/packages-private/diagnostics-frontend/test/report.test.ts similarity index 53% rename from packages-private/diagnostics-frontend-bundle/test/render-md.test.ts rename to packages-private/diagnostics-frontend/test/report.test.ts index 17adcef883..c45961ccb9 100644 --- a/packages-private/diagnostics-frontend-bundle/test/render-md.test.ts +++ b/packages-private/diagnostics-frontend/test/report.test.ts @@ -7,16 +7,18 @@ import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { afterAll, beforeAll, expect, test } from 'vitest'; -import { collectReport } from '../src/manifest'; -import { renderBundleReportMarkdown } from '../src/report'; -import type { VisualizerReport } from '../src/visualizer'; +import { collectBundleReport } from '../src/bundle/manifest'; +import { renderFrontendDiagnosticsMarkdown } from '../src/report'; +import type { BrowserMetricsReport } from '../src/browser/types'; +import type { VisualizerReport } from '../src/bundle/visualizer'; -const fixturesDir = join(import.meta.dirname, 'fixtures'); +const bundleFixturesDir = join(import.meta.dirname, 'bundle/fixtures'); +const browserFixturesDir = join(import.meta.dirname, 'browser/fixtures'); /** * ビルド成果物のfixture。 * - * `collectReport` はファイルの中身を見ずサイズしか使わないので、実体は指定バイト数の + * `collectBundleReport` はファイルの中身を見ずサイズしか使わないので、実体は指定バイト数の * 詰め物でよい。ディレクトリ名が `built` になるためリポジトリにはコミットできず * (ルートの .gitignore がビルド成果物として除外する)、テスト実行時に組み立てている。 */ @@ -31,7 +33,7 @@ const manifest = { }; const fileSizes = { - before: { + base: { 'assets/boot-a1.js': 20_000, 'assets/vue-b2.js': 90_000, 'assets/foo-d4.js': 5_000, @@ -39,7 +41,7 @@ const fileSizes = { 'ja-JP/i18n-c3.js': 4_000, 'ja-JP/orphan.js': 1_200, }, - after: { + head: { // 差が小さすぎる (閾値5バイト以下) ので「(other)」に集約される 'assets/boot-a1.js': 20_003, // 明確に増えるので diff表に行として出る @@ -50,15 +52,15 @@ const fileSizes = { // manifestに載らない出力なので「(other generated chunks)」に集約される 'ja-JP/orphan.js': 1_500, }, -} as const satisfies Record<'before' | 'after', Record>; +} as const satisfies Record<'base' | 'head', Record>; -let repoDirs: { before: string; after: string }; +let repoDirs: { base: string; head: string }; let workDir: string; beforeAll(async () => { - workDir = await mkdtemp(join(tmpdir(), 'diagnostics-frontend-bundle-')); + workDir = await mkdtemp(join(tmpdir(), 'diagnostics-frontend-report-test-')); - for (const label of ['before', 'after'] as const) { + for (const label of ['base', 'head'] as const) { const outDir = join(workDir, label, 'built/_frontend_vite_'); await mkdir(outDir, { recursive: true }); await writeFile(join(outDir, 'manifest.json'), JSON.stringify(manifest)); @@ -71,8 +73,8 @@ beforeAll(async () => { } repoDirs = { - before: join(workDir, 'before'), - after: join(workDir, 'after'), + base: join(workDir, 'base'), + head: join(workDir, 'head'), }; }); @@ -80,26 +82,45 @@ afterAll(async () => { await rm(workDir, { recursive: true, force: true }); }); -async function loadStats(name: string) { - return JSON.parse(await readFile(join(fixturesDir, `${name}-stats.json`), 'utf8')) as VisualizerReport; +async function loadBundleStats(name: 'base' | 'head') { + return JSON.parse(await readFile(join(bundleFixturesDir, `${name}-stats.json`), 'utf8')) as VisualizerReport; +} + +async function loadBrowserReport(name: 'base' | 'head') { + return JSON.parse(await readFile(join(browserFixturesDir, `${name}.json`), 'utf8')) as BrowserMetricsReport; +} + +async function renderReport(detailedHtmlUrl: string | null) { + return renderFrontendDiagnosticsMarkdown({ + bundle: { + base: await collectBundleReport(repoDirs.base), + head: await collectBundleReport(repoDirs.head), + baseStats: await loadBundleStats('base'), + headStats: await loadBundleStats('head'), + visualizerArtifactUrl: 'https://example.invalid/treemap', + }, + browser: { + base: await loadBrowserReport('base'), + head: await loadBrowserReport('head'), + baseHeapSnapshotUrl: 'https://example.invalid/base', + headHeapSnapshotUrl: 'https://example.invalid/head', + detailedHtmlUrl, + }, + }); } /** * 出力をゴールデンファイルで固定する。 * 意図的に変更したときは `vitest -u` で更新し、__snapshots__ の差分もレビューすること。 */ -test('renders the frontend bundle report', async () => { - const markdown = renderBundleReportMarkdown( - await collectReport(repoDirs.before), - await collectReport(repoDirs.after), - await loadStats('before'), - await loadStats('after'), - { visualizerArtifactUrl: 'https://example.invalid/treemap' }, - ); +test('renders one frontend diagnostics markdown report from bundle and browser data', async () => { + const markdown = await renderReport('https://example.invalid/html'); - await expect(markdown).toMatchFileSnapshot('./__snapshots__/render-md.md'); + await expect(markdown).toMatchFileSnapshot('./__snapshots__/report.md'); }); -test('fails loudly when the built output is missing', async () => { - await expect(collectReport(join(workDir, 'nonexistent'))).rejects.toThrow(); +test('omits the browser details link when no detailed html artifact was uploaded', async () => { + const markdown = await renderReport(null); + + expect(markdown).not.toContain('View details'); }); diff --git a/packages-private/diagnostics-frontend-browser/tsconfig.json b/packages-private/diagnostics-frontend/tsconfig.json similarity index 100% rename from packages-private/diagnostics-frontend-browser/tsconfig.json rename to packages-private/diagnostics-frontend/tsconfig.json diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 53a344d0e5..02293b4690 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -131,7 +131,7 @@ importers: specifier: 4.1.10 version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) - packages-private/diagnostics-frontend-browser: + packages-private/diagnostics-frontend: dependencies: diagnostics-shared: specifier: workspace:* @@ -156,28 +156,6 @@ importers: specifier: 4.1.10 version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) - packages-private/diagnostics-frontend-bundle: - dependencies: - diagnostics-shared: - specifier: workspace:* - version: link:../diagnostics-shared - devDependencies: - '@types/node': - specifier: 26.1.1 - version: 26.1.1 - tsx: - specifier: 4.23.1 - version: 4.23.1 - typescript: - specifier: 5.9.3 - version: 5.9.3 - vite: - specifier: 8.1.4 - version: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1) - vitest: - specifier: 4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) - packages-private/diagnostics-shared: devDependencies: '@types/node':