mirror of
https://github.com/vercel/eve.git
synced 2026-09-20 05:35:39 +08:00
ci(eve): report build profile timings (#815)
Signed-off-by: Casey Gowrie <ctgowrie@gmail.com>
This commit is contained in:
@@ -2,4 +2,4 @@
|
||||
"eve": patch
|
||||
---
|
||||
|
||||
Add `eve build --profile <path>` for a machine-readable build-timing and final-output-size report.
|
||||
Add `eve build --profile <path>` for a machine-readable build-timing and final-output-size report. Profile collection is best-effort, so reporting failures do not fail an otherwise successful build.
|
||||
|
||||
@@ -3,7 +3,6 @@ name: Bundle Analysis
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
@@ -11,12 +10,14 @@ on:
|
||||
- labeled
|
||||
paths:
|
||||
- ".github/workflows/bundle-analysis.yml"
|
||||
- "e2e/fixtures/agent-tools-sandbox/**"
|
||||
- "apps/fixtures/weather-agent/**"
|
||||
- "package.json"
|
||||
- "packages/eve/**"
|
||||
- "pnpm-lock.yaml"
|
||||
- "pnpm-workspace.yaml"
|
||||
- "scripts/init-install-report.mjs"
|
||||
- "scripts/build-profile-report.mjs"
|
||||
- "scripts/nitro-bundle-report-compare.mjs"
|
||||
- "scripts/nitro-bundle-report-budget.mjs"
|
||||
- "scripts/nitro-bundle-report.mjs"
|
||||
@@ -36,6 +37,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
BUNDLE_WARNING_ACK_LABEL: acknowledge-bundle-warning
|
||||
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
|
||||
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
|
||||
VERCEL_USE_EXPERIMENTAL_FRAMEWORKS: "1"
|
||||
|
||||
steps:
|
||||
- name: Resolve workflow mode
|
||||
@@ -105,29 +110,30 @@ jobs:
|
||||
if: ${{ steps.mode.outputs.acknowledge_only != 'true' }}
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Prepare main baseline worktree
|
||||
- name: Prepare pull request base worktree
|
||||
if: ${{ steps.mode.outputs.acknowledge_only != 'true' && github.event_name == 'pull_request' }}
|
||||
id: baseline
|
||||
env:
|
||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
BASELINE_ROOT="$RUNNER_TEMP/eve-main-baseline"
|
||||
BASELINE_ROOT="$RUNNER_TEMP/eve-pr-base"
|
||||
rm -rf "$BASELINE_ROOT"
|
||||
git worktree add --detach "$BASELINE_ROOT" "$BASE_SHA"
|
||||
echo "root=$BASELINE_ROOT" >> "$GITHUB_OUTPUT"
|
||||
echo "label=main (${BASE_SHA:0:7})" >> "$GITHUB_OUTPUT"
|
||||
echo "label=${BASE_REF} (${BASE_SHA:0:7})" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Install main baseline dependencies
|
||||
- name: Install pull request base dependencies
|
||||
if: ${{ steps.mode.outputs.acknowledge_only != 'true' && github.event_name == 'pull_request' }}
|
||||
working-directory: ${{ steps.baseline.outputs.root }}
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build eve package on main baseline
|
||||
- name: Build eve package on pull request base
|
||||
if: ${{ steps.mode.outputs.acknowledge_only != 'true' && github.event_name == 'pull_request' }}
|
||||
working-directory: ${{ steps.baseline.outputs.root }}
|
||||
run: pnpm --filter eve build
|
||||
|
||||
- name: Resolve weather app on main baseline
|
||||
- name: Resolve weather app on pull request base
|
||||
if: ${{ steps.mode.outputs.acknowledge_only != 'true' && github.event_name == 'pull_request' }}
|
||||
id: baseline_weather_app
|
||||
working-directory: ${{ steps.baseline.outputs.root }}
|
||||
@@ -146,26 +152,26 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build weather app on main baseline in Vercel mode
|
||||
- name: Build weather app on pull request base for size analysis
|
||||
if: ${{ steps.mode.outputs.acknowledge_only != 'true' && github.event_name == 'pull_request' }}
|
||||
id: baseline_build
|
||||
continue-on-error: true
|
||||
working-directory: ${{ steps.baseline.outputs.root }}
|
||||
run: >-
|
||||
VERCEL=1 pnpm --filter "${STEPS_BASELINE_WEATHER_APP_OUTPUTS_FILTER}"
|
||||
exec eve build --skip-sandbox-prewarm
|
||||
env:
|
||||
STEPS_BASELINE_WEATHER_APP_OUTPUTS_FILTER: ${{ steps.baseline_weather_app.outputs.filter }}
|
||||
run: >-
|
||||
VERCEL=1 pnpm --filter "${STEPS_BASELINE_WEATHER_APP_OUTPUTS_FILTER}" \
|
||||
exec eve build --skip-sandbox-prewarm
|
||||
|
||||
- name: Note skipped main baseline
|
||||
- name: Note skipped pull request base size analysis
|
||||
if: ${{ steps.mode.outputs.acknowledge_only != 'true' && github.event_name == 'pull_request' && steps.baseline_build.outcome != 'success' }}
|
||||
run: |
|
||||
echo "Main baseline build failed; bundle analysis will run without a baseline comparison." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Pull request base build failed; bundle analysis will run without a baseline comparison." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Generate main baseline bundle report
|
||||
- name: Generate pull request base bundle report
|
||||
if: ${{ steps.mode.outputs.acknowledge_only != 'true' && github.event_name == 'pull_request' && steps.baseline_build.outcome == 'success' }}
|
||||
env:
|
||||
BASELINE_REPORT_JSON: ${{ runner.temp }}/weather-agent-bundle-report-main.json
|
||||
BASELINE_REPORT_JSON: ${{ runner.temp }}/weather-agent-bundle-report-base.json
|
||||
STEPS_BASELINE_WEATHER_APP_OUTPUTS_PATH: ${{ steps.baseline_weather_app.outputs.path }}
|
||||
working-directory: ${{ steps.baseline.outputs.root }}
|
||||
run: |
|
||||
@@ -176,20 +182,89 @@ jobs:
|
||||
--package-label packages/eve \
|
||||
--output-json "$BASELINE_REPORT_JSON"
|
||||
|
||||
- name: Build sandbox benchmark on pull request base in Vercel mode
|
||||
if: ${{ steps.mode.outputs.acknowledge_only != 'true' && github.event_name == 'pull_request' }}
|
||||
id: baseline_sandbox_build
|
||||
continue-on-error: true
|
||||
working-directory: ${{ steps.baseline.outputs.root }}
|
||||
env:
|
||||
BASELINE_SANDBOX_BUILD_PROFILE: ${{ runner.temp }}/sandbox-build-profile-base.json
|
||||
run: |
|
||||
token_args=()
|
||||
if [ -n "${VERCEL_TOKEN:-}" ]; then
|
||||
token_args=(--token "$VERCEL_TOKEN")
|
||||
fi
|
||||
team_args=()
|
||||
if [ -n "${VERCEL_ORG_ID:-}" ]; then
|
||||
team_args=(--team "$VERCEL_ORG_ID")
|
||||
fi
|
||||
|
||||
pnpm --filter agent-tools-sandbox exec vc link --yes \
|
||||
--project "$VERCEL_PROJECT_ID" "${team_args[@]}" "${token_args[@]}"
|
||||
pnpm --filter agent-tools-sandbox exec vc env pull --yes --environment=preview \
|
||||
"${token_args[@]}"
|
||||
|
||||
profile_args=()
|
||||
if pnpm --filter agent-tools-sandbox exec eve build --help | grep -Fq -- "--profile"; then
|
||||
profile_args=(--profile "$BASELINE_SANDBOX_BUILD_PROFILE")
|
||||
else
|
||||
echo "Pull request base predates eve build --profile; build timing will report the current build only." >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
# Sandbox templates key on VERCEL_PROJECT_ID at build and runtime.
|
||||
# Do not add VERCEL_TEAM_ID: runtime does not receive it.
|
||||
VERCEL=1 \
|
||||
VERCEL_ENV=preview \
|
||||
VERCEL_TARGET_ENV=preview \
|
||||
VERCEL_PROJECT_ID="$VERCEL_PROJECT_ID" \
|
||||
pnpm --filter agent-tools-sandbox exec eve build "${profile_args[@]}"
|
||||
|
||||
- name: Note skipped sandbox benchmark base
|
||||
if: ${{ steps.mode.outputs.acknowledge_only != 'true' && github.event_name == 'pull_request' && steps.baseline_sandbox_build.outcome != 'success' }}
|
||||
run: |
|
||||
echo "Pull request base sandbox benchmark failed; build timing will run without a baseline comparison." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Build eve package
|
||||
if: ${{ steps.mode.outputs.acknowledge_only != 'true' }}
|
||||
run: pnpm --filter eve build
|
||||
|
||||
- name: Build weather-agent in Vercel mode
|
||||
- name: Build weather-agent for size analysis
|
||||
if: ${{ steps.mode.outputs.acknowledge_only != 'true' }}
|
||||
run: >-
|
||||
VERCEL=1 pnpm --filter weather-agent
|
||||
exec eve build --skip-sandbox-prewarm
|
||||
|
||||
- name: Build sandbox benchmark in Vercel mode
|
||||
if: ${{ steps.mode.outputs.acknowledge_only != 'true' }}
|
||||
env:
|
||||
SANDBOX_BUILD_PROFILE: ${{ runner.temp }}/sandbox-build-profile.json
|
||||
run: |
|
||||
token_args=()
|
||||
if [ -n "${VERCEL_TOKEN:-}" ]; then
|
||||
token_args=(--token "$VERCEL_TOKEN")
|
||||
fi
|
||||
team_args=()
|
||||
if [ -n "${VERCEL_ORG_ID:-}" ]; then
|
||||
team_args=(--team "$VERCEL_ORG_ID")
|
||||
fi
|
||||
|
||||
pnpm --filter agent-tools-sandbox exec vc link --yes \
|
||||
--project "$VERCEL_PROJECT_ID" "${team_args[@]}" "${token_args[@]}"
|
||||
pnpm --filter agent-tools-sandbox exec vc env pull --yes --environment=preview \
|
||||
"${token_args[@]}"
|
||||
|
||||
# Sandbox templates key on VERCEL_PROJECT_ID at build and runtime.
|
||||
# Do not add VERCEL_TEAM_ID: runtime does not receive it.
|
||||
VERCEL=1 \
|
||||
VERCEL_ENV=preview \
|
||||
VERCEL_TARGET_ENV=preview \
|
||||
VERCEL_PROJECT_ID="$VERCEL_PROJECT_ID" \
|
||||
pnpm --filter agent-tools-sandbox exec eve build --profile "$SANDBOX_BUILD_PROFILE"
|
||||
|
||||
- name: Generate bundle report
|
||||
if: ${{ steps.mode.outputs.acknowledge_only != 'true' }}
|
||||
env:
|
||||
BASELINE_REPORT_JSON: ${{ runner.temp }}/weather-agent-bundle-report-main.json
|
||||
BASELINE_REPORT_JSON: ${{ runner.temp }}/weather-agent-bundle-report-base.json
|
||||
BASELINE_REPORT_LABEL: ${{ steps.baseline.outputs.label }}
|
||||
HAS_BASELINE_REPORT: ${{ github.event_name == 'pull_request' && steps.baseline_build.outcome == 'success' }}
|
||||
BUNDLE_REPORT_JSON: ${{ runner.temp }}/weather-agent-bundle-report.json
|
||||
@@ -216,11 +291,49 @@ jobs:
|
||||
--output-json "$BUNDLE_REPORT_JSON" \
|
||||
--output-markdown "$BUNDLE_REPORT_MARKDOWN"
|
||||
|
||||
- name: Generate build timing report
|
||||
if: ${{ steps.mode.outputs.acknowledge_only != 'true' }}
|
||||
env:
|
||||
BASELINE_BUILD_PROFILE: ${{ runner.temp }}/sandbox-build-profile-base.json
|
||||
BASELINE_BUILD_PROFILE_LABEL: ${{ steps.baseline.outputs.label }}
|
||||
BUILD_PROFILE: ${{ runner.temp }}/sandbox-build-profile.json
|
||||
BUILD_PROFILE_REPORT_JSON: ${{ runner.temp }}/sandbox-build-profile-report.json
|
||||
BUILD_PROFILE_REPORT_MARKDOWN: ${{ runner.temp }}/sandbox-build-profile-report.md
|
||||
HAS_BASELINE_BUILD_PROFILE: ${{ github.event_name == 'pull_request' && steps.baseline_sandbox_build.outcome == 'success' }}
|
||||
run: |
|
||||
report_args=()
|
||||
if [ "$HAS_BASELINE_BUILD_PROFILE" = "true" ] && [ -f "$BASELINE_BUILD_PROFILE" ]; then
|
||||
report_args=(
|
||||
--baseline-profile "$BASELINE_BUILD_PROFILE"
|
||||
--baseline-label "$BASELINE_BUILD_PROFILE_LABEL"
|
||||
)
|
||||
fi
|
||||
|
||||
node ./scripts/build-profile-report.mjs \
|
||||
--profile "$BUILD_PROFILE" \
|
||||
--app-label e2e/fixtures/agent-tools-sandbox \
|
||||
--sandbox-prewarm included \
|
||||
--require-phase sandbox.prewarm \
|
||||
"${report_args[@]}" \
|
||||
--output-json "$BUILD_PROFILE_REPORT_JSON" \
|
||||
--output-markdown "$BUILD_PROFILE_REPORT_MARKDOWN"
|
||||
|
||||
- name: Combine bundle and timing reports
|
||||
if: ${{ steps.mode.outputs.acknowledge_only != 'true' }}
|
||||
env:
|
||||
ANALYSIS_REPORT_MARKDOWN: ${{ runner.temp }}/weather-agent-analysis-report.md
|
||||
BUILD_PROFILE_REPORT_MARKDOWN: ${{ runner.temp }}/sandbox-build-profile-report.md
|
||||
BUNDLE_REPORT_MARKDOWN: ${{ runner.temp }}/weather-agent-bundle-report.md
|
||||
run: |
|
||||
cat "$BUNDLE_REPORT_MARKDOWN" > "$ANALYSIS_REPORT_MARKDOWN"
|
||||
printf '\n\n' >> "$ANALYSIS_REPORT_MARKDOWN"
|
||||
cat "$BUILD_PROFILE_REPORT_MARKDOWN" >> "$ANALYSIS_REPORT_MARKDOWN"
|
||||
|
||||
- name: Publish job summary
|
||||
if: ${{ steps.mode.outputs.acknowledge_only != 'true' }}
|
||||
env:
|
||||
BUNDLE_REPORT_MARKDOWN: ${{ runner.temp }}/weather-agent-bundle-report.md
|
||||
run: cat "$BUNDLE_REPORT_MARKDOWN" >> "$GITHUB_STEP_SUMMARY"
|
||||
ANALYSIS_REPORT_MARKDOWN: ${{ runner.temp }}/weather-agent-analysis-report.md
|
||||
run: cat "$ANALYSIS_REPORT_MARKDOWN" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload bundle report artifact
|
||||
if: ${{ steps.mode.outputs.acknowledge_only != 'true' }}
|
||||
@@ -230,18 +343,21 @@ jobs:
|
||||
path: |
|
||||
${{ runner.temp }}/weather-agent-bundle-report.json
|
||||
${{ runner.temp }}/weather-agent-bundle-report.md
|
||||
${{ runner.temp }}/sandbox-build-profile*.json
|
||||
${{ runner.temp }}/sandbox-build-profile*.md
|
||||
${{ runner.temp }}/weather-agent-analysis-report.md
|
||||
|
||||
- name: Update pull request comment
|
||||
if: ${{ steps.mode.outputs.acknowledge_only != 'true' && github.event_name == 'pull_request' }}
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
env:
|
||||
BUNDLE_REPORT_MARKDOWN: ${{ runner.temp }}/weather-agent-bundle-report.md
|
||||
ANALYSIS_REPORT_MARKDOWN: ${{ runner.temp }}/weather-agent-analysis-report.md
|
||||
with:
|
||||
script: |
|
||||
const fs = require("node:fs");
|
||||
|
||||
const marker = "<!-- eve-weather-agent-bundle-analysis -->";
|
||||
const reportBody = fs.readFileSync(process.env.BUNDLE_REPORT_MARKDOWN, "utf8").trim();
|
||||
const reportBody = fs.readFileSync(process.env.ANALYSIS_REPORT_MARKDOWN, "utf8").trim();
|
||||
const body = `${marker}\n${reportBody}`;
|
||||
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
fi
|
||||
# HEAD is the PR merge commit; HEAD^1 is the base branch tip, so
|
||||
# this diff is exactly the PR's changed files.
|
||||
if git diff --name-only HEAD^1 HEAD | grep -qE '^(packages/eve/|apps/fixtures/|e2e/|pnpm-workspace\.yaml$|pnpm-lock\.yaml$|\.github/workflows/e2e-vercel\.yml$|\.github/scripts/discover-e2e-fixtures\.sh$)'; then
|
||||
if git diff --name-only HEAD^1 HEAD | grep -qE '^(packages/eve/|apps/fixtures/|e2e/|pnpm-workspace\.yaml$|pnpm-lock\.yaml$|scripts/build-profile-report\.mjs$|\.github/workflows/e2e-vercel\.yml$|\.github/scripts/discover-e2e-fixtures\.sh$)'; then
|
||||
echo "relevant=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "relevant=false" >> "$GITHUB_OUTPUT"
|
||||
@@ -112,6 +112,8 @@ jobs:
|
||||
|
||||
- name: Deploy fixture and run evals
|
||||
if: needs.changes.outputs.relevant == 'true'
|
||||
env:
|
||||
EVE_BUILD_PROFILE: ${{ runner.temp }}/eve-build-profile-${{ matrix.fixture.name }}-${{ matrix.model.name }}.json
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "$EVE_EVAL_JUNIT_DIR"
|
||||
@@ -137,7 +139,7 @@ jobs:
|
||||
VERCEL_ENV=preview \
|
||||
VERCEL_TARGET_ENV=preview \
|
||||
VERCEL_PROJECT_ID="$VERCEL_PROJECT_ID" \
|
||||
pnpm exec eve build
|
||||
pnpm exec eve build --profile "$EVE_BUILD_PROFILE"
|
||||
DEPLOYMENT_URL="$(pnpm exec vc deploy --prebuilt --yes --target=preview \
|
||||
--env "EVE_E2E_MODEL=$EVE_E2E_MODEL" "${token_args[@]}" | tail -n 1)"
|
||||
|
||||
@@ -160,6 +162,19 @@ jobs:
|
||||
--junit "$EVE_EVAL_JUNIT_DIR/${{ matrix.fixture.name }}-${{ matrix.model.name }}-redeploy.xml"
|
||||
fi
|
||||
|
||||
- name: Log deployable build timing
|
||||
if: needs.changes.outputs.relevant == 'true'
|
||||
env:
|
||||
BUILD_PROFILE: ${{ runner.temp }}/eve-build-profile-${{ matrix.fixture.name }}-${{ matrix.model.name }}.json
|
||||
FIXTURE_PATH: ${{ matrix.fixture.dir }}
|
||||
run: |
|
||||
node ./scripts/build-profile-report.mjs \
|
||||
--profile "$BUILD_PROFILE" \
|
||||
--app-label "$FIXTURE_PATH" \
|
||||
--sandbox-prewarm included \
|
||||
--require-phase sandbox.prewarm \
|
||||
--output-format build-time
|
||||
|
||||
- name: Upload eval artifacts
|
||||
if: (failure()) && needs.changes.outputs.relevant == 'true'
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v6
|
||||
|
||||
@@ -95,10 +95,10 @@ eve build [--profile <path>] [--skip-sandbox-prewarm]
|
||||
|
||||
Compiles and bundles in an invocation-owned directory under `.eve/builds/`, then publishes the completed host output and prints its path. Scratch workspaces are removed after success or failure.
|
||||
|
||||
| Flag | Type | Default | Description |
|
||||
| ------------------------ | ------ | ------- | ----------------------------------------------------------------------------------------- |
|
||||
| `--profile <path>` | string | off | Write a versioned JSON report with build-phase timings and final output-size measurements |
|
||||
| `--skip-sandbox-prewarm` | flag | off | Skip sandbox template prewarm for a Vercel build; the output might not be deployable |
|
||||
| Flag | Type | Default | Description |
|
||||
| ------------------------ | ------ | ------- | --------------------------------------------------------------------------------------------- |
|
||||
| `--profile <path>` | string | off | Best-effort versioned JSON report with build-phase timings and final output-size measurements |
|
||||
| `--skip-sandbox-prewarm` | flag | off | Skip sandbox template prewarm for a Vercel build; the output might not be deployable |
|
||||
|
||||
Use a profile file to establish a repeatable baseline before changing the build pipeline:
|
||||
|
||||
@@ -106,7 +106,7 @@ Use a profile file to establish a repeatable baseline before changing the build
|
||||
eve build --profile .eve/build-profiles/baseline.json
|
||||
```
|
||||
|
||||
The report is written only after a successful build. It records total elapsed time, completed phase timings, and final regular-file totals for file count, raw bytes, and the sum of each file compressed with gzip. For Vercel output it also includes a subtotal for every real `.func` directory, so app and flow bundles can be compared separately. The profile path resolves from the app root and must be outside the published output directory; profile collection does not add a file to the deployment.
|
||||
The report is attempted only after a successful build. It records total elapsed time, completed phase timings, and final regular-file totals for file count, raw bytes, and the sum of each file compressed with gzip. For Vercel output it also includes a subtotal for every real `.func` directory, so app and flow bundles can be compared separately. The profile path resolves from the app root and should be outside the published output directory; profile collection does not add a file to the deployment. If collection or writing fails, eve emits a warning but keeps the completed build successful.
|
||||
|
||||
Production builds do not write through the stable compiler, host, Nitro, or Workflow files owned by `eve dev`, so builds can run while a local dev server is active. A failed build leaves the last successful `.output/` and agent summary untouched. Concurrent completed builds serialize only the final publication window.
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ export function registerBuildCommand(input: {
|
||||
input.program
|
||||
.command("build")
|
||||
.description("Build the current eve application.")
|
||||
.option("--profile <path>", "Write timing and output-size profile JSON to a file")
|
||||
.option("--profile <path>", "Write best-effort timing and output-size profile JSON to a file")
|
||||
.option(
|
||||
"--skip-sandbox-prewarm",
|
||||
"Skip sandbox template prewarm for a Vercel build; output may not be deployable",
|
||||
@@ -61,14 +61,5 @@ export function registerBuildCommand(input: {
|
||||
tone: "success",
|
||||
}),
|
||||
);
|
||||
if (profileOutputPath !== undefined) {
|
||||
input.logger.log(
|
||||
renderCliTaggedLine(theme, {
|
||||
message: `wrote build profile to ${profileOutputPath}`,
|
||||
tag: "build",
|
||||
tone: "success",
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -405,14 +405,13 @@ describe("eve dev local server ownership", () => {
|
||||
});
|
||||
|
||||
describe("eve build output ownership", () => {
|
||||
it("forwards a profile path relative to the application root and reports it", async () => {
|
||||
it("forwards a profile path relative to the application root", async () => {
|
||||
const buildHost = vi.fn(async () => "/app/.output");
|
||||
const output: string[] = [];
|
||||
const profilePath = ".eve/build-profiles/notes.json";
|
||||
|
||||
await runCli(
|
||||
["build", "--profile", profilePath],
|
||||
{ error: () => {}, log: (message) => output.push(message) },
|
||||
{ error: () => {}, log: () => {} },
|
||||
{ buildHost },
|
||||
);
|
||||
|
||||
@@ -421,9 +420,6 @@ describe("eve build output ownership", () => {
|
||||
skipVercelSandboxPrewarm: false,
|
||||
vercelServiceOutput: undefined,
|
||||
});
|
||||
expect(output.join("\n")).toContain(
|
||||
`wrote build profile to ${resolve(process.cwd(), profilePath)}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves the internal service output directory from the build working directory", async () => {
|
||||
|
||||
@@ -282,19 +282,67 @@ describe("buildApplication", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects a profile path inside the published output", async () => {
|
||||
it("keeps a profile path inside the published output from failing or changing a build", async () => {
|
||||
vi.stubEnv("VERCEL", "");
|
||||
const appRoot = await createScratchDirectory("eve-build-application-profile-output-");
|
||||
const profilePath = join(appRoot, ".output", "build-profile.json");
|
||||
|
||||
const { buildApplication } = await import("#internal/nitro/host/build-application.js");
|
||||
await expect(
|
||||
buildApplication(appRoot, {
|
||||
profileOutputPath: join(appRoot, ".output", "build-profile.json"),
|
||||
prepareProductionApplicationHostMock.mockImplementationOnce(prepareHostBuildWorkspace);
|
||||
createProductionApplicationNitroMock.mockImplementationOnce(
|
||||
async (_preparedHost: PreparedApplicationHost, options: { outputDir: string }) =>
|
||||
createNitroStub(options.outputDir),
|
||||
);
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
const { buildApplication } = await import("#internal/nitro/host/build-application.js");
|
||||
const outputDir = await buildApplication(appRoot, {
|
||||
profileOutputPath: profilePath,
|
||||
skipVercelSandboxPrewarm: false,
|
||||
}),
|
||||
).rejects.toThrow("must be outside the published output directory");
|
||||
});
|
||||
|
||||
expect(prepareProductionApplicationHostMock).not.toHaveBeenCalled();
|
||||
expect(outputDir).toBe(join(appRoot, ".output"));
|
||||
await expect(readFile(profilePath, "utf8")).rejects.toThrow();
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("must be outside the published output directory"),
|
||||
);
|
||||
} finally {
|
||||
warn.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps profile write errors from failing a completed build", async () => {
|
||||
vi.stubEnv("VERCEL", "");
|
||||
const appRoot = await createScratchDirectory("eve-build-application-profile-write-error-");
|
||||
const profileParentPath = join(appRoot, "profile-parent");
|
||||
const profilePath = join(profileParentPath, "build.json");
|
||||
|
||||
prepareProductionApplicationHostMock.mockImplementationOnce(prepareHostBuildWorkspace);
|
||||
createProductionApplicationNitroMock.mockImplementationOnce(
|
||||
async (_preparedHost: PreparedApplicationHost, options: { outputDir: string }) =>
|
||||
createNitroStub(options.outputDir),
|
||||
);
|
||||
await writeFile(profileParentPath, "not-a-directory\n");
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
const { buildApplication } = await import("#internal/nitro/host/build-application.js");
|
||||
const outputDir = await buildApplication(appRoot, {
|
||||
profileOutputPath: profilePath,
|
||||
skipVercelSandboxPrewarm: false,
|
||||
});
|
||||
|
||||
expect(outputDir).toBe(join(appRoot, ".output"));
|
||||
await expect(readFile(join(outputDir, "eve-cache.json"), "utf8")).resolves.toContain(
|
||||
"eveVersion",
|
||||
);
|
||||
await expect(readFile(profilePath, "utf8")).rejects.toThrow();
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("failed to write optional build profile"),
|
||||
);
|
||||
} finally {
|
||||
warn.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the last-good output when Nitro mutates its target before failing", async () => {
|
||||
@@ -339,6 +387,7 @@ describe("buildApplication", () => {
|
||||
it("builds isolated Vercel Nitro surfaces and stitches workflow functions", async () => {
|
||||
vi.stubEnv("VERCEL", "1");
|
||||
const appRoot = await createScratchDirectory("eve-build-application-vercel-");
|
||||
const profilePath = join(appRoot, ".eve", "profiles", "vercel-build.json");
|
||||
const stableFlowOutputDir = join(appRoot, ".eve", "nitro-output", "flow");
|
||||
const staleFlowOutputPath = join(stableFlowOutputDir, "stale-flow.txt");
|
||||
|
||||
@@ -382,7 +431,10 @@ describe("buildApplication", () => {
|
||||
);
|
||||
|
||||
const { buildApplication } = await import("#internal/nitro/host/build-application.js");
|
||||
const outputDir = await buildApplication(appRoot, DEPLOYABLE_BUILD_OPTIONS);
|
||||
const outputDir = await buildApplication(appRoot, {
|
||||
...DEPLOYABLE_BUILD_OPTIONS,
|
||||
profileOutputPath: profilePath,
|
||||
});
|
||||
|
||||
expect(outputDir).toBe(join(appRoot, ".vercel", "output"));
|
||||
expect(createProductionApplicationNitroMock).toHaveBeenCalledTimes(2);
|
||||
@@ -450,6 +502,11 @@ describe("buildApplication", () => {
|
||||
log: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
const profile = JSON.parse(await readFile(profilePath, "utf8")) as ApplicationBuildProfile;
|
||||
expect(profile.target).toBe("vercel");
|
||||
expect(profile.phases).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ name: "sandbox.prewarm" })]),
|
||||
);
|
||||
|
||||
const summary = JSON.parse(
|
||||
await readFile(join(appRoot, VERCEL_EVE_AGENT_SUMMARY_OUTPUT_PATH), "utf8"),
|
||||
|
||||
@@ -39,6 +39,7 @@ import type {
|
||||
PreparedApplicationHost,
|
||||
} from "#internal/nitro/host/types.js";
|
||||
import { findClosestVercelOutputDirectory } from "#shared/vercel-output-directory.js";
|
||||
import { toErrorMessage } from "#shared/errors.js";
|
||||
import { resolveDiscoveryProject } from "#discover/project.js";
|
||||
import { createDiskRuntimeCompiledArtifactsSource } from "#runtime/compiled-artifacts-source.js";
|
||||
|
||||
@@ -73,6 +74,30 @@ function assertProfileOutputOutsideBuildOutput(
|
||||
}
|
||||
}
|
||||
|
||||
async function writeOptionalApplicationBuildProfile(input: {
|
||||
readonly outputDirectory: string;
|
||||
readonly profileOutputPath: string;
|
||||
readonly profiler: ApplicationBuildProfiler;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
assertProfileOutputOutsideBuildOutput(input.profileOutputPath, input.outputDirectory);
|
||||
const timing = input.profiler.finish();
|
||||
const output = await measureApplicationBuildOutput(input.outputDirectory);
|
||||
await writeApplicationBuildProfile(
|
||||
input.profileOutputPath,
|
||||
createApplicationBuildProfile({
|
||||
output,
|
||||
target: process.env.VERCEL ? "vercel" : "local",
|
||||
timing,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`eve: failed to write optional build profile to ${input.profileOutputPath}; continuing with the published build output: ${toErrorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -389,7 +414,6 @@ export async function buildApplication(
|
||||
let preserveWorkspaceForRecovery = false;
|
||||
let outputDirectory: string;
|
||||
try {
|
||||
assertProfileOutputOutsideBuildOutput(profileOutputPath, workspace.publication.output.finalDir);
|
||||
outputDirectory = await buildApplicationInWorkspace(workspace, options, profiler);
|
||||
} catch (error) {
|
||||
preserveWorkspaceForRecovery = error instanceof RecoverablePublicationError;
|
||||
@@ -403,16 +427,11 @@ export async function buildApplication(
|
||||
}
|
||||
|
||||
if (profiler !== undefined && profileOutputPath !== undefined) {
|
||||
const timing = profiler.finish();
|
||||
const output = await measureApplicationBuildOutput(outputDirectory);
|
||||
await writeApplicationBuildProfile(
|
||||
await writeOptionalApplicationBuildProfile({
|
||||
outputDirectory,
|
||||
profileOutputPath,
|
||||
createApplicationBuildProfile({
|
||||
output,
|
||||
target: process.env.VERCEL ? "vercel" : "local",
|
||||
timing,
|
||||
}),
|
||||
);
|
||||
profiler,
|
||||
});
|
||||
}
|
||||
|
||||
return outputDirectory;
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
interface BuildProfile {
|
||||
durationMs: number;
|
||||
kind: "eve-build-profile";
|
||||
phases: Array<{
|
||||
durationMs: number;
|
||||
name: string;
|
||||
}>;
|
||||
schemaVersion: 1;
|
||||
target: "local" | "vercel";
|
||||
}
|
||||
|
||||
interface BuildProfileReport {
|
||||
baseline: null | {
|
||||
label: string;
|
||||
profile: BuildProfile;
|
||||
};
|
||||
comparison: null | {
|
||||
durationMs: {
|
||||
baseline: number;
|
||||
current: number;
|
||||
delta: number;
|
||||
};
|
||||
phases: Array<{
|
||||
baselineDurationMs: null | number;
|
||||
currentDurationMs: null | number;
|
||||
deltaMs: null | number;
|
||||
name: string;
|
||||
}>;
|
||||
};
|
||||
current: {
|
||||
label: string;
|
||||
profile: BuildProfile;
|
||||
};
|
||||
sandboxPrewarm: "included" | "skipped";
|
||||
}
|
||||
|
||||
interface BuildProfileReportModule {
|
||||
createBuildProfileReport(input: {
|
||||
appLabel: string;
|
||||
baselineLabel?: string;
|
||||
baselineProfile?: BuildProfile | null;
|
||||
currentLabel?: string;
|
||||
currentProfile: BuildProfile;
|
||||
requiredCurrentPhases?: string[];
|
||||
sandboxPrewarm?: "included" | "skipped";
|
||||
}): BuildProfileReport;
|
||||
renderBuildProfileReportMarkdown(report: BuildProfileReport): string;
|
||||
renderBuildProfileTimingLog(report: BuildProfileReport): string;
|
||||
}
|
||||
|
||||
async function loadBuildProfileReportModule(): Promise<BuildProfileReportModule> {
|
||||
const moduleUrl = new URL("../../../scripts/build-profile-report.mjs", import.meta.url);
|
||||
|
||||
return (await import(moduleUrl.href)) as BuildProfileReportModule;
|
||||
}
|
||||
|
||||
function createProfile(input: {
|
||||
durationMs: number;
|
||||
phases: BuildProfile["phases"];
|
||||
}): BuildProfile {
|
||||
return {
|
||||
durationMs: input.durationMs,
|
||||
kind: "eve-build-profile",
|
||||
phases: input.phases,
|
||||
schemaVersion: 1,
|
||||
target: "vercel",
|
||||
};
|
||||
}
|
||||
|
||||
describe("build profile report", () => {
|
||||
it("renders a phase-by-phase comparison against a baseline profile", async () => {
|
||||
const { createBuildProfileReport, renderBuildProfileReportMarkdown } =
|
||||
await loadBuildProfileReportModule();
|
||||
const report = createBuildProfileReport({
|
||||
appLabel: "apps/fixtures/weather-agent",
|
||||
baselineLabel: "main (abc1234)",
|
||||
baselineProfile: createProfile({
|
||||
durationMs: 1_000,
|
||||
phases: [
|
||||
{ durationMs: 600, name: "nitro.app.bundle" },
|
||||
{ durationMs: 200, name: "workflow.emit" },
|
||||
],
|
||||
}),
|
||||
currentProfile: createProfile({
|
||||
durationMs: 1_250,
|
||||
phases: [
|
||||
{ durationMs: 700, name: "nitro.app.bundle" },
|
||||
{ durationMs: 300, name: "workflow.emit" },
|
||||
{ durationMs: 40, name: "output.publish" },
|
||||
],
|
||||
}),
|
||||
sandboxPrewarm: "skipped",
|
||||
});
|
||||
const markdown = renderBuildProfileReportMarkdown(report);
|
||||
|
||||
expect(report.comparison?.durationMs).toEqual({
|
||||
baseline: 1_000,
|
||||
current: 1_250,
|
||||
delta: 250,
|
||||
});
|
||||
expect(report.comparison?.phases).toEqual([
|
||||
{
|
||||
baselineDurationMs: 600,
|
||||
currentDurationMs: 700,
|
||||
deltaMs: 100,
|
||||
name: "nitro.app.bundle",
|
||||
},
|
||||
{
|
||||
baselineDurationMs: 200,
|
||||
currentDurationMs: 300,
|
||||
deltaMs: 100,
|
||||
name: "workflow.emit",
|
||||
},
|
||||
{
|
||||
baselineDurationMs: null,
|
||||
currentDurationMs: 40,
|
||||
deltaMs: null,
|
||||
name: "output.publish",
|
||||
},
|
||||
]);
|
||||
expect(markdown).toContain("## Build Timing: `apps/fixtures/weather-agent`");
|
||||
expect(markdown).toContain("sandbox template prewarm skipped");
|
||||
expect(markdown).toContain(
|
||||
"Build pipeline: 1.00 s -> **1.25 s** (+250.0 ms) vs `main (abc1234)`.",
|
||||
);
|
||||
expect(markdown).toContain("<details>");
|
||||
expect(markdown).toContain("<summary>Detailed phase timings vs `main (abc1234)`</summary>");
|
||||
expect(markdown).toContain("| `nitro.app.bundle` | 600.0 ms | 700.0 ms | +100.0 ms |");
|
||||
expect(markdown).toContain("| `output.publish` | — | 40.0 ms | — |");
|
||||
});
|
||||
|
||||
it("renders the current profile without a baseline", async () => {
|
||||
const { createBuildProfileReport, renderBuildProfileReportMarkdown } =
|
||||
await loadBuildProfileReportModule();
|
||||
const report = createBuildProfileReport({
|
||||
appLabel: "apps/fixtures/weather-agent",
|
||||
currentProfile: createProfile({
|
||||
durationMs: 500,
|
||||
phases: [{ durationMs: 450, name: "nitro.flow.bundle" }],
|
||||
}),
|
||||
});
|
||||
const markdown = renderBuildProfileReportMarkdown(report);
|
||||
|
||||
expect(report.baseline).toBeNull();
|
||||
expect(report.comparison).toBeNull();
|
||||
expect(markdown).toContain("Current build pipeline: **500.0 ms** (target: `vercel`).");
|
||||
expect(markdown).toContain("deployable Vercel build with sandbox template prewarm included");
|
||||
expect(markdown).toContain("No baseline build profile was supplied");
|
||||
expect(markdown).toContain("<details>");
|
||||
expect(markdown).toContain("<summary>Detailed phase timings</summary>");
|
||||
expect(markdown).toContain("| `nitro.flow.bundle` | 450.0 ms |");
|
||||
});
|
||||
|
||||
it("requires prewarm when a deployable-build regression check requests it", async () => {
|
||||
const { createBuildProfileReport } = await loadBuildProfileReportModule();
|
||||
|
||||
expect(() =>
|
||||
createBuildProfileReport({
|
||||
appLabel: "apps/fixtures/weather-agent",
|
||||
currentProfile: createProfile({
|
||||
durationMs: 500,
|
||||
phases: [{ durationMs: 450, name: "nitro.flow.bundle" }],
|
||||
}),
|
||||
requiredCurrentPhases: ["sandbox.prewarm"],
|
||||
}),
|
||||
).toThrow('Current build profile is missing required phase "sandbox.prewarm".');
|
||||
});
|
||||
|
||||
it("renders only the build time for E2E logs without copying unrecognized profile fields", async () => {
|
||||
const { createBuildProfileReport, renderBuildProfileTimingLog } =
|
||||
await loadBuildProfileReportModule();
|
||||
const profile = {
|
||||
...createProfile({
|
||||
durationMs: 500,
|
||||
phases: [{ durationMs: 450, name: "nitro.flow.bundle" }],
|
||||
}),
|
||||
VERCEL_OIDC_TOKEN: "must-not-appear-in-summary",
|
||||
};
|
||||
const report = createBuildProfileReport({
|
||||
appLabel: "e2e/fixtures/agent-tools-sandbox",
|
||||
currentProfile: profile,
|
||||
});
|
||||
const output = renderBuildProfileTimingLog(report);
|
||||
|
||||
expect(output).toBe(
|
||||
"eve build (e2e/fixtures/agent-tools-sandbox): 500.0 ms (sandbox template prewarm included).",
|
||||
);
|
||||
expect(output).not.toContain("must-not-appear-in-summary");
|
||||
expect(JSON.stringify(report)).not.toContain("must-not-appear-in-summary");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,392 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const BUILD_PROFILE_KIND = "eve-build-profile";
|
||||
const BUILD_PROFILE_SCHEMA_VERSION = 1;
|
||||
const BUILD_PROFILE_REPORT_KIND = "eve-build-profile-report";
|
||||
const BUILD_PROFILE_REPORT_SCHEMA_VERSION = 1;
|
||||
|
||||
function isRecord(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readDuration(value, label) {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
||||
throw new Error(`${label} must be a non-negative finite number.`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function readBuildProfile(value, sourceLabel) {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error(`${sourceLabel} must be a JSON object.`);
|
||||
}
|
||||
|
||||
if (value.kind !== BUILD_PROFILE_KIND) {
|
||||
throw new Error(`${sourceLabel} must have kind "${BUILD_PROFILE_KIND}".`);
|
||||
}
|
||||
|
||||
if (value.schemaVersion !== BUILD_PROFILE_SCHEMA_VERSION) {
|
||||
throw new Error(
|
||||
`${sourceLabel} must use schema version ${BUILD_PROFILE_SCHEMA_VERSION}, received ${String(value.schemaVersion)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (value.target !== "local" && value.target !== "vercel") {
|
||||
throw new Error(`${sourceLabel} must have target "local" or "vercel".`);
|
||||
}
|
||||
|
||||
if (!Array.isArray(value.phases)) {
|
||||
throw new Error(`${sourceLabel}.phases must be an array.`);
|
||||
}
|
||||
|
||||
const phaseNames = new Set();
|
||||
const phases = value.phases.map((phase, index) => {
|
||||
if (!isRecord(phase) || typeof phase.name !== "string" || phase.name.length === 0) {
|
||||
throw new Error(`${sourceLabel}.phases[${index}].name must be a non-empty string.`);
|
||||
}
|
||||
|
||||
if (phaseNames.has(phase.name)) {
|
||||
throw new Error(`${sourceLabel}.phases must not contain duplicate phase names.`);
|
||||
}
|
||||
phaseNames.add(phase.name);
|
||||
|
||||
return {
|
||||
durationMs: readDuration(phase.durationMs, `${sourceLabel}.phases[${index}].durationMs`),
|
||||
name: phase.name,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
durationMs: readDuration(value.durationMs, `${sourceLabel}.durationMs`),
|
||||
phases,
|
||||
target: value.target,
|
||||
};
|
||||
}
|
||||
|
||||
function formatDuration(durationMs) {
|
||||
if (durationMs < 1_000) {
|
||||
return `${durationMs.toFixed(1)} ms`;
|
||||
}
|
||||
|
||||
return `${(durationMs / 1_000).toFixed(2)} s`;
|
||||
}
|
||||
|
||||
function formatSignedDuration(durationMs) {
|
||||
if (durationMs === 0) {
|
||||
return "0.0 ms";
|
||||
}
|
||||
|
||||
return `${durationMs > 0 ? "+" : "-"}${formatDuration(Math.abs(durationMs))}`;
|
||||
}
|
||||
|
||||
function formatMarkdownCode(value) {
|
||||
return String(value).replaceAll("`", "\\`");
|
||||
}
|
||||
|
||||
function createPhaseComparison(currentProfile, baselineProfile) {
|
||||
const currentPhases = new Map(currentProfile.phases.map((phase) => [phase.name, phase]));
|
||||
const baselinePhases = new Map(baselineProfile.phases.map((phase) => [phase.name, phase]));
|
||||
const phaseNames = [
|
||||
...currentProfile.phases.map((phase) => phase.name),
|
||||
...baselineProfile.phases
|
||||
.map((phase) => phase.name)
|
||||
.filter((phaseName) => !currentPhases.has(phaseName)),
|
||||
];
|
||||
|
||||
return phaseNames.map((name) => {
|
||||
const current = currentPhases.get(name) ?? null;
|
||||
const baseline = baselinePhases.get(name) ?? null;
|
||||
|
||||
return {
|
||||
baselineDurationMs: baseline?.durationMs ?? null,
|
||||
currentDurationMs: current?.durationMs ?? null,
|
||||
deltaMs:
|
||||
current === null || baseline === null ? null : current.durationMs - baseline.durationMs,
|
||||
name,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a stable, CI-oriented view of one or two `eve build --profile` files.
|
||||
*/
|
||||
export function createBuildProfileReport(input) {
|
||||
const current = readBuildProfile(input.currentProfile, "Current build profile");
|
||||
const baseline =
|
||||
input.baselineProfile === undefined || input.baselineProfile === null
|
||||
? null
|
||||
: readBuildProfile(input.baselineProfile, "Baseline build profile");
|
||||
|
||||
if (baseline !== null && baseline.target !== current.target) {
|
||||
throw new Error(
|
||||
`Build profile targets must match: baseline is "${baseline.target}" and current is "${current.target}".`,
|
||||
);
|
||||
}
|
||||
|
||||
const requiredCurrentPhases = input.requiredCurrentPhases ?? [];
|
||||
const currentPhaseNames = new Set(current.phases.map((phase) => phase.name));
|
||||
for (const phaseName of requiredCurrentPhases) {
|
||||
if (!currentPhaseNames.has(phaseName)) {
|
||||
throw new Error(`Current build profile is missing required phase "${phaseName}".`);
|
||||
}
|
||||
}
|
||||
|
||||
const sandboxPrewarm = input.sandboxPrewarm ?? "included";
|
||||
if (sandboxPrewarm !== "included" && sandboxPrewarm !== "skipped") {
|
||||
throw new Error('sandboxPrewarm must be "included" or "skipped".');
|
||||
}
|
||||
|
||||
return {
|
||||
appLabel: input.appLabel,
|
||||
baseline:
|
||||
baseline === null
|
||||
? null
|
||||
: {
|
||||
label: input.baselineLabel ?? "baseline",
|
||||
profile: baseline,
|
||||
},
|
||||
comparison:
|
||||
baseline === null
|
||||
? null
|
||||
: {
|
||||
durationMs: {
|
||||
baseline: baseline.durationMs,
|
||||
current: current.durationMs,
|
||||
delta: current.durationMs - baseline.durationMs,
|
||||
},
|
||||
phases: createPhaseComparison(current, baseline),
|
||||
},
|
||||
current: {
|
||||
label: input.currentLabel ?? "current",
|
||||
profile: current,
|
||||
},
|
||||
kind: BUILD_PROFILE_REPORT_KIND,
|
||||
sandboxPrewarm,
|
||||
schemaVersion: BUILD_PROFILE_REPORT_SCHEMA_VERSION,
|
||||
};
|
||||
}
|
||||
|
||||
function renderCurrentPhaseTable(profile) {
|
||||
return [
|
||||
"| Phase | Duration |",
|
||||
"| --- | ---: |",
|
||||
...profile.phases.map(
|
||||
(phase) => `| \`${formatMarkdownCode(phase.name)}\` | ${formatDuration(phase.durationMs)} |`,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function renderComparisonPhaseTable(comparison) {
|
||||
return [
|
||||
"| Phase | Baseline | Current | Delta |",
|
||||
"| --- | ---: | ---: | ---: |",
|
||||
...comparison.phases.map((phase) => {
|
||||
const baseline =
|
||||
phase.baselineDurationMs === null ? "—" : formatDuration(phase.baselineDurationMs);
|
||||
const current =
|
||||
phase.currentDurationMs === null ? "—" : formatDuration(phase.currentDurationMs);
|
||||
const delta = phase.deltaMs === null ? "—" : formatSignedDuration(phase.deltaMs);
|
||||
|
||||
return `| \`${formatMarkdownCode(phase.name)}\` | ${baseline} | ${current} | ${delta} |`;
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
/** Renders one compact timing report for a GitHub job summary or pull request comment. */
|
||||
export function renderBuildProfileReportMarkdown(report) {
|
||||
const current = report.current.profile;
|
||||
const lines = [
|
||||
`## Build Timing: \`${formatMarkdownCode(report.appLabel)}\``,
|
||||
"",
|
||||
"This is an informational timing measurement inside `eve build`, from preflight through publication. Output-size measurement and profile writing are excluded.",
|
||||
"",
|
||||
report.sandboxPrewarm === "skipped"
|
||||
? "**Benchmark mode:** Vercel bundling with sandbox template prewarm skipped. It is intentionally reproducible for Bundle Analysis, but not the full wall-clock time of a deployable Vercel build."
|
||||
: "**Build mode:** deployable Vercel build with sandbox template prewarm included.",
|
||||
"",
|
||||
];
|
||||
|
||||
if (report.comparison === null || report.baseline === null) {
|
||||
lines.push(
|
||||
`- Current build pipeline: **${formatDuration(current.durationMs)}** (target: \`${current.target}\`).`,
|
||||
"- No baseline build profile was supplied, so this report shows the current phases only.",
|
||||
"",
|
||||
"<details>",
|
||||
"<summary>Detailed phase timings</summary>",
|
||||
"",
|
||||
...renderCurrentPhaseTable(current),
|
||||
"",
|
||||
"</details>",
|
||||
);
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
const comparison = report.comparison;
|
||||
lines.push(
|
||||
`- Build pipeline: ${formatDuration(comparison.durationMs.baseline)} -> **${formatDuration(comparison.durationMs.current)}** (${formatSignedDuration(comparison.durationMs.delta)}) vs \`${formatMarkdownCode(report.baseline.label)}\`.`,
|
||||
"- Timing is informational: shared GitHub runners are too variable for a hard timing budget.",
|
||||
"",
|
||||
"<details>",
|
||||
`<summary>Detailed phase timings vs \`${formatMarkdownCode(report.baseline.label)}\`</summary>`,
|
||||
"",
|
||||
...renderComparisonPhaseTable(comparison),
|
||||
"",
|
||||
"</details>",
|
||||
);
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** Renders one deployable-build duration for an E2E job log. */
|
||||
export function renderBuildProfileTimingLog(report) {
|
||||
const current = report.current.profile;
|
||||
const buildMode =
|
||||
report.sandboxPrewarm === "skipped"
|
||||
? "sandbox template prewarm skipped"
|
||||
: "sandbox template prewarm included";
|
||||
|
||||
return `eve build (${report.appLabel}): ${formatDuration(current.durationMs)} (${buildMode}).`;
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
process.stdout.write(
|
||||
[
|
||||
"Usage: node ./scripts/build-profile-report.mjs --profile <path> [options]",
|
||||
"",
|
||||
"Options:",
|
||||
" --profile <path> Current eve build profile JSON",
|
||||
" --app-label <label> Application label shown in the report",
|
||||
" --baseline-profile <path> Optional baseline eve build profile JSON",
|
||||
" --baseline-label <label> Display label for the baseline profile",
|
||||
" --current-label <label> Display label for the current profile",
|
||||
" --sandbox-prewarm <mode> Whether prewarm was included or skipped (default: included)",
|
||||
" --output-format <format> Render markdown or a one-line build-time log (default: markdown)",
|
||||
" --require-phase <name> Require a phase in the current profile; repeatable",
|
||||
" --output-json <path> Write the JSON report to this file",
|
||||
" --output-markdown <path> Write the Markdown report to this file",
|
||||
" --help Show this help text",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
function parseArguments(argv) {
|
||||
const parsedArguments = {
|
||||
appLabel: "eve application",
|
||||
baselineLabel: "baseline",
|
||||
baselineProfilePath: null,
|
||||
currentLabel: "current",
|
||||
outputFormat: "markdown",
|
||||
outputJsonPath: null,
|
||||
outputMarkdownPath: null,
|
||||
profilePath: null,
|
||||
requiredCurrentPhases: [],
|
||||
sandboxPrewarm: "included",
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const argument = argv[index];
|
||||
|
||||
if (argument === "--help") {
|
||||
printUsage();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const value = argv[index + 1];
|
||||
if (value === undefined) {
|
||||
throw new Error(`Missing value for "${argument}".`);
|
||||
}
|
||||
|
||||
if (argument === "--profile") {
|
||||
parsedArguments.profilePath = value;
|
||||
} else if (argument === "--app-label") {
|
||||
parsedArguments.appLabel = value;
|
||||
} else if (argument === "--baseline-profile") {
|
||||
parsedArguments.baselineProfilePath = value;
|
||||
} else if (argument === "--baseline-label") {
|
||||
parsedArguments.baselineLabel = value;
|
||||
} else if (argument === "--current-label") {
|
||||
parsedArguments.currentLabel = value;
|
||||
} else if (argument === "--sandbox-prewarm") {
|
||||
if (value !== "included" && value !== "skipped") {
|
||||
throw new Error('The "--sandbox-prewarm" value must be "included" or "skipped".');
|
||||
}
|
||||
parsedArguments.sandboxPrewarm = value;
|
||||
} else if (argument === "--output-format") {
|
||||
if (value !== "build-time" && value !== "markdown") {
|
||||
throw new Error('The "--output-format" value must be "build-time" or "markdown".');
|
||||
}
|
||||
parsedArguments.outputFormat = value;
|
||||
} else if (argument === "--require-phase") {
|
||||
parsedArguments.requiredCurrentPhases.push(value);
|
||||
} else if (argument === "--output-json") {
|
||||
parsedArguments.outputJsonPath = value;
|
||||
} else if (argument === "--output-markdown") {
|
||||
parsedArguments.outputMarkdownPath = value;
|
||||
} else {
|
||||
throw new Error(`Unknown argument "${argument}".`);
|
||||
}
|
||||
|
||||
index += 1;
|
||||
}
|
||||
|
||||
if (parsedArguments.profilePath === null) {
|
||||
throw new Error('The "--profile" option is required.');
|
||||
}
|
||||
|
||||
return parsedArguments;
|
||||
}
|
||||
|
||||
async function readJson(path) {
|
||||
return JSON.parse(await readFile(resolve(path), "utf8"));
|
||||
}
|
||||
|
||||
async function writeOutputFile(path, contents) {
|
||||
await mkdir(dirname(resolve(path)), {
|
||||
recursive: true,
|
||||
});
|
||||
await writeFile(path, contents, "utf8");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArguments(process.argv.slice(2));
|
||||
const currentProfile = await readJson(args.profilePath);
|
||||
const baselineProfile =
|
||||
args.baselineProfilePath === null ? null : await readJson(args.baselineProfilePath);
|
||||
const report = createBuildProfileReport({
|
||||
appLabel: args.appLabel,
|
||||
baselineLabel: args.baselineLabel,
|
||||
baselineProfile,
|
||||
currentLabel: args.currentLabel,
|
||||
currentProfile,
|
||||
requiredCurrentPhases: args.requiredCurrentPhases,
|
||||
sandboxPrewarm: args.sandboxPrewarm,
|
||||
});
|
||||
const output =
|
||||
args.outputFormat === "build-time"
|
||||
? renderBuildProfileTimingLog(report)
|
||||
: renderBuildProfileReportMarkdown(report);
|
||||
|
||||
if (args.outputJsonPath !== null) {
|
||||
await writeOutputFile(args.outputJsonPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
}
|
||||
|
||||
if (args.outputMarkdownPath !== null) {
|
||||
await writeOutputFile(args.outputMarkdownPath, `${output}\n`);
|
||||
}
|
||||
|
||||
if (args.outputMarkdownPath === null) {
|
||||
process.stdout.write(`${output}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
const executedScriptPath = process.argv[1] ? resolve(process.argv[1]) : null;
|
||||
const moduleScriptPath = resolve(fileURLToPath(import.meta.url));
|
||||
|
||||
if (executedScriptPath !== null && executedScriptPath === moduleScriptPath) {
|
||||
await main();
|
||||
}
|
||||
Reference in New Issue
Block a user