Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .cspell/project-words.txt
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ streetsidesoftware
prerelease
workflow_dispatch
checkout
startgroup
endgroup
ncconfig
nvmrc

Expand Down
115 changes: 115 additions & 0 deletions __tests__/commands/command-logs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { headCommand } from '../../src/commands/head.ts'
import { hideCommand } from '../../src/commands/hide.ts'
import { unhideCommand } from '../../src/commands/unhide.ts'
import { tryStat } from '../../src/fs.ts'
import { captureStdout, makeFixture, makeInputs, seedFile, type TestFixture } from '../_helpers.ts'

// These tests pin the user-visible log surface (group labels + info lines) and
// the missing-source error wording for the small command wrappers, plus the
// tryStat error-swallowing contract. They exist to keep mutation coverage on
// code that is otherwise only exercised for its return value.
describe('head/hide/unhide log + error surface', () => {
let fx: TestFixture

beforeEach(async () => {
fx = await makeFixture('gh-action-logs')
})

afterEach(async () => {
await rm(fx.workDir, { recursive: true, force: true })
})

it('head: groups the probe and logs size/type/sha1', async () => {
await seedFile(fx, 'h.txt', 'head-me')
let result: Awaited<ReturnType<typeof headCommand>> | undefined
const out = await captureStdout(async () => {
result = await headCommand(fx.bucket, makeInputs('head', fx, { source: 'h.txt' }))
})
expect(result?.contentSha1).toBeTruthy()
expect(out).toContain('::group::head b2://gh-action-logs/h.txt')
expect(out).toContain(
`size=${result?.size} type=${result?.contentType} sha1=${result?.contentSha1}`,
)
expect(out).toContain('::endgroup::')
})

it('head: requires a source and names the action in the error', async () => {
await expect(headCommand(fx.bucket, makeInputs('head', fx))).rejects.toThrow(
"'source' input is required for 'head' action (the B2 file name)",
)
})

it('hide: groups the call and logs the created marker', async () => {
await seedFile(fx, 'g.txt', 'hide-me')
let result: Awaited<ReturnType<typeof hideCommand>> | undefined
const out = await captureStdout(async () => {
result = await hideCommand(fx.bucket, makeInputs('hide', fx, { source: 'g.txt' }))
})
expect(out).toContain('::group::hide b2://gh-action-logs/g.txt')
expect(out).toContain(`hidden: g.txt (marker fileId=${result?.fileId})`)
expect(out).toContain('::endgroup::')
})

it('hide: requires a source and names the action in the error', async () => {
await expect(hideCommand(fx.bucket, makeInputs('hide', fx))).rejects.toThrow(
"'source' input is required for 'hide' action (the B2 file name)",
)
})

it('unhide: logs the removed marker when one exists', async () => {
await seedFile(fx, 'u.txt', 'unhide-me')
await fx.bucket.hideFile('u.txt')
let result: Awaited<ReturnType<typeof unhideCommand>> | undefined
const out = await captureStdout(async () => {
result = await unhideCommand(fx.bucket, makeInputs('unhide', fx, { source: 'u.txt' }))
})
expect(result?.removedMarkerFileId).not.toBeNull()
expect(out).toContain('::group::unhide b2://gh-action-logs/u.txt')
expect(out).toContain(
`removed hide marker fileId=${result?.removedMarkerFileId}, u.txt is now visible`,
)
})

it('unhide: reports a no-op when there is no hide marker', async () => {
await seedFile(fx, 'v.txt', 'visible')
let result: Awaited<ReturnType<typeof unhideCommand>> | undefined
const out = await captureStdout(async () => {
result = await unhideCommand(fx.bucket, makeInputs('unhide', fx, { source: 'v.txt' }))
})
expect(result?.removedMarkerFileId).toBeNull()
expect(out).toContain('no hide marker found for v.txt (already visible or non-existent)')
})

it('unhide: requires a source and names the action in the error', async () => {
await expect(unhideCommand(fx.bucket, makeInputs('unhide', fx))).rejects.toThrow(
"'source' input is required for 'unhide' action (the B2 file name)",
)
})
})

describe('tryStat', () => {
let fx: TestFixture

beforeEach(async () => {
fx = await makeFixture('gh-action-try-stat')
})

afterEach(async () => {
await rm(fx.workDir, { recursive: true, force: true })
})

it('returns Stats for an existing path', async () => {
const p = join(fx.workDir, 'present.txt')
await writeFile(p, 'x')
const s = await tryStat(p)
expect(s?.isFile()).toBe(true)
})

it('returns undefined instead of throwing for a missing path', async () => {
const s = await tryStat(join(fx.workDir, 'does', 'not', 'exist.txt'))
expect(s).toBeUndefined()
})
})
63 changes: 62 additions & 1 deletion __tests__/commands/head-purge-multipresign.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { type PresignedFile, presignCommand } from '../../src/commands/presign.t
import { purgeCommand } from '../../src/commands/purge.ts'
import { uploadCommand } from '../../src/commands/upload.ts'
import { setSummaryJsonOutput } from '../../src/outputs.ts'
import { captureStdout, makeFixture, makeInputs, type TestFixture } from '../_helpers.ts'
import { captureStdout, makeFixture, makeInputs, seedFile, type TestFixture } from '../_helpers.ts'

function inputs(action: Parameters<typeof makeInputs>[0], over: Record<string, unknown> = {}) {
return makeInputs(action, { bucket: 'gh-action-hpx', ...over })
Expand Down Expand Up @@ -83,6 +83,67 @@ describe('purge command', () => {
/'allow-bucket-purge' must be true/,
)
})

it('purges the whole bucket when allow-bucket-purge is set, warning loudly', async () => {
await seedFile(fx, 'a/x.txt', 'x')
await seedFile(fx, 'top.txt', 'y')
let result: Awaited<ReturnType<typeof purgeCommand>> | undefined
const out = await captureStdout(async () => {
result = await purgeCommand(fx.bucket, inputs('purge', { allowBucketPurge: true }))
})
expect(result?.errors).toBe(0)
expect(result?.files.length).toBeGreaterThanOrEqual(2)
expect(result?.files.every((f) => f.action === 'upload' && !f.skipped)).toBe(true)
expect(out).toContain('::warning::')
expect(out).toContain('permanently delete EVERY version in bucket "gh-action-hpx"')
expect(out).toContain('::group::purge b2://gh-action-hpx/ (all versions)')
expect(out).toMatch(/ {2}purged top\.txt \(/)
const after = await fx.bucket.listFileVersions({})
expect(after.files).toHaveLength(0)
})

it('previews a whole-bucket purge with no warning under dry-run', async () => {
await seedFile(fx, 'a/x.txt', 'x')
let result: Awaited<ReturnType<typeof purgeCommand>> | undefined
const out = await captureStdout(async () => {
result = await purgeCommand(
fx.bucket,
inputs('purge', { allowBucketPurge: true, dryRun: true }),
)
})
expect(result?.files.length).toBeGreaterThan(0)
expect(result?.files.every((f) => f.skipped && f.action === 'skip')).toBe(true)
expect(out).not.toContain('::warning::')
expect(out).toContain('::group::dry-run b2://gh-action-hpx/ (all versions)')
expect(out).toMatch(/ {2}would purge a\/x\.txt \(/)
const after = await fx.bucket.listFileVersions({})
expect(after.files.length).toBeGreaterThan(0)
})

it('appends a trailing slash to a prefix source that lacks one', async () => {
// 'log' must become prefix 'log/', so 'log/inside.txt' is purged but the
// sibling 'logs.txt' (which a bare 'log' prefix would also match) survives.
await seedFile(fx, 'log/inside.txt', 'in')
await seedFile(fx, 'logs.txt', 'out')
const result = await purgeCommand(fx.bucket, inputs('purge', { source: 'log' }))
expect(result.errors).toBe(0)
const inside = await fx.bucket.listFileVersions({ prefix: 'log/' })
expect(inside.files).toHaveLength(0)
const sibling = await fx.bucket.listFileVersions({ prefix: 'logs.txt' })
expect(sibling.files.length).toBeGreaterThan(0)
})

it('warns with the failed-purge message when a version cannot be deleted', async () => {
await seedFile(fx, 'pf/a.txt', 'a')
fx.sim.injectFailure({ on: 'b2_delete_file_version', status: 500, code: 'internal_error' })
let result: Awaited<ReturnType<typeof purgeCommand>> | undefined
const out = await captureStdout(async () => {
result = await purgeCommand(fx.bucket, inputs('purge', { source: 'pf/' }))
})
expect(result?.errors).toBeGreaterThanOrEqual(1)
expect(out).toContain('::warning::')
expect(out).toContain('failed to purge')
})
})

describe('presign command (prefix mode)', () => {
Expand Down
Loading