Files
orca/src/shared/gitlab-job-log-excerpt.test.ts
T
NeilandOrca 0927b9c156 fix(gitlab): load pipeline job traces in the Checks side panel (#7732) (#12266)
* test(repro): demonstrate #7732 GitLab pipeline job details never load in Checks panel

Co-authored-by: Orca <help@stably.ai>

* fix(gitlab): load pipeline job traces in the Checks side panel (#7732)

Expanding a GitLab pipeline job in the Checks panel always showed
"No inline details are available for this check.": the mapper dropped the
numeric job id, `PRCheckDetail` had nowhere to carry it, and every consumer
called the GitHub check-runs API, which returns null for a GitLab job.

- carry `gitlabJobId` on `PRCheckDetail` and add the `gitlab-job:` branch to
  all three identity ladders (panel rows, editor tabs, fix-prompt keys) so
  same-stage jobs with no web_url stop colliding
- add a runtime-routed trace client so SSH/remote workspaces work, not just
  local IPC, and thread the MR's `projectRef` for fork pipelines
- bound the trace in main via the existing `sliceCheckLogTail` (now shared,
  not GitHub-only) so a multi-megabyte CI log never crosses the 1 MB
  transport frame cap; strip ANSI/section markers up to the CR only, which
  keeps each section's visible header and command echo
- render the excerpt inline instead of "Log tail available in full details."
- feed GitLab traces to "Fix with AI", which previously sent bare check names
- skip the fetch for jobs that cannot have a trace (created/manual/skipped)
  so GitLab's 404 does not replace the benign empty state, and re-arm a
  failed load when the job's state changes since the panel has no retry

Co-authored-by: Orca <help@stably.ai>

* fix(gitlab): treat a missing job log as an empty log, not an error (#7732)

Round-1 review follow-up.

- a job canceled before it started (or whose log was erased/expired) is
  `completed`/`cancelled`, so the panel fetched its trace, GitLab answered 404,
  and `classifyGlabError`'s issue-edit copy ("Issue not found — it may have been
  deleted.") landed verbatim on the auto-expanded check row; main now maps that
  404 to an empty trace so the row keeps its benign empty state
- keep a missing project a real error (GitLab masks unauthorized projects as
  404) and add `classifyJobLogError` so 403/unknown failures stop borrowing
  issue-edit wording on a job-log read
- broaden the empty-log copy in all five catalogs: it now covers erased and
  expired logs, not only jobs that never ran
- e2e: derive the repro screenshot dir from `process.cwd()` (or an env
  override) instead of a hardcoded POSIX path to a throwaway worktree
- bound the raw trace before the ANSI/section passes so a multi-megabyte log
  is not scanned in full on the main-process event loop
- drop the redundant `if (repo)` in `handleFixChecksWithAI` and the now-dead
  "Log tail available in full details." catalog entry

Co-authored-by: Orca <help@stably.ai>

* fix(gitlab): address review — project ref on reload, retry re-arm, IPC timeout

- Carry the MR's GitLab project ref on the check-details tab so reloading a
  fork/cross-project job tab fetches the trace from the pipeline's own project.
- Re-arm the sidebar retry when a details load resolves to null, not only when
  it throws; a detail-less row otherwise never retried after the job moved on.
- Bound the local `gl.jobTrace` IPC call with the same 30s timeout the runtime
  RPC path uses — glab runs without a subprocess timeout in main.
- Document that the trace 404 -> empty-log mapping is deliberately broad.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-08-03 22:48:37 -07:00

91 lines
3.4 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { gitLabJobTraceToLogExcerpt, toGitLabJobLogExcerptResult } from './gitlab-job-log-excerpt'
import { PR_CHECK_LOG_TAIL_BYTES } from './check-job-log-tail-slice'
const CSI_ERASE = '\u001b[0K'
const RESET = '\u001b[0;m'
const CR = '\r'
describe('gitLabJobTraceToLogExcerpt', () => {
it('keeps the visible header of a section marker and drops the marker itself', () => {
const trace = [
`section_start:1699000000:build_script${CR}${CSI_ERASE}$ pnpm build`,
'built in 3s',
`section_end:1699000030:build_script${CR}${CSI_ERASE}`
].join('\n')
const excerpt = gitLabJobTraceToLogExcerpt(trace)
expect(excerpt).toContain('$ pnpm build')
expect(excerpt).toContain('built in 3s')
expect(excerpt).not.toContain('section_start')
expect(excerpt).not.toContain('section_end')
})
it('strips ANSI colour sequences', () => {
const excerpt = gitLabJobTraceToLogExcerpt(`${RESET}ERROR: Job failed${RESET}`)
expect(excerpt).toBe('ERROR: Job failed')
expect(excerpt).not.toContain('[0;m')
})
it('splits carriage-return progress output so the line tail still applies', () => {
const progress = Array.from({ length: 5_000 }, (_, index) => `downloading ${index}%`).join('\r')
const excerpt = gitLabJobTraceToLogExcerpt(`${progress}\nERROR: Job failed: exit code 1`)
expect(excerpt).toContain('ERROR: Job failed: exit code 1')
expect(excerpt).not.toContain('downloading 0%')
expect(excerpt.length).toBeLessThanOrEqual(PR_CHECK_LOG_TAIL_BYTES)
})
it('bounds a multi-megabyte trace below the 1 MB runtime transport frame cap', () => {
const huge = Array.from(
{ length: 60_000 },
(_, index) => `line ${index} ${'x'.repeat(60)}`
).join('\n')
const excerpt = gitLabJobTraceToLogExcerpt(huge)
expect(huge.length).toBeGreaterThan(1024 * 1024)
expect(Buffer.byteLength(excerpt, 'utf8')).toBeLessThanOrEqual(PR_CHECK_LOG_TAIL_BYTES)
expect(excerpt).toContain('line 59999')
})
it('preserves earlier error context buried under trailing noise', () => {
const trace = [
...Array.from({ length: 150 }, (_, index) => `Installing package ${index}`),
'ERROR: Job failed: exit code 1',
...Array.from({ length: 90 }, (_, index) => `Uploading artifact ${index}`)
].join('\n')
const excerpt = gitLabJobTraceToLogExcerpt(trace)
expect(excerpt).toContain('ERROR: Job failed: exit code 1')
expect(excerpt).toContain('Uploading artifact 89')
})
it('bounds the raw trace before stripping so a huge log is not scanned in full', () => {
const filler = `${'x'.repeat(200)}\n`.repeat(6_000) // ~1.2 MB, above the raw cap
const excerpt = gitLabJobTraceToLogExcerpt(`head marker\n${filler}ERROR: Job failed`)
expect(excerpt).toContain('ERROR: Job failed')
expect(excerpt).not.toContain('head marker')
})
it('returns an empty excerpt for a job that never produced a log', () => {
expect(gitLabJobTraceToLogExcerpt('')).toBe('')
expect(gitLabJobTraceToLogExcerpt(` \n${CSI_ERASE}\n `)).toBe('')
})
})
describe('toGitLabJobLogExcerptResult', () => {
it('bounds a successful trace and passes errors through untouched', () => {
const bounded = toGitLabJobLogExcerptResult({ ok: true, trace: `${RESET}done` })
expect(bounded).toEqual({ ok: true, trace: 'done' })
const failure = { ok: false, error: '403 Forbidden' } as const
expect(toGitLabJobLogExcerptResult(failure)).toBe(failure)
})
})