mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-05 21:54:50 +00:00
Compare commits
1 Commits
bl/queue-b
...
glary/ashb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a4cf6023b |
@@ -40,7 +40,7 @@ test.describe('Careers page @smoke', () => {
|
||||
const engineeringLocator = page.getByTestId('careers-role-link')
|
||||
await expect(engineeringLocator.first()).toBeVisible()
|
||||
const engineeringCount = await engineeringLocator.count()
|
||||
expect(engineeringCount).toBeLessThanOrEqual(allCount)
|
||||
expect(engineeringCount).toBeLessThan(allCount)
|
||||
expect(engineeringCount).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -34,10 +34,10 @@ function freshOutcome(droppedCount = 0): FetchOutcome {
|
||||
return {
|
||||
status: 'fresh',
|
||||
droppedCount,
|
||||
droppedRoles:
|
||||
droppedCount === 0
|
||||
? []
|
||||
: [{ title: 'Bad Role', reason: 'jobUrl: Invalid url' }],
|
||||
droppedRoles: Array.from({ length: droppedCount }, (_, i) => ({
|
||||
title: `Bad Role ${i + 1}`,
|
||||
reason: 'jobUrl: Invalid url'
|
||||
})),
|
||||
snapshot: {
|
||||
fetchedAt: new Date().toISOString(),
|
||||
departments: [
|
||||
@@ -100,6 +100,7 @@ describe('reportAshbyOutcome', () => {
|
||||
reportAshbyOutcome({
|
||||
status: 'stale',
|
||||
reason: 'HTTP 401 Unauthorized',
|
||||
reasonCode: 'auth',
|
||||
snapshot: baseSnapshot()
|
||||
})
|
||||
const annotation = writeSpy.mock.calls[0]![0] as string
|
||||
@@ -110,14 +111,41 @@ describe('reportAshbyOutcome', () => {
|
||||
reportAshbyOutcome({
|
||||
status: 'stale',
|
||||
reason: 'missing WEBSITE_ASHBY_API_KEY or WEBSITE_ASHBY_JOB_BOARD_NAME',
|
||||
reasonCode: 'missing-env',
|
||||
snapshot: baseSnapshot()
|
||||
})
|
||||
const annotation = writeSpy.mock.calls[0]![0] as string
|
||||
expect(annotation).toContain('::warning title=Ashby integration')
|
||||
})
|
||||
|
||||
it('emits ::error for schema mismatch in a stale outcome', () => {
|
||||
reportAshbyOutcome({
|
||||
status: 'stale',
|
||||
reason: 'envelope schema validation failed: apiVersion: Expected "1"',
|
||||
reasonCode: 'schema',
|
||||
snapshot: baseSnapshot()
|
||||
})
|
||||
const annotation = writeSpy.mock.calls[0]![0] as string
|
||||
expect(annotation).toContain('::error title=Ashby schema mismatch')
|
||||
})
|
||||
|
||||
it('emits ::warning for network errors in a stale outcome', () => {
|
||||
reportAshbyOutcome({
|
||||
status: 'stale',
|
||||
reason: 'HTTP 503 Service Unavailable',
|
||||
reasonCode: 'network',
|
||||
snapshot: baseSnapshot()
|
||||
})
|
||||
const annotation = writeSpy.mock.calls[0]![0] as string
|
||||
expect(annotation).toContain('::warning title=Ashby API unavailable')
|
||||
})
|
||||
|
||||
it('emits ::error for a failed outcome and writes no fresh-only sections', () => {
|
||||
reportAshbyOutcome({ status: 'failed', reason: 'HTTP 500 Server Error' })
|
||||
reportAshbyOutcome({
|
||||
status: 'failed',
|
||||
reason: 'HTTP 500 Server Error',
|
||||
reasonCode: 'network'
|
||||
})
|
||||
const annotation = writeSpy.mock.calls[0]![0] as string
|
||||
expect(annotation).toContain('::error title=Ashby fetch failed')
|
||||
expect(readFileSync(summaryPath, 'utf8')).toContain('Failed')
|
||||
@@ -127,4 +155,57 @@ describe('reportAshbyOutcome', () => {
|
||||
delete process.env.GITHUB_STEP_SUMMARY
|
||||
expect(() => reportAshbyOutcome(freshOutcome(0))).not.toThrow()
|
||||
})
|
||||
|
||||
it('renders snapshot age as "today" for a stale outcome fetched moments ago', () => {
|
||||
reportAshbyOutcome({
|
||||
status: 'stale',
|
||||
reason: 'HTTP 503 Service Unavailable',
|
||||
reasonCode: 'network',
|
||||
snapshot: { ...baseSnapshot(), fetchedAt: new Date().toISOString() }
|
||||
})
|
||||
expect(readFileSync(summaryPath, 'utf8')).toContain('| today |')
|
||||
})
|
||||
|
||||
it('renders snapshot age as "unknown" when fetchedAt is unparseable', () => {
|
||||
reportAshbyOutcome({
|
||||
status: 'stale',
|
||||
reason: 'HTTP 503 Service Unavailable',
|
||||
reasonCode: 'network',
|
||||
snapshot: { ...baseSnapshot(), fetchedAt: 'not-a-date' }
|
||||
})
|
||||
expect(readFileSync(summaryPath, 'utf8')).toContain('| unknown |')
|
||||
})
|
||||
|
||||
it('renders snapshot age as "unknown" when fetchedAt is in the future', () => {
|
||||
const future = new Date(Date.now() + 7 * 86_400_000).toISOString()
|
||||
reportAshbyOutcome({
|
||||
status: 'stale',
|
||||
reason: 'HTTP 503 Service Unavailable',
|
||||
reasonCode: 'network',
|
||||
snapshot: { ...baseSnapshot(), fetchedAt: future }
|
||||
})
|
||||
expect(readFileSync(summaryPath, 'utf8')).toContain('| unknown |')
|
||||
})
|
||||
|
||||
it('renders snapshot age as "1 day" when exactly one day old', () => {
|
||||
const oneDayAgo = new Date(Date.now() - 86_400_000).toISOString()
|
||||
reportAshbyOutcome({
|
||||
status: 'stale',
|
||||
reason: 'HTTP 503 Service Unavailable',
|
||||
reasonCode: 'network',
|
||||
snapshot: { ...baseSnapshot(), fetchedAt: oneDayAgo }
|
||||
})
|
||||
expect(readFileSync(summaryPath, 'utf8')).toContain('| 1 day |')
|
||||
})
|
||||
|
||||
it('renders snapshot age in days when older than one day', () => {
|
||||
const fiveDaysAgo = new Date(Date.now() - 5 * 86_400_000).toISOString()
|
||||
reportAshbyOutcome({
|
||||
status: 'stale',
|
||||
reason: 'HTTP 503 Service Unavailable',
|
||||
reasonCode: 'network',
|
||||
snapshot: { ...baseSnapshot(), fetchedAt: fiveDaysAgo }
|
||||
})
|
||||
expect(readFileSync(summaryPath, 'utf8')).toContain('| 5 days |')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { appendFileSync } from 'node:fs'
|
||||
|
||||
import type { FetchOutcome } from './ashby'
|
||||
import type { FailureCode, FetchOutcome } from './ashby'
|
||||
|
||||
const MS_PER_DAY = 86_400_000
|
||||
|
||||
let hasReported = false
|
||||
|
||||
@@ -41,7 +43,7 @@ function buildAnnotations(outcome: FetchOutcome): string[] {
|
||||
}
|
||||
|
||||
if (outcome.status === 'stale') {
|
||||
return [staleAnnotation(outcome.reason)]
|
||||
return [staleAnnotation(outcome.reason, outcome.reasonCode)]
|
||||
}
|
||||
|
||||
return [
|
||||
@@ -49,22 +51,29 @@ function buildAnnotations(outcome: FetchOutcome): string[] {
|
||||
]
|
||||
}
|
||||
|
||||
function staleAnnotation(reason: string): string {
|
||||
function staleAnnotation(reason: string, code: FailureCode): string {
|
||||
const escaped = escapeAnnotation(reason)
|
||||
if (reason.startsWith('missing ')) {
|
||||
return `::warning title=Ashby integration::${escaped}. Falling back to committed snapshot.%0A%0AAction items:%0A 1. If you're a contributor without key access, this is expected. The snapshot will be used.%0A 2. If this is CI, check that the \`WEBSITE_ASHBY_API_KEY\` secret exists in the repo and is referenced in .github/workflows/ci-website-build.yaml.`
|
||||
switch (code) {
|
||||
case 'missing-env':
|
||||
return `::warning title=Ashby integration::${escaped}. Falling back to committed snapshot.%0A%0AAction items:%0A 1. If you're a contributor without key access, this is expected. The snapshot will be used.%0A 2. If this is CI, check that the \`WEBSITE_ASHBY_API_KEY\` secret exists in the repo and is referenced in .github/workflows/ci-website-build.yaml.`
|
||||
case 'auth':
|
||||
return `::error title=Ashby authentication failed::${escaped}. The WEBSITE_ASHBY_API_KEY is missing, invalid, or revoked. Build continues with the last-known-good snapshot.%0A%0AAction items:%0A 1. Open Ashby → Settings → API Keys and confirm the key is active.%0A 2. Update the \`WEBSITE_ASHBY_API_KEY\` secret in GitHub Actions and Vercel.%0A 3. Re-run this workflow.`
|
||||
case 'schema':
|
||||
return `::error title=Ashby schema mismatch::${escaped}. The Ashby API contract has likely changed. Build continues with the snapshot, but future updates will fail until the schema is fixed.%0A%0AAction items:%0A 1. Check https://developers.ashbyhq.com/reference for API changelog.%0A 2. Update apps/website/src/utils/ashby.schema.ts to match the new shape.`
|
||||
case 'network':
|
||||
return `::warning title=Ashby API unavailable::${escaped}. Using last-known-good snapshot.%0A%0AAction items:%0A 1. Check https://status.ashbyhq.com%0A 2. Re-run this workflow once Ashby is healthy.`
|
||||
default: {
|
||||
const _exhaustive: never = code
|
||||
return _exhaustive
|
||||
}
|
||||
}
|
||||
if (reason.startsWith('HTTP 401') || reason.startsWith('HTTP 403')) {
|
||||
return `::error title=Ashby authentication failed::${escaped}. The WEBSITE_ASHBY_API_KEY is missing, invalid, or revoked. Build continues with the last-known-good snapshot.%0A%0AAction items:%0A 1. Open Ashby → Settings → API Keys and confirm the key is active.%0A 2. Update the \`WEBSITE_ASHBY_API_KEY\` secret in GitHub Actions and Vercel.%0A 3. Re-run this workflow.`
|
||||
}
|
||||
if (reason.startsWith('envelope')) {
|
||||
return `::error title=Ashby schema mismatch::${escaped}. The Ashby API contract has likely changed. Build continues with the snapshot, but future updates will fail until the schema is fixed.%0A%0AAction items:%0A 1. Check https://developers.ashbyhq.com/reference for API changelog.%0A 2. Update apps/website/src/utils/ashby.schema.ts to match the new shape.`
|
||||
}
|
||||
return `::warning title=Ashby API unavailable::${escaped}. Using last-known-good snapshot.%0A%0AAction items:%0A 1. Check https://status.ashbyhq.com%0A 2. Re-run this workflow once Ashby is healthy.`
|
||||
}
|
||||
|
||||
function escapeAnnotation(value: string): string {
|
||||
return value.replace(/\r?\n/g, '%0A').replace(/\r/g, '%0D')
|
||||
return value
|
||||
.replace(/%/g, '%25')
|
||||
.replace(/\r?\n/g, '%0A')
|
||||
.replace(/\r/g, '%0D')
|
||||
}
|
||||
|
||||
function buildStepSummary(outcome: FetchOutcome): string {
|
||||
@@ -106,8 +115,9 @@ function buildStepSummary(outcome: FetchOutcome): string {
|
||||
function describeSnapshotAge(fetchedAt: string): string {
|
||||
const fetched = new Date(fetchedAt).getTime()
|
||||
if (Number.isNaN(fetched)) return 'unknown'
|
||||
const days = Math.floor((Date.now() - fetched) / 86_400_000)
|
||||
if (days <= 0) return 'today'
|
||||
const days = Math.floor((Date.now() - fetched) / MS_PER_DAY)
|
||||
if (days < 0) return 'unknown'
|
||||
if (days === 0) return 'today'
|
||||
if (days === 1) return '1 day'
|
||||
return `${days} days`
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ export const AshbyJobPostingSchema = z.object({
|
||||
|
||||
export const AshbyJobBoardResponseSchema = z.object({
|
||||
apiVersion: z.literal('1'),
|
||||
// Deliberately z.unknown() — each job is validated individually in
|
||||
// parseRoles() so that invalid postings are dropped with per-job error
|
||||
// messages rather than rejecting the entire response.
|
||||
jobs: z.array(z.unknown())
|
||||
})
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ const BASE_URL = 'https://ashby.test'
|
||||
const BOARD = 'comfy-org'
|
||||
const KEY = 'abc-123-secret'
|
||||
|
||||
const tempDirs = new Set<URL>()
|
||||
|
||||
function validJob(overrides: Partial<AshbyJobPosting> = {}): unknown {
|
||||
return {
|
||||
title: 'Design Engineer',
|
||||
@@ -52,7 +54,15 @@ function withSnapshotDir(snapshot: RolesSnapshot | null): URL {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ashby-test-'))
|
||||
const file = join(dir, 'ashby-roles.snapshot.json')
|
||||
if (snapshot) writeFileSync(file, JSON.stringify(snapshot))
|
||||
return pathToFileURL(file)
|
||||
const url = pathToFileURL(file)
|
||||
tempDirs.add(url)
|
||||
return url
|
||||
}
|
||||
|
||||
function mockFetch(
|
||||
impl: (...args: Parameters<typeof fetch>) => Promise<Response>
|
||||
): typeof fetch {
|
||||
return vi.fn(impl) as unknown as typeof fetch
|
||||
}
|
||||
|
||||
describe('fetchRolesForBuild', () => {
|
||||
@@ -69,17 +79,21 @@ describe('fetchRolesForBuild', () => {
|
||||
vi.restoreAllMocks()
|
||||
process.env.WEBSITE_ASHBY_API_KEY = savedApiKey
|
||||
process.env.WEBSITE_ASHBY_JOB_BOARD_NAME = savedBoardName
|
||||
for (const url of tempDirs) {
|
||||
rmSync(new URL('.', url), { recursive: true, force: true })
|
||||
}
|
||||
tempDirs.clear()
|
||||
})
|
||||
|
||||
it('returns fresh when the API succeeds', async () => {
|
||||
const fetchImpl = vi.fn(async () =>
|
||||
const fetchImpl = mockFetch(async () =>
|
||||
response({ apiVersion: '1', jobs: [validJob()] })
|
||||
)
|
||||
const outcome = await fetchRolesForBuild({
|
||||
apiKey: KEY,
|
||||
boardName: BOARD,
|
||||
baseUrl: BASE_URL,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
fetchImpl
|
||||
})
|
||||
expect(outcome.status).toBe('fresh')
|
||||
if (outcome.status !== 'fresh') return
|
||||
@@ -90,17 +104,37 @@ describe('fetchRolesForBuild', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('title-cases multi-word department names on the role', async () => {
|
||||
const fetchImpl = mockFetch(async () =>
|
||||
response({
|
||||
apiVersion: '1',
|
||||
jobs: [validJob({ department: 'Product Engineering' })]
|
||||
})
|
||||
)
|
||||
const outcome = await fetchRolesForBuild({
|
||||
apiKey: KEY,
|
||||
boardName: BOARD,
|
||||
baseUrl: BASE_URL,
|
||||
fetchImpl
|
||||
})
|
||||
expect(outcome.status).toBe('fresh')
|
||||
if (outcome.status !== 'fresh') return
|
||||
expect(outcome.snapshot.departments[0]!.roles[0]!.department).toBe(
|
||||
'Product Engineering'
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to jobUrl when applyUrl is missing and keeps the role', async () => {
|
||||
const job = validJob()
|
||||
delete (job as Record<string, unknown>).applyUrl
|
||||
const fetchImpl = vi.fn(async () =>
|
||||
const fetchImpl = mockFetch(async () =>
|
||||
response({ apiVersion: '1', jobs: [job] })
|
||||
)
|
||||
const outcome = await fetchRolesForBuild({
|
||||
apiKey: KEY,
|
||||
boardName: BOARD,
|
||||
baseUrl: BASE_URL,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
fetchImpl
|
||||
})
|
||||
expect(outcome.status).toBe('fresh')
|
||||
if (outcome.status !== 'fresh') return
|
||||
@@ -111,7 +145,7 @@ describe('fetchRolesForBuild', () => {
|
||||
|
||||
it('drops invalid roles individually and keeps the valid ones', async () => {
|
||||
const snapshotUrl = withSnapshotDir(makeSnapshot())
|
||||
const fetchImpl = vi.fn(async () =>
|
||||
const fetchImpl = mockFetch(async () =>
|
||||
response({
|
||||
apiVersion: '1',
|
||||
jobs: [validJob(), validJob({ title: 'Bad Role', jobUrl: 'not-a-url' })]
|
||||
@@ -122,31 +156,31 @@ describe('fetchRolesForBuild', () => {
|
||||
boardName: BOARD,
|
||||
baseUrl: BASE_URL,
|
||||
snapshotUrl,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
fetchImpl
|
||||
})
|
||||
expect(outcome.status).toBe('fresh')
|
||||
if (outcome.status !== 'fresh') return
|
||||
expect(outcome.droppedCount).toBe(1)
|
||||
expect(outcome.droppedRoles[0]!.title).toBe('Bad Role')
|
||||
expect(outcome.snapshot.departments[0]!.roles).toHaveLength(1)
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('renders an empty-but-fresh outcome when hiring is paused', async () => {
|
||||
const snapshotUrl = withSnapshotDir(makeSnapshot())
|
||||
const fetchImpl = vi.fn(async () => response({ apiVersion: '1', jobs: [] }))
|
||||
const fetchImpl = mockFetch(async () =>
|
||||
response({ apiVersion: '1', jobs: [] })
|
||||
)
|
||||
const outcome = await fetchRolesForBuild({
|
||||
apiKey: KEY,
|
||||
boardName: BOARD,
|
||||
baseUrl: BASE_URL,
|
||||
snapshotUrl,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
fetchImpl
|
||||
})
|
||||
expect(outcome.status).toBe('fresh')
|
||||
if (outcome.status !== 'fresh') return
|
||||
expect(outcome.snapshot.departments).toEqual([])
|
||||
expect(outcome.droppedCount).toBe(0)
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('normalizes missing department and location to safe defaults', async () => {
|
||||
@@ -154,7 +188,7 @@ describe('fetchRolesForBuild', () => {
|
||||
const job = validJob()
|
||||
delete (job as Record<string, unknown>).department
|
||||
delete (job as Record<string, unknown>).location
|
||||
const fetchImpl = vi.fn(async () =>
|
||||
const fetchImpl = mockFetch(async () =>
|
||||
response({ apiVersion: '1', jobs: [job] })
|
||||
)
|
||||
const outcome = await fetchRolesForBuild({
|
||||
@@ -162,19 +196,18 @@ describe('fetchRolesForBuild', () => {
|
||||
boardName: BOARD,
|
||||
baseUrl: BASE_URL,
|
||||
snapshotUrl,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
fetchImpl
|
||||
})
|
||||
expect(outcome.status).toBe('fresh')
|
||||
if (outcome.status !== 'fresh') return
|
||||
const [department] = outcome.snapshot.departments
|
||||
expect(department?.name).toBe('OTHER')
|
||||
expect(department?.roles[0]?.location).toBe('Remote')
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('filters out roles with isListed=false', async () => {
|
||||
const snapshotUrl = withSnapshotDir(makeSnapshot())
|
||||
const fetchImpl = vi.fn(async () =>
|
||||
const fetchImpl = mockFetch(async () =>
|
||||
response({
|
||||
apiVersion: '1',
|
||||
jobs: [validJob(), validJob({ title: 'Hidden', isListed: false })]
|
||||
@@ -185,7 +218,7 @@ describe('fetchRolesForBuild', () => {
|
||||
boardName: BOARD,
|
||||
baseUrl: BASE_URL,
|
||||
snapshotUrl,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
fetchImpl
|
||||
})
|
||||
expect(outcome.status).toBe('fresh')
|
||||
if (outcome.status !== 'fresh') return
|
||||
@@ -193,54 +226,54 @@ describe('fetchRolesForBuild', () => {
|
||||
d.roles.map((r) => r.title)
|
||||
)
|
||||
expect(titles).not.toContain('Hidden')
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('returns stale with missing env when the snapshot is present', async () => {
|
||||
const snapshot = makeSnapshot()
|
||||
const snapshotUrl = withSnapshotDir(snapshot)
|
||||
const fetchImpl = vi.fn()
|
||||
const fetchImpl = mockFetch(async () => response({}))
|
||||
const outcome = await fetchRolesForBuild({
|
||||
snapshotUrl,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
fetchImpl
|
||||
})
|
||||
expect(outcome.status).toBe('stale')
|
||||
if (outcome.status !== 'stale') return
|
||||
expect(outcome.reason).toMatch(/^missing /)
|
||||
expect(outcome.reasonCode).toBe('missing-env')
|
||||
expect(fetchImpl).not.toHaveBeenCalled()
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('returns failed when both env and snapshot are missing', async () => {
|
||||
const snapshotUrl = withSnapshotDir(null)
|
||||
const outcome = await fetchRolesForBuild({
|
||||
snapshotUrl,
|
||||
fetchImpl: vi.fn() as unknown as typeof fetch
|
||||
fetchImpl: mockFetch(async () => response({}))
|
||||
})
|
||||
expect(outcome.status).toBe('failed')
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
if (outcome.status !== 'failed') return
|
||||
expect(outcome.reasonCode).toBe('missing-env')
|
||||
})
|
||||
|
||||
it('does not retry on HTTP 401', async () => {
|
||||
const snapshotUrl = withSnapshotDir(makeSnapshot())
|
||||
const fetchImpl = vi.fn(async () => response({}, { status: 401 }))
|
||||
const fetchImpl = mockFetch(async () => response({}, { status: 401 }))
|
||||
const outcome = await fetchRolesForBuild({
|
||||
apiKey: KEY,
|
||||
boardName: BOARD,
|
||||
baseUrl: BASE_URL,
|
||||
snapshotUrl,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
fetchImpl
|
||||
})
|
||||
expect(outcome.status).toBe('stale')
|
||||
if (outcome.status !== 'stale') return
|
||||
expect(outcome.reason).toMatch(/^HTTP 401/)
|
||||
expect(outcome.reasonCode).toBe('auth')
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('retries 5xx up to the configured limit then falls back to snapshot', async () => {
|
||||
const snapshotUrl = withSnapshotDir(makeSnapshot())
|
||||
const fetchImpl = vi.fn(async () => response({}, { status: 503 }))
|
||||
const fetchImpl = mockFetch(async () => response({}, { status: 503 }))
|
||||
const sleep = vi.fn(async () => undefined)
|
||||
const outcome = await fetchRolesForBuild({
|
||||
apiKey: KEY,
|
||||
@@ -249,39 +282,42 @@ describe('fetchRolesForBuild', () => {
|
||||
snapshotUrl,
|
||||
retryDelaysMs: [1, 1, 1],
|
||||
sleep,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
fetchImpl
|
||||
})
|
||||
expect(outcome.status).toBe('stale')
|
||||
if (outcome.status !== 'stale') return
|
||||
expect(outcome.reasonCode).toBe('network')
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(4)
|
||||
expect(sleep).toHaveBeenCalledTimes(3)
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('falls back to snapshot on envelope schema mismatch', async () => {
|
||||
const snapshotUrl = withSnapshotDir(makeSnapshot())
|
||||
const fetchImpl = vi.fn(async () => response({ apiVersion: '2', jobs: [] }))
|
||||
const fetchImpl = mockFetch(async () =>
|
||||
response({ apiVersion: '2', jobs: [] })
|
||||
)
|
||||
const outcome = await fetchRolesForBuild({
|
||||
apiKey: KEY,
|
||||
boardName: BOARD,
|
||||
baseUrl: BASE_URL,
|
||||
snapshotUrl,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
fetchImpl
|
||||
})
|
||||
expect(outcome.status).toBe('stale')
|
||||
if (outcome.status !== 'stale') return
|
||||
expect(outcome.reason).toMatch(/^envelope schema/)
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
expect(outcome.reasonCode).toBe('schema')
|
||||
})
|
||||
|
||||
it('memoizes within a single process', async () => {
|
||||
const fetchImpl = vi.fn(async () =>
|
||||
const fetchImpl = mockFetch(async () =>
|
||||
response({ apiVersion: '1', jobs: [validJob()] })
|
||||
)
|
||||
const opts = {
|
||||
apiKey: KEY,
|
||||
boardName: BOARD,
|
||||
baseUrl: BASE_URL,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
fetchImpl
|
||||
}
|
||||
const [a, b] = await Promise.all([
|
||||
fetchRolesForBuild(opts),
|
||||
@@ -297,7 +333,7 @@ describe('fetchRolesForBuild', () => {
|
||||
const before = new URL(snapshotUrl.href)
|
||||
const fs = await import('node:fs')
|
||||
const initial = fs.readFileSync(before).toString()
|
||||
const fetchImpl = vi.fn(async () =>
|
||||
const fetchImpl = mockFetch(async () =>
|
||||
response({ apiVersion: '1', jobs: [validJob()] })
|
||||
)
|
||||
await fetchRolesForBuild({
|
||||
@@ -305,24 +341,75 @@ describe('fetchRolesForBuild', () => {
|
||||
boardName: BOARD,
|
||||
baseUrl: BASE_URL,
|
||||
snapshotUrl,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
fetchImpl
|
||||
})
|
||||
const after = fs.readFileSync(before).toString()
|
||||
expect(after).toBe(initial)
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('does not retry on 4xx auth failures for 403', async () => {
|
||||
const snapshotUrl = withSnapshotDir(makeSnapshot())
|
||||
const fetchImpl = vi.fn(async () => response({}, { status: 403 }))
|
||||
await fetchRolesForBuild({
|
||||
const fetchImpl = mockFetch(async () => response({}, { status: 403 }))
|
||||
const outcome = await fetchRolesForBuild({
|
||||
apiKey: KEY,
|
||||
boardName: BOARD,
|
||||
baseUrl: BASE_URL,
|
||||
snapshotUrl,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
fetchImpl
|
||||
})
|
||||
expect(outcome.status).toBe('stale')
|
||||
if (outcome.status !== 'stale') return
|
||||
expect(outcome.reason).toMatch(/^HTTP 403/)
|
||||
expect(outcome.reasonCode).toBe('auth')
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('retries on network errors and falls back to snapshot', async () => {
|
||||
const snapshotUrl = withSnapshotDir(makeSnapshot())
|
||||
const fetchImpl = mockFetch(async () => {
|
||||
throw new Error('fetch failed')
|
||||
})
|
||||
const sleep = vi.fn(async () => undefined)
|
||||
const outcome = await fetchRolesForBuild({
|
||||
apiKey: KEY,
|
||||
boardName: BOARD,
|
||||
baseUrl: BASE_URL,
|
||||
snapshotUrl,
|
||||
retryDelaysMs: [1, 1],
|
||||
sleep,
|
||||
fetchImpl
|
||||
})
|
||||
expect(outcome.status).toBe('stale')
|
||||
if (outcome.status !== 'stale') return
|
||||
expect(outcome.reason).toMatch(/^network error:/)
|
||||
expect(outcome.reasonCode).toBe('network')
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(3)
|
||||
expect(sleep).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('groups jobs by department and sorts alphabetically', async () => {
|
||||
const fetchImpl = mockFetch(async () =>
|
||||
response({
|
||||
apiVersion: '1',
|
||||
jobs: [
|
||||
validJob({ title: 'Role Z', department: 'Zzz Dept' }),
|
||||
validJob({ title: 'Role A', department: 'Aaa Dept' }),
|
||||
validJob({ title: 'Role A2', department: 'Aaa Dept' })
|
||||
]
|
||||
})
|
||||
)
|
||||
const outcome = await fetchRolesForBuild({
|
||||
apiKey: KEY,
|
||||
boardName: BOARD,
|
||||
baseUrl: BASE_URL,
|
||||
fetchImpl
|
||||
})
|
||||
expect(outcome.status).toBe('fresh')
|
||||
if (outcome.status !== 'fresh') return
|
||||
expect(outcome.snapshot.departments).toHaveLength(2)
|
||||
expect(outcome.snapshot.departments[0]!.name).toBe('AAA DEPT')
|
||||
expect(outcome.snapshot.departments[0]!.roles).toHaveLength(2)
|
||||
expect(outcome.snapshot.departments[1]!.name).toBe('ZZZ DEPT')
|
||||
expect(outcome.snapshot.departments[1]!.roles).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
|
||||
import type { z } from 'zod'
|
||||
|
||||
import type { AshbyJobPosting } from './ashby.schema'
|
||||
import type { Department, Role, RolesSnapshot } from '../data/roles'
|
||||
|
||||
@@ -19,6 +21,8 @@ export interface DroppedRole {
|
||||
reason: string
|
||||
}
|
||||
|
||||
export type FailureCode = 'missing-env' | 'auth' | 'schema' | 'network'
|
||||
|
||||
export type FetchOutcome =
|
||||
| {
|
||||
status: 'fresh'
|
||||
@@ -26,8 +30,13 @@ export type FetchOutcome =
|
||||
droppedCount: number
|
||||
droppedRoles: DroppedRole[]
|
||||
}
|
||||
| { status: 'stale'; snapshot: RolesSnapshot; reason: string }
|
||||
| { status: 'failed'; reason: string }
|
||||
| {
|
||||
status: 'stale'
|
||||
snapshot: RolesSnapshot
|
||||
reason: string
|
||||
reasonCode: FailureCode
|
||||
}
|
||||
| { status: 'failed'; reason: string; reasonCode: FailureCode }
|
||||
|
||||
interface FetchRolesOptions {
|
||||
apiKey?: string
|
||||
@@ -63,6 +72,7 @@ async function doFetchRolesForBuild(
|
||||
if (!apiKey || !boardName) {
|
||||
return fallback(
|
||||
'missing WEBSITE_ASHBY_API_KEY or WEBSITE_ASHBY_JOB_BOARD_NAME',
|
||||
'missing-env',
|
||||
options.snapshotUrl
|
||||
)
|
||||
}
|
||||
@@ -80,16 +90,17 @@ async function doFetchRolesForBuild(
|
||||
}
|
||||
}
|
||||
|
||||
return fallback(result.reason, options.snapshotUrl)
|
||||
return fallback(result.reason, result.reasonCode, options.snapshotUrl)
|
||||
}
|
||||
|
||||
async function fallback(
|
||||
reason: string,
|
||||
reasonCode: FailureCode,
|
||||
snapshotUrl: URL | undefined
|
||||
): Promise<FetchOutcome> {
|
||||
const snapshot = await readSnapshot(snapshotUrl)
|
||||
if (snapshot) return { status: 'stale', snapshot, reason }
|
||||
return { status: 'failed', reason }
|
||||
if (snapshot) return { status: 'stale', snapshot, reason, reasonCode }
|
||||
return { status: 'failed', reason, reasonCode }
|
||||
}
|
||||
|
||||
interface FetchOk {
|
||||
@@ -101,6 +112,7 @@ interface FetchOk {
|
||||
interface FetchErr {
|
||||
kind: 'err'
|
||||
reason: string
|
||||
reasonCode: FailureCode
|
||||
}
|
||||
|
||||
async function tryFetchAndParse(
|
||||
@@ -134,21 +146,20 @@ async function tryFetchAndParse(
|
||||
if (!envelope.success) {
|
||||
return {
|
||||
kind: 'err',
|
||||
reason: `envelope schema validation failed: ${envelope.error.issues
|
||||
.map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)
|
||||
.join('; ')}`
|
||||
reasonCode: 'schema',
|
||||
reason: `envelope schema validation failed: ${formatZodIssues(envelope.error.issues)}`
|
||||
}
|
||||
}
|
||||
|
||||
return parseRoles(envelope.data.jobs)
|
||||
}
|
||||
|
||||
return { kind: 'err', reason: lastReason }
|
||||
return { kind: 'err', reason: lastReason, reasonCode: 'network' }
|
||||
}
|
||||
|
||||
type CallResponse =
|
||||
| { kind: 'ok'; body: unknown }
|
||||
| { kind: 'err'; reason: string; retryable: boolean }
|
||||
| { kind: 'err'; reason: string; reasonCode: FailureCode; retryable: boolean }
|
||||
|
||||
async function callOnce(
|
||||
fetchImpl: typeof fetch,
|
||||
@@ -172,9 +183,12 @@ async function callOnce(
|
||||
}
|
||||
const retryable =
|
||||
res.status === 429 || (res.status >= 500 && res.status < 600)
|
||||
const reasonCode: FailureCode =
|
||||
res.status === 401 || res.status === 403 ? 'auth' : 'network'
|
||||
return {
|
||||
kind: 'err',
|
||||
reason: `HTTP ${res.status} ${res.statusText || ''}`.trim(),
|
||||
reasonCode,
|
||||
retryable
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -182,7 +196,7 @@ async function callOnce(
|
||||
error instanceof Error
|
||||
? `network error: ${error.message}`
|
||||
: 'network error'
|
||||
return { kind: 'err', reason, retryable: true }
|
||||
return { kind: 'err', reason, reasonCode: 'network', retryable: true }
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
@@ -197,9 +211,7 @@ function parseRoles(jobs: readonly unknown[]): FetchOk {
|
||||
if (!parsed.success) {
|
||||
droppedRoles.push({
|
||||
title: extractTitle(raw),
|
||||
reason: parsed.error.issues
|
||||
.map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)
|
||||
.join('; ')
|
||||
reason: formatZodIssues(parsed.error.issues)
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -211,15 +223,9 @@ function parseRoles(jobs: readonly unknown[]): FetchOk {
|
||||
}
|
||||
|
||||
function extractTitle(raw: unknown): string {
|
||||
if (
|
||||
raw !== null &&
|
||||
typeof raw === 'object' &&
|
||||
'title' in raw &&
|
||||
typeof (raw as { title: unknown }).title === 'string'
|
||||
) {
|
||||
return (raw as { title: string }).title
|
||||
}
|
||||
return ''
|
||||
if (typeof raw !== 'object' || raw === null) return ''
|
||||
const title = (raw as Record<string, unknown>).title
|
||||
return typeof title === 'string' ? title : ''
|
||||
}
|
||||
|
||||
const DEFAULT_DEPARTMENT = 'Other'
|
||||
@@ -266,7 +272,7 @@ function slugify(value: string): string {
|
||||
}
|
||||
|
||||
function capitalize(value: string): string {
|
||||
return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase()
|
||||
return value.toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
async function readSnapshot(
|
||||
@@ -290,10 +296,22 @@ function isRolesSnapshot(value: unknown): value is RolesSnapshot {
|
||||
const candidate = value as { fetchedAt?: unknown; departments?: unknown }
|
||||
return (
|
||||
typeof candidate.fetchedAt === 'string' &&
|
||||
Array.isArray(candidate.departments)
|
||||
Array.isArray(candidate.departments) &&
|
||||
candidate.departments.every(
|
||||
(d) =>
|
||||
typeof d === 'object' &&
|
||||
d !== null &&
|
||||
Array.isArray((d as Department).roles)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function formatZodIssues(issues: z.ZodIssue[]): string {
|
||||
return issues
|
||||
.map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)
|
||||
.join('; ')
|
||||
}
|
||||
|
||||
function defaultSleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user