diff --git a/.github/workflows/build-image.yaml b/.github/workflows/build-image.yaml index 2f41db9a..4783ce28 100644 --- a/.github/workflows/build-image.yaml +++ b/.github/workflows/build-image.yaml @@ -1,51 +1,81 @@ -name: Build runner image +name: Build & Push runner image -# 用 runner-versions.yaml 里的每个底座版本构建自有 runner 镜像。 -# 底座版本升级只改 .github/runner-versions.yaml,不改 Dockerfile。 -# 默认只构建(不推送);配置好 registry 后取消 push 相关步骤的注释。 +# 手动触发:构建并推送自有 runner 镜像到 SWR。 +# 不在 push 时自动跑(省资源、避免堆镜像 tag),也不创建 git tag。 +# 触发:Actions 页面 → Run workflow,可选填 runner_version / image_tag。 on: - push: - tags: - - 'v*' workflow_dispatch: + inputs: + runner_version: + description: 'runner base version (default: latest in .github/runner-versions.yaml)' + type: string + required: false + image_tag: + description: 'image tag (default: --)' + type: string + required: false + +env: + REGISTRY: swr.cn-southwest-2.myhuaweicloud.com + IMAGE_NAME: swr.cn-southwest-2.myhuaweicloud.com/modelfoundry/runner + BASE_REGISTRY: ghcr.io + +concurrency: + group: build-image-${{ github.ref }} + cancel-in-progress: false jobs: - versions: + resolve: runs-on: ubuntu-latest outputs: - matrix: ${{ steps.read.outputs.matrix }} + runner_version: ${{ steps.r.outputs.runner_version }} + image_tag: ${{ steps.r.outputs.image_tag }} steps: - uses: actions/checkout@v5 - - id: read + - id: r run: | - matrix=$(grep -E '^\s*-\s*"' .github/runner-versions.yaml \ - | sed 's/.*"\(.*\)".*/\1/' \ - | jq -R -s -c 'split("\n")[:-1]') - echo "matrix=${matrix}" >> "$GITHUB_OUTPUT" + rv="${{ inputs.runner_version }}" + if [ -z "$rv" ]; then + rv=$(grep -E '^\s*-\s*"' .github/runner-versions.yaml \ + | sed 's/.*"\(.*\)".*/\1/' | sort -V | tail -1) + fi + echo "runner_version=${rv}" >> "$GITHUB_OUTPUT" + + it="${{ inputs.image_tag }}" + if [ -z "$it" ]; then + short=$(printf '%s' "$GITHUB_SHA" | cut -c1-6) + branch=$(printf '%s' "${GITHUB_REF_NAME}" | sed -E 's#[^A-Za-z0-9_.-]#-#g') + it="${rv}-${branch}-${short}" + fi + it="${it:0:120}" + echo "image_tag=${it}" >> "$GITHUB_OUTPUT" + echo "runner_version=${rv} image_tag=${it}" build: - needs: versions + needs: resolve runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - runner_version: ${{ fromJson(needs.versions.outputs.matrix) }} steps: - uses: actions/checkout@v5 - - name: Build image for runner ${{ matrix.runner_version }} - run: | - docker build \ - --build-arg RUNNER_VERSION=${{ matrix.runner_version }} \ - -t runner-container-hooks:${{ matrix.runner_version }}-${{ github.ref_name }} \ - . - # - name: Log in to registry - # uses: docker/login-action@v3 - # with: - # registry: ${{ vars.REGISTRY }} - # username: ${{ secrets.REGISTRY_USERNAME }} - # password: ${{ secrets.REGISTRY_PASSWORD }} - # - name: Push image - # run: | - # docker tag runner-container-hooks:${{ matrix.runner_version }}-${{ github.ref_name }} \ - # ${{ vars.REGISTRY }}/runner:${{ matrix.runner_version }}-${{ github.ref_name }} - # docker push ${{ vars.REGISTRY }}/runner:${{ matrix.runner_version }}-${{ github.ref_name }} + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + + - name: Login to SWR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ secrets.SWR_USERNAME }} + password: ${{ secrets.SWR_PASSWORD }} + + - name: Build and push (linux/amd64, linux/arm64) + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + provenance: false + build-args: | + RUNNER_VERSION=${{ needs.resolve.outputs.runner_version }} + BASE_REGISTRY=${{ env.BASE_REGISTRY }} + tags: ${{ env.IMAGE_NAME }}:${{ needs.resolve.outputs.image_tag }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 792a21a5..1edb916d 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -42,9 +42,20 @@ git merge vX.Y.0 # 合并上游新版本 tag ## 自有 runner 镜像 -- 底座版本集中在 `.github/runner-versions.yaml`,升级 runner 只改此文件。 +由 GitHub Actions(`.github/workflows/build-image.yaml`)**手动触发**构建并推送到 SWR(替换原 Jenkins 流水线,不在 push 时自动跑,省资源)。 + +- 入口:Actions → **Build & Push runner image** → Run workflow(可选填 `runner_version`、`image_tag`)。 +- 底座版本集中在 `.github/runner-versions.yaml`,默认取**最新版**;手动运行时可指定。 - `Dockerfile` 通过 `--build-arg RUNNER_VERSION=` 接收底座版本。 -- 镜像 tag 编码两个维度:`-`,例如 `2.334.0-v0.8.1-ascend.1`。 + +镜像 tag 约定: + +| 场景 | 镜像 tag | 说明 | +|---|---|---| +| 生产 | `-vX.Y.Z-ascend.N` | 手动运行并指定 `image_tag`,不可变 | +| 测试 | `--<短sha>` | 不填 `image_tag` 时的默认值,可变可弃 | + +- 需要 secrets:`SWR_USERNAME`(含 region 前缀的完整用户名)、`SWR_PASSWORD`;镜像名与仓库见 workflow 的 `IMAGE_NAME`。 ## 回滚 diff --git a/package-lock.json b/package-lock.json index 719a2da2..ee0ae37d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "hooks", - "version": "0.8.0", + "version": "0.8.0-ascend.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hooks", - "version": "0.8.0", + "version": "0.8.0-ascend.1", "license": "MIT", "dependencies": { "eslint-plugin-jest": "^29.0.1" diff --git a/packages/docker/src/utils/index.spec.ts b/packages/docker/src/utils/index.spec.ts index 9bb5070e..88e43966 100644 --- a/packages/docker/src/utils/index.spec.ts +++ b/packages/docker/src/utils/index.spec.ts @@ -1,4 +1,9 @@ -import { optionsWithDockerEnvs, sanitize, fixArgs, checkEnvironment } from './index' +import { + optionsWithDockerEnvs, + sanitize, + fixArgs, + checkEnvironment +} from './index' describe('sanitize', () => { it('strips non-alphanumeric chars except underscore', () => { diff --git a/packages/k8s/src/hooks/prepare-job.ts b/packages/k8s/src/hooks/prepare-job.ts index d40b7019..19b90082 100644 --- a/packages/k8s/src/hooks/prepare-job.ts +++ b/packages/k8s/src/hooks/prepare-job.ts @@ -104,9 +104,10 @@ export async function prepareJob( // The boundary may be '"\nHeaders:' (real newline) or the literal // string ends before Headers — use the last '"' before 'Headers:' as fallback const headersIdx = raw.indexOf('Headers:') - const bodyEnd = headersIdx !== -1 - ? raw.lastIndexOf('"', headersIdx) // last " before Headers: - : raw.indexOf('"\nHeaders:') + const bodyEnd = + headersIdx !== -1 + ? raw.lastIndexOf('"', headersIdx) // last " before Headers: + : raw.indexOf('"\nHeaders:') if (bodyStart !== -1 && bodyEnd !== -1 && bodyEnd > bodyStart) { // Body content is a JSON string literal (without surrounding quotes). // Wrap it in quotes and JSON.parse to properly unescape \" \\ \n \t etc. diff --git a/packages/k8s/src/hooks/run-container-step.ts b/packages/k8s/src/hooks/run-container-step.ts index 16b8853f..7156d1f8 100644 --- a/packages/k8s/src/hooks/run-container-step.ts +++ b/packages/k8s/src/hooks/run-container-step.ts @@ -142,8 +142,8 @@ export async function runContainerStep( } } catch (error) { try { - const pod = await getPodByName(podName) - const terminatedErrors = getContainerTerminatedErrors(pod) + const fetchedPod = await getPodByName(podName) + const terminatedErrors = getContainerTerminatedErrors(fetchedPod) if (terminatedErrors.length > 0) { const details = await describePodFailure(podName) core.error( diff --git a/packages/k8s/src/hooks/run-script-step.ts b/packages/k8s/src/hooks/run-script-step.ts index 2867064d..a08aa323 100644 --- a/packages/k8s/src/hooks/run-script-step.ts +++ b/packages/k8s/src/hooks/run-script-step.ts @@ -2,7 +2,12 @@ import * as fs from 'fs' import * as core from '@actions/core' import { RunScriptStepArgs } from 'hooklib' -import { execCpFromPod, execCpToPod, execPodStep, execPodStepWithOutput } from '../k8s' +import { + execCpFromPod, + execCpToPod, + execPodStep, + execPodStepWithOutput +} from '../k8s' import { writeRunScript } from '../k8s/utils' import { JOB_CONTAINER_NAME } from './constants' import { dirname } from 'path' @@ -84,7 +89,10 @@ export async function runScriptStep( } } catch (err) { core.debug(`execPodStep failed: ${JSON.stringify(err)}`) - if (err instanceof Error && err.message.startsWith('failed to run script step')) { + if ( + err instanceof Error && + err.message.startsWith('failed to run script step') + ) { throw err } const message = (err as any)?.response?.body?.message || err diff --git a/packages/k8s/src/k8s/index.ts b/packages/k8s/src/k8s/index.ts index 52980457..d5da2256 100644 --- a/packages/k8s/src/k8s/index.ts +++ b/packages/k8s/src/k8s/index.ts @@ -300,7 +300,7 @@ export async function execPodStepWithOutput( // Ring buffer of the last N non-empty lines (cap to keep memory bounded). const buffer: string[] = [] - const push = (line: string) => { + const push = (line: string): void => { if (line.length === 0) return buffer.push(line) if (buffer.length > tailLines) buffer.shift() @@ -308,7 +308,7 @@ export async function execPodStepWithOutput( // Separate pending buffers per stream to prevent stdout/stderr interleaving. let pendingOut = '' - const ingestOut = (chunk: Buffer | string) => { + const ingestOut = (chunk: Buffer | string): void => { pendingOut += chunk.toString('utf8') const lines = pendingOut.split(/\r?\n/) pendingOut = lines.pop() ?? '' @@ -316,14 +316,14 @@ export async function execPodStepWithOutput( } let pendingErr = '' - const ingestErr = (chunk: Buffer | string) => { + const ingestErr = (chunk: Buffer | string): void => { pendingErr += chunk.toString('utf8') const lines = pendingErr.split(/\r?\n/) pendingErr = lines.pop() ?? '' for (const line of lines) push(line) } - const flushPending = () => { + const flushPending = (): void => { if (pendingOut) { push(pendingOut) pendingOut = '' @@ -682,7 +682,7 @@ export async function execCpFromPod( errStream, null, false, - async _status => { + async () => { if (errStream.size()) { reject( new Error( @@ -1493,6 +1493,7 @@ async function describePodWarningEvents(podName: string): Promise { // until the timeout, not terminate early. This function always returns [] as a // result, but is kept so checkUnrecoverableErrors compiles and the dedup logic // remains intact for future use. +// eslint-disable-next-line @typescript-eslint/no-unused-vars -- signature kept for callers; always returns [] export function getPodConditionErrors(_pod: k8s.V1Pod): string[] { return [] } @@ -1610,7 +1611,7 @@ export async function waitForPodPhases( try { await backOffManager.backOff() - } catch (error) { + } catch { // BackOffManager throws "backoff timeout" when maxTimeSeconds is exceeded. // Don't surface that bare message: collect diagnostics first so the user // can see WHY the pod never became ready. diff --git a/packages/k8s/src/k8s/utils/index.spec.ts b/packages/k8s/src/k8s/utils/index.spec.ts index 568fb3cc..62413958 100644 --- a/packages/k8s/src/k8s/utils/index.spec.ts +++ b/packages/k8s/src/k8s/utils/index.spec.ts @@ -33,13 +33,17 @@ describe('generateContainerName', () => { expect(generateContainerName('postgres:latest')).toBe('postgres') expect(generateContainerName('postgres')).toBe('postgres') expect( - generateContainerName('public.ecr.aws/url/with/multiple/slashes/postgres:latest') + generateContainerName( + 'public.ecr.aws/url/with/multiple/slashes/postgres:latest' + ) ).toBe('postgres') }) it('throws on invalid image string', () => { expect(() => generateContainerName(':latest')).toThrow() - expect(() => generateContainerName('localstack/localstack/:latest')).toThrow() + expect(() => + generateContainerName('localstack/localstack/:latest') + ).toThrow() }) }) @@ -144,7 +148,10 @@ describe('writeRunScript', () => { }) it('returns containerPath and runnerPath', () => { - const { containerPath, runnerPath } = writeRunScript('/work', 'sh', ['-e', 'script.sh']) + const { containerPath, runnerPath } = writeRunScript('/work', 'sh', [ + '-e', + 'script.sh' + ]) expect(containerPath).toMatch(/\/__w\/_temp\/.*\.sh/) expect(runnerPath).toContain(tmpDir) expect(fs.existsSync(runnerPath)).toBe(true) @@ -175,7 +182,7 @@ describe('writeRunScript', () => { it('throws if env key contains "$"', () => { expect(() => - writeRunScript('/work', 'sh', [], [], { 'BAD$KEY': 'val' }) + writeRunScript('/work', 'sh', [], [], { BAD$KEY: 'val' }) ).toThrow() }) }) @@ -205,9 +212,9 @@ describe('writeContainerStepScript', () => { }) it('throws on invalid working directory', () => { - expect(() => - writeContainerStepScript(tmpDir, 'tooshort', 'sh') - ).toThrow('Invalid working directory') + expect(() => writeContainerStepScript(tmpDir, 'tooshort', 'sh')).toThrow( + 'Invalid working directory' + ) }) it('throws if env key contains invalid chars', () => { @@ -234,7 +241,11 @@ describe('prepareJobScript', () => { it('returns paths and file exists', () => { const { containerPath, runnerPath } = prepareJobScript([ - { sourceVolumePath: '/src', targetVolumePath: '/mnt/data', readOnly: false } + { + sourceVolumePath: '/src', + targetVolumePath: '/mnt/data', + readOnly: false + } ]) expect(containerPath).toMatch(/\/__w\/_temp\/.*\.sh/) expect(fs.existsSync(runnerPath)).toBe(true) @@ -242,8 +253,16 @@ describe('prepareJobScript', () => { it('includes mkdir for each mount target', () => { const { runnerPath } = prepareJobScript([ - { sourceVolumePath: '/s1', targetVolumePath: '/mnt/vol1', readOnly: false }, - { sourceVolumePath: '/s2', targetVolumePath: '/mnt/vol2', readOnly: false } + { + sourceVolumePath: '/s1', + targetVolumePath: '/mnt/vol1', + readOnly: false + }, + { + sourceVolumePath: '/s2', + targetVolumePath: '/mnt/vol2', + readOnly: false + } ]) const content = fs.readFileSync(runnerPath, 'utf8') expect(content).toContain('/mnt/vol1') diff --git a/packages/k8s/tests/exec-pod-step-with-output-test.ts b/packages/k8s/tests/exec-pod-step-with-output-test.ts index 9f67bbdb..b947fcfd 100644 --- a/packages/k8s/tests/exec-pod-step-with-output-test.ts +++ b/packages/k8s/tests/exec-pod-step-with-output-test.ts @@ -1,5 +1,4 @@ import * as k8s from '@kubernetes/client-node' -import * as core from '@actions/core' import { execPodStepWithOutput } from '../src/k8s' jest.mock('@actions/core', () => ({ @@ -24,7 +23,17 @@ describe('execPodStepWithOutput', () => { it('resolves with code 0 and captured stdout on success', async () => { execSpy.mockImplementation( - (_ns, _pod, _container, _cmd, stdout, _stderr, _stdin, _tty, callback) => { + async ( + _ns, + _pod, + _container, + _cmd, + stdout, + _stderr, + _stdin, + _tty, + callback + ) => { stdout.write('hello from script\n') stdout.write('second line\n') callback({ status: 'Success', code: 0 }) @@ -43,7 +52,7 @@ describe('execPodStepWithOutput', () => { it('resolves with exit code when exec() promise rejects with non-zero exit message', async () => { // This is the k8s client v1.x path: exec() rejects directly, callback never fires. - execSpy.mockImplementation((_ns, _pod, _container, _cmd, stdout) => { + execSpy.mockImplementation(async (_ns, _pod, _container, _cmd, stdout) => { stdout.write('some output before fail\n') return Promise.reject( new Error( @@ -62,7 +71,17 @@ describe('execPodStepWithOutput', () => { it('resolves with exit code from Failure status callback', async () => { execSpy.mockImplementation( - (_ns, _pod, _container, _cmd, _stdout, stderr, _stdin, _tty, callback) => { + async ( + _ns, + _pod, + _container, + _cmd, + _stdout, + stderr, + _stdin, + _tty, + callback + ) => { stderr.write('error output\n') callback({ status: 'Failure', @@ -91,7 +110,17 @@ describe('execPodStepWithOutput', () => { it('rejects on Failure callback with no exit code in message', async () => { execSpy.mockImplementation( - (_ns, _pod, _container, _cmd, _stdout, _stderr, _stdin, _tty, callback) => { + async ( + _ns, + _pod, + _container, + _cmd, + _stdout, + _stderr, + _stdin, + _tty, + callback + ) => { callback({ status: 'Failure', message: 'container not found' }) return Promise.resolve() } @@ -103,7 +132,17 @@ describe('execPodStepWithOutput', () => { it('retains only the last tailLines lines in the output buffer', async () => { execSpy.mockImplementation( - (_ns, _pod, _container, _cmd, stdout, _stderr, _stdin, _tty, callback) => { + async ( + _ns, + _pod, + _container, + _cmd, + stdout, + _stderr, + _stdin, + _tty, + callback + ) => { for (let i = 0; i < 30; i++) { stdout.write(`line ${i}\n`) } @@ -126,7 +165,17 @@ describe('execPodStepWithOutput', () => { it('keeps stdout and stderr pending buffers independent (no interleaving)', async () => { execSpy.mockImplementation( - (_ns, _pod, _container, _cmd, stdout, stderr, _stdin, _tty, callback) => { + async ( + _ns, + _pod, + _container, + _cmd, + stdout, + stderr, + _stdin, + _tty, + callback + ) => { // Write partial lines to both streams without a newline delimiter. // If the pending buffers were shared, "out partial" and "err partial" // would be concatenated into one corrupted line. diff --git a/packages/k8s/tests/run-container-step-terminated-test.ts b/packages/k8s/tests/run-container-step-terminated-test.ts index 8111a895..208162b3 100644 --- a/packages/k8s/tests/run-container-step-terminated-test.ts +++ b/packages/k8s/tests/run-container-step-terminated-test.ts @@ -44,12 +44,9 @@ function makeMinimalArgs(): RunContainerStepArgs { } describe('runContainerStep terminated error detection', () => { - let createStepPodSpy: jest.SpyInstance - let waitForPodPhasesSpy: jest.SpyInstance let getPodByNameSpy: jest.SpyInstance let getContainerTerminatedErrorsSpy: jest.SpyInstance let describePodFailureSpy: jest.SpyInstance - let deletePodSpy: jest.SpyInstance let coreErrorSpy: jest.SpyInstance beforeEach(() => { @@ -58,29 +55,30 @@ describe('runContainerStep terminated error detection', () => { process.env['GITHUB_WORKSPACE'] = '/tmp/runner/_work/repo/repo' process.env['ACTIONS_RUNNER_POD_NAME'] = 'test-runner-pod' - createStepPodSpy = jest - .spyOn(k8sModule, 'createContainerStepPod') - .mockResolvedValue({ - metadata: { name: 'test-step-pod' } - } as k8s.V1Pod) + jest.spyOn(k8sModule, 'createContainerStepPod').mockResolvedValue({ + metadata: { name: 'test-step-pod' } + } as k8s.V1Pod) - waitForPodPhasesSpy = jest + jest .spyOn(k8sModule, 'waitForPodPhases') - .mockRejectedValue(new Error('Pod test-step-pod has unrecoverable errors')) + .mockRejectedValue( + new Error('Pod test-step-pod has unrecoverable errors') + ) - getPodByNameSpy = jest - .spyOn(k8sModule, 'getPodByName') + getPodByNameSpy = jest.spyOn(k8sModule, 'getPodByName') - getContainerTerminatedErrorsSpy = jest - .spyOn(k8sModule, 'getContainerTerminatedErrors') + getContainerTerminatedErrorsSpy = jest.spyOn( + k8sModule, + 'getContainerTerminatedErrors' + ) describePodFailureSpy = jest .spyOn(k8sModule, 'describePodFailure') - .mockResolvedValue('Pod status: Failed\nContainer details:\n ✗ container "job" terminated: OOMKilled (exit code 137)') + .mockResolvedValue( + 'Pod status: Failed\nContainer details:\n ✗ container "job" terminated: OOMKilled (exit code 137)' + ) - deletePodSpy = jest - .spyOn(k8sModule, 'deletePod') - .mockResolvedValue(undefined) + jest.spyOn(k8sModule, 'deletePod').mockResolvedValue(undefined) coreErrorSpy = jest.spyOn(core, 'error') }) @@ -96,7 +94,12 @@ describe('runContainerStep terminated error detection', () => { it('detects OOMKilled and logs describePodFailure output', async () => { getPodByNameSpy.mockResolvedValue( buildPodWithTerminated([ - terminatedContainer('job', 'OOMKilled', 137, 'The node was low on resource: memory') + terminatedContainer( + 'job', + 'OOMKilled', + 137, + 'The node was low on resource: memory' + ) ]) ) getContainerTerminatedErrorsSpy.mockReturnValue([ @@ -115,9 +118,7 @@ describe('runContainerStep terminated error detection', () => { it('detects Error (exit non-zero) and logs describePodFailure output', async () => { getPodByNameSpy.mockResolvedValue( - buildPodWithTerminated([ - terminatedContainer('job', 'Error', 1) - ]) + buildPodWithTerminated([terminatedContainer('job', 'Error', 1)]) ) getContainerTerminatedErrorsSpy.mockReturnValue([ ' ✗ container "job": Error (exit code 1)' @@ -136,7 +137,12 @@ describe('runContainerStep terminated error detection', () => { it('detects FailedPostStartHookError and logs describePodFailure output', async () => { getPodByNameSpy.mockResolvedValue( buildPodWithTerminated([ - terminatedContainer('job', 'FailedPostStartHookError', 137, 'postStart hook failed') + terminatedContainer( + 'job', + 'FailedPostStartHookError', + 137, + 'postStart hook failed' + ) ]) ) getContainerTerminatedErrorsSpy.mockReturnValue([ diff --git a/packages/k8s/tests/run-script-step-unit-test.ts b/packages/k8s/tests/run-script-step-unit-test.ts index 48d95792..9fad839e 100644 --- a/packages/k8s/tests/run-script-step-unit-test.ts +++ b/packages/k8s/tests/run-script-step-unit-test.ts @@ -1,4 +1,3 @@ -import * as core from '@actions/core' import { runScriptStep } from '../src/hooks' import * as k8sModule from '../src/k8s' import * as utils from '../src/k8s/utils' @@ -23,10 +22,7 @@ function makeArgs(): RunScriptStepArgs { const state = { jobPod: 'test-job-pod' } describe('runScriptStep error classification', () => { - let execPodStepSpy: jest.SpyInstance let execPodStepWithOutputSpy: jest.SpyInstance - let execCpToPodSpy: jest.SpyInstance - let execCpFromPodSpy: jest.SpyInstance beforeEach(() => { process.env['ACTIONS_RUNNER_KUBERNETES_NAMESPACE'] = 'default' @@ -39,20 +35,13 @@ describe('runScriptStep error classification', () => { runnerPath: '/tmp/test-script.sh' }) - execPodStepSpy = jest - .spyOn(k8sModule, 'execPodStep') - .mockResolvedValue(0) + jest.spyOn(k8sModule, 'execPodStep').mockResolvedValue(0) - execCpToPodSpy = jest - .spyOn(k8sModule, 'execCpToPod') - .mockResolvedValue(undefined) + jest.spyOn(k8sModule, 'execCpToPod').mockResolvedValue(undefined) - execCpFromPodSpy = jest - .spyOn(k8sModule, 'execCpFromPod') - .mockResolvedValue(undefined) + jest.spyOn(k8sModule, 'execCpFromPod').mockResolvedValue(undefined) - execPodStepWithOutputSpy = jest - .spyOn(k8sModule, 'execPodStepWithOutput') + execPodStepWithOutputSpy = jest.spyOn(k8sModule, 'execPodStepWithOutput') }) afterEach(() => { @@ -94,7 +83,7 @@ describe('runScriptStep error classification', () => { expect(caughtErr).toBeDefined() expect(caughtErr?.message).toContain('script output line') expect(caughtErr?.message).toContain('Last output:') - expect(caughtErr?.message).toContain('─') + expect(caughtErr?.message).toContain('-'.repeat(60)) }) it('throws generic error when execPodStepWithOutput rejects with non-exit error', async () => {