Files
WeHub Mirror 6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
WeHub snapshot of cb28d14c6f2c081de7a0d8729a8c816c9adef67a
2026-08-10 11:17:50 +08:00

148 lines
5.3 KiB
TypeScript

/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { jobLogsTool } from '@/tools/github/job_logs'
import type { JobLogsParams } from '@/tools/github/types'
const BASE_PARAMS: JobLogsParams = {
owner: 'octo',
repo: 'demo',
job_id: 42,
apiKey: 'ghp_test',
}
function logResponse(body: string): Response {
return new Response(body, { headers: { 'Content-Type': 'text/plain' } })
}
/** A storage host that honoured the suffix range: 206 plus the served window. */
function partialLogResponse(body: string, totalBytes: number): Response {
return new Response(body, {
status: 206,
headers: {
'Content-Type': 'text/plain',
'Content-Range': `bytes ${totalBytes - Buffer.byteLength(body)}-${totalBytes - 1}/${totalBytes}`,
},
})
}
describe('github_job_logs', () => {
it('reads the per-job log endpoint, not the run-level archive', () => {
const url = (jobLogsTool.request.url as (params: JobLogsParams) => string)(BASE_PARAMS)
expect(url).toBe('https://api.github.com/repos/octo/demo/actions/jobs/42/logs')
})
it('escapes coordinates so they cannot redirect the authenticated request', () => {
const url = (jobLogsTool.request.url as (params: JobLogsParams) => string)({
...BASE_PARAMS,
owner: '../../orgs/secret',
repo: 'demo?ref=x',
})
expect(url).toBe(
'https://api.github.com/repos/..%2F..%2Forgs%2Fsecret/demo%3Fref%3Dx/actions/jobs/42/logs'
)
})
it('rejects a job id that is not a positive integer', () => {
const url = jobLogsTool.request.url as (params: JobLogsParams) => string
expect(() => url({ ...BASE_PARAMS, job_id: 0 })).toThrow(/job_id must be a positive integer/)
expect(() => url({ ...BASE_PARAMS, job_id: 1.5 })).toThrow(/job_id must be a positive integer/)
expect(() => url({ ...BASE_PARAMS, job_id: '9/../..' as unknown as number })).toThrow(
/job_id must be a positive integer/
)
})
it('returns a short log whole', async () => {
const result = await jobLogsTool.transformResponse!(logResponse('boom\n'), BASE_PARAMS)
expect(result).toEqual({
success: true,
output: { logs: 'boom\n', truncated: false, totalBytes: 5 },
})
})
it('keeps the tail of a long log, where the failure is reported', async () => {
const log = `${'noise\n'.repeat(5_000)}FAILED: expected 1 to be 2`
const result = await jobLogsTool.transformResponse!(logResponse(log), {
...BASE_PARAMS,
maxCharacters: 40,
})
expect(result.output.logs).toHaveLength(40)
expect(result.output.logs.endsWith('FAILED: expected 1 to be 2')).toBe(true)
expect(result.output).toMatchObject({ totalBytes: log.length, truncated: true })
})
// A verbose CI job exceeds the executor's 10 MB response cap, which throws rather
// than truncating — so asking for only the tail is what keeps a diagnostic available
// on exactly the runs that most need one.
it('asks the storage host for only the tail it intends to keep', () => {
const headers = jobLogsTool.request.headers({ ...BASE_PARAMS, maxCharacters: 4_096 })
expect(headers.Range).toBe('bytes=-4096')
})
it('trims the partial first line of a ranged response and reports the full size', async () => {
const result = await jobLogsTool.transformResponse!(
partialLogResponse('ise\nFAILED: expected 1 to be 2', 10_000),
{ ...BASE_PARAMS, maxCharacters: 4_096 }
)
expect(result.output).toEqual({
logs: 'FAILED: expected 1 to be 2',
truncated: true,
totalBytes: 10_000,
})
})
// A suffix range asking for more bytes than the log holds is satisfied with the
// WHOLE log, still as a 206 — `Content-Range` starts at 0. Trimming the first line
// there would delete a real line, and this is the common case for a job that
// failed fast and logged little.
it('keeps the first line when a 206 served the whole log', async () => {
const log = 'first line\nsecond line\n'
const result = await jobLogsTool.transformResponse!(
partialLogResponse(log, Buffer.byteLength(log)),
{ ...BASE_PARAMS, maxCharacters: 20_000 }
)
expect(result.output).toEqual({
logs: log,
truncated: false,
totalBytes: Buffer.byteLength(log),
})
})
// A host that ignores the range answers 200 with the whole body, so the local
// slice has to remain the fallback rather than an assumption about partiality.
it('falls back to the local slice when the range is ignored', async () => {
const log = `${'noise\n'.repeat(100)}tail`
const result = await jobLogsTool.transformResponse!(logResponse(log), {
...BASE_PARAMS,
maxCharacters: 10,
})
expect(result.output.logs).toBe(log.slice(-10))
expect(result.output).toMatchObject({ truncated: true, totalBytes: log.length })
})
it('rejects a cap outside the supported range', async () => {
await expect(
jobLogsTool.transformResponse!(logResponse('x'), { ...BASE_PARAMS, maxCharacters: 0 })
).rejects.toThrow(/maxCharacters must be an integer between 1 and 200000/)
})
it('drops the GitHub token on the redirect to third-party blob storage', () => {
// The tool fetch follows redirects itself rather than through the fetch spec,
// so without this the PAT would be replayed to the storage host.
expect(jobLogsTool.request.stripAuthOnRedirect).toBe(true)
})
})