diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index e572613e029..7f10289b51f 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -526,6 +526,165 @@ jobs: with: ref: refs/tags/${{ needs.cut.outputs.tag }} + terminal-rendering-golden: + needs: cut + if: needs.cut.outputs.should_release == 'true' + name: terminal rendering golden ${{ matrix.platform }} + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + env: + NODE_OPTIONS: --max-old-space-size=4096 + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + platform: linux + - os: macos-15 + platform: mac + - os: windows-latest + platform: windows + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: refs/tags/${{ needs.cut.outputs.tag }} + + - name: Install native build tools + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y build-essential python3 xvfb + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: package.json + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + with: + run_install: false + + # Why: Linux terminal golden E2E uses the same native install path as + # release CI, which needs pnpm to bypass its non-executable gyp_main.py. + - name: Use external node-gyp to avoid pnpm's bundled copy (Linux only) + if: runner.os == 'Linux' + run: | + npm install -g node-gyp@11.5.0 + echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build Electron app for terminal rendering golden + run: npx electron-vite build --mode e2e + + - name: Run terminal rendering golden on Linux + if: runner.os == 'Linux' + run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden + + - name: Run terminal rendering golden on macOS + if: runner.os == 'macOS' + run: env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden + + - name: Run terminal rendering golden on Windows + if: runner.os == 'Windows' + shell: pwsh + run: | + $env:SKIP_BUILD = '1' + $env:ORCA_E2E_FORWARD_APP_LOGS = '1' + pnpm run test:e2e:terminal-rendering-golden + + - name: Upload Playwright traces + if: failure() + uses: actions/upload-artifact@v7 + with: + name: terminal-rendering-golden-${{ matrix.platform }}-playwright-traces + path: test-results/ + retention-days: 7 + if-no-files-found: ignore + + # Why: these broader terminal rendering repros are useful release evidence, + # but they include heavier app-like flows and must not block publishing. + terminal-rendering-release-evidence: + needs: cut + if: needs.cut.outputs.should_release == 'true' + continue-on-error: true + name: terminal rendering release evidence ${{ matrix.platform }} + runs-on: ${{ matrix.os }} + timeout-minutes: 35 + env: + NODE_OPTIONS: --max-old-space-size=4096 + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + platform: linux + - os: macos-15 + platform: mac + - os: windows-latest + platform: windows + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: refs/tags/${{ needs.cut.outputs.tag }} + + - name: Install native build tools + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y build-essential python3 xvfb + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: package.json + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + with: + run_install: false + + # Why: keep the non-blocking evidence lane on the same Linux native + # install path as the blocking golden and release build jobs. + - name: Use external node-gyp to avoid pnpm's bundled copy (Linux only) + if: runner.os == 'Linux' + run: | + npm install -g node-gyp@11.5.0 + echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build Electron app for terminal rendering evidence + run: npx electron-vite build --mode e2e + + - name: Run terminal rendering evidence on Linux + if: runner.os == 'Linux' + run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-release-evidence + + - name: Run terminal rendering evidence on macOS + if: runner.os == 'macOS' + run: env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-release-evidence + + - name: Run terminal rendering evidence on Windows + if: runner.os == 'Windows' + shell: pwsh + run: | + $env:SKIP_BUILD = '1' + $env:ORCA_E2E_FORWARD_APP_LOGS = '1' + pnpm run test:e2e:terminal-rendering-release-evidence + + - name: Upload Playwright traces + if: failure() + uses: actions/upload-artifact@v7 + with: + name: terminal-rendering-release-evidence-${{ matrix.platform }}-playwright-traces + path: test-results/ + retention-days: 7 + if-no-files-found: ignore + build: needs: - cut @@ -771,6 +930,7 @@ jobs: needs: - cut - build + - terminal-rendering-golden runs-on: ubuntu-latest permissions: contents: write diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index e5da9490494..c1359c6a36e 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -25,6 +25,8 @@ describe('Electron runtime package contract', () => { 'build:mac:release', 'build:linux', 'test:e2e', + 'test:e2e:terminal-rendering-golden', + 'test:e2e:terminal-rendering-release-evidence', 'test:e2e:headful' ] @@ -217,11 +219,18 @@ describe('Electron runtime package contract', () => { const goldenWorkflow = parse( readFileSync(join(projectDir, '.github/workflows/golden-e2e-experiment.yml'), 'utf8') ) + const releaseWorkflow = parse( + readFileSync(join(projectDir, '.github/workflows/release-cut.yml'), 'utf8') + ) const steps = goldenWorkflow.jobs['golden-e2e'].steps const linuxRunStep = steps.find((step) => step.name === 'Run golden E2E tests on Linux') const macRunStep = steps.find((step) => step.name === 'Run golden E2E tests on macOS') const windowsRunStep = steps.find((step) => step.name === 'Run golden E2E tests on Windows') const pullRequestPaths = goldenWorkflow.on.pull_request.paths + const releaseGoldenJob = releaseWorkflow.jobs['terminal-rendering-golden'] + const releaseEvidenceJob = releaseWorkflow.jobs['terminal-rendering-release-evidence'] + const releaseBuildNeeds = releaseWorkflow.jobs.build.needs + const publishReleaseNeeds = releaseWorkflow.jobs['publish-release'].needs expect(packageScripts['test:e2e:terminal-rendering-golden']).toContain( '@terminal-rendering-golden' @@ -232,11 +241,36 @@ describe('Electron runtime package contract', () => { expect(packageScripts['test:e2e:terminal-rendering-golden']).not.toContain( 'terminal-long-table-scroll-restore.spec.ts' ) + expect(packageScripts['test:e2e:terminal-rendering-release-evidence']).toContain( + 'terminal-opencode-emoji-table-rendering.spec.ts' + ) + expect(packageScripts['test:e2e:terminal-rendering-release-evidence']).toContain( + 'terminal-long-table-scroll-restore.spec.ts' + ) for (const runStep of [linuxRunStep, macRunStep, windowsRunStep]) { expect(runStep.run).toContain('pnpm run test:e2e:terminal-rendering-golden') } expect(pullRequestPaths).toContain('tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts') expect(pullRequestPaths).toContain('tests/e2e/fixtures/terminal-emoji-table.md') expect(pullRequestPaths).toContain('src/renderer/src/lib/pane-manager/**') + expect(releaseBuildNeeds).not.toContain('terminal-rendering-golden') + expect(releaseBuildNeeds).not.toContain('terminal-rendering-release-evidence') + expect(publishReleaseNeeds).toContain('terminal-rendering-golden') + expect(publishReleaseNeeds).toContain('build') + expect(publishReleaseNeeds).not.toContain('terminal-rendering-release-evidence') + expect(releaseGoldenJob['continue-on-error']).toBeUndefined() + expect(releaseGoldenJob.strategy.matrix.include.map(({ platform }) => platform).sort()).toEqual( + ['linux', 'mac', 'windows'] + ) + expect(releaseGoldenJob.steps.map((step) => step.run ?? '')).toContain( + 'xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden' + ) + expect(releaseEvidenceJob['continue-on-error']).toBe(true) + expect( + releaseEvidenceJob.strategy.matrix.include.map(({ platform }) => platform).sort() + ).toEqual(['linux', 'mac', 'windows']) + expect(releaseEvidenceJob.steps.map((step) => step.run ?? '')).toContain( + 'xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-release-evidence' + ) }) }) diff --git a/package.json b/package.json index b01b8403b1b..278d81aa3a2 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "build:linux": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --linux", "test:e2e": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headless", "test:e2e:terminal-rendering-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts --grep @terminal-rendering-golden --config tests/playwright.config.ts --project electron-headless --workers=1", + "test:e2e:terminal-rendering-release-evidence": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-opencode-emoji-table-rendering.spec.ts tests/e2e/terminal-long-table-scroll-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=2", "test:e2e:terminal-perf": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-typing-latency.spec.ts tests/e2e/terminal-foreground-redraw-freeze.spec.ts tests/e2e/terminal-output-scheduler.spec.ts tests/e2e/terminal-hidden-tui-visual-restore.spec.ts tests/e2e/artificial-opencode-terminal-load.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=2", "test:e2e:terminal-perf:scale": "pnpm run ensure:electron-runtime && node config/scripts/run-terminal-scale-perf-e2e.mjs", "test:e2e:terminal-perf:scale:report": "pnpm run ensure:electron-runtime && node config/scripts/run-terminal-scale-perf-report-gate.mjs", diff --git a/tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts b/tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts index e9f195cca07..365d81d5eec 100644 --- a/tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts +++ b/tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts @@ -269,28 +269,37 @@ async function readVisibleSingerRowGeometry(page: Page): Promise<{ throw new Error('Active terminal DOM unavailable') } const screenRect = screen.getBoundingClientRect() - if (!rows) { - const buffer = pane.terminal.buffer.active - const line = Array.from( - { length: pane.terminal.rows }, - (_, row) => buffer.getLine(buffer.viewportY + row)?.translateToString(true) ?? '' + const buffer = pane.terminal.buffer.active + const visibleLine = Array.from( + { length: pane.terminal.rows }, + (_, row) => buffer.getLine(buffer.viewportY + row)?.translateToString(true) ?? '' + ).find((text) => text.includes('Singer')) + const scrollbackLine = + visibleLine ?? + Array.from( + { length: buffer.baseY + buffer.length }, + (_, index) => buffer.getLine(index)?.translateToString(true) ?? '' ).find((text) => text.includes('Singer')) - if (!line) { - throw new Error('Singer row buffer line unavailable') - } - const cellWidth = pane.terminal._core?._renderService?.dimensions?.css?.cell?.width ?? 0 - return { - cols: pane.terminal.cols, - screenRight: screenRect.right, - rowRight: screenRect.left + pane.terminal.cols * cellWidth, - rowText: line - } + if (!scrollbackLine) { + throw new Error('Singer row buffer line unavailable') + } + const cellWidth = pane.terminal._core?._renderService?.dimensions?.css?.cell?.width ?? 0 + const bufferGeometry = { + cols: pane.terminal.cols, + screenRight: screenRect.right, + rowRight: screenRect.left + pane.terminal.cols * cellWidth, + rowText: scrollbackLine + } + if (!rows) { + return bufferGeometry } const row = Array.from(rows.children).find((element) => (element.textContent ?? '').includes('Singer') ) as HTMLElement | undefined if (!row) { - throw new Error('Singer row DOM unavailable') + // Why: xterm can repaint DOM rows between scroll and measurement; the + // terminal buffer still gives a stable right-edge bound for the golden. + return bufferGeometry } const rowRect = row.getBoundingClientRect() return {