Spaces:
Running
Running
| import { | |
| GIT_ARCHIVE_MAX_BUFFER_BYTES, | |
| assertDeployMarkerMatches, | |
| buildDeployMarker, | |
| buildDeployMarkerRouteSource, | |
| isSpaceDeployPath, | |
| isHfUploadExistingSpacePolicyError, | |
| isRetryableSpaceInfoReadError, | |
| shouldUseGitPushForSpace, | |
| buildUploadArgs, | |
| extractUploadCommitSha, | |
| findRemoteDeletePaths, | |
| parseRepositorySlug, | |
| rewriteSpaceReadmeImageSources, | |
| waitForRunning | |
| } from './deploy-hf-space.mjs'; | |
| import assert from 'node:assert/strict'; | |
| import { describe, it } from 'node:test'; | |
| describe('HF Space deploy script', () => { | |
| it('recognizes the existing Docker Space create-policy error for the Git deployment fallback', () => { | |
| const policyError = [ | |
| 'Set HF_DEBUG=1 as environment variable for full traceback.', | |
| "Error: Client error '402 Payment Required' for url 'https://huggingface.co/api/repos/create'", | |
| 'Static Spaces are free for everyone, but hosting Gradio and Docker Spaces on free cpu-basic requires a PRO subscription.' | |
| ].join('\n'); | |
| assert.equal(isHfUploadExistingSpacePolicyError(policyError), true); | |
| assert.equal( | |
| isHfUploadExistingSpacePolicyError( | |
| "Client error '402 Payment Required' for url 'https://huggingface.co/api/models/create'" | |
| ), | |
| false | |
| ); | |
| assert.equal( | |
| isHfUploadExistingSpacePolicyError( | |
| "Client error '401 Unauthorized' for url 'https://huggingface.co/api/repos/create'" | |
| ), | |
| false | |
| ); | |
| }); | |
| it('uses Git directly for an existing Docker Space', () => { | |
| assert.equal(shouldUseGitPushForSpace({ sdk: 'docker' }), true); | |
| assert.equal(shouldUseGitPushForSpace({ sdk: 'gradio' }), false); | |
| assert.equal(shouldUseGitPushForSpace({}), false); | |
| }); | |
| it('extracts the Space commit SHA from hf upload JSON output', () => { | |
| const sha = extractUploadCommitSha( | |
| [ | |
| 'Start hashing 189 files.', | |
| '{"url":"https://huggingface.co/spaces/misonL/visual-journal/commit/32151b6aeaec0e59f14d1aaa87fba160ca8410df"}' | |
| ].join('\n') | |
| ); | |
| assert.equal(sha, '32151b6aeaec0e59f14d1aaa87fba160ca8410df'); | |
| }); | |
| it('rejects upload output without a Space commit URL', () => { | |
| assert.throws( | |
| () => extractUploadCommitSha('{"url":"https://huggingface.co/spaces/misonL/demo"}'), | |
| /commit SHA or commit URL/ | |
| ); | |
| }); | |
| it('extracts the Space commit SHA from direct hf upload JSON fields', () => { | |
| assert.equal( | |
| extractUploadCommitSha('{"sha":"cccccccccccccccccccccccccccccccccccccccc"}'), | |
| 'cccccccccccccccccccccccccccccccccccccccc' | |
| ); | |
| assert.equal( | |
| extractUploadCommitSha('{"commit_sha":"dddddddddddddddddddddddddddddddddddddddd"}'), | |
| 'dddddddddddddddddddddddddddddddddddddddd' | |
| ); | |
| }); | |
| it('extracts the Space commit SHA when hf prints warning lines before JSON', () => { | |
| const sha = extractUploadCommitSha( | |
| [ | |
| '[WARN] retrying upload metadata', | |
| '{"url":"https://huggingface.co/spaces/misonL/visual-journal/commit/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}', | |
| 'done' | |
| ].join('\n') | |
| ); | |
| assert.equal(sha, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); | |
| }); | |
| it('builds upload args without embedding newline characters in commit metadata', () => { | |
| const args = buildUploadArgs({ | |
| sourceDir: '/tmp/source', | |
| localSha: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', | |
| repoSlug: 'MisonL/visual-journal' | |
| }); | |
| assert.equal(args.includes('--json'), true); | |
| assert.equal(args[args.indexOf('--commit-message') + 1], 'Deploy bbbbbbb to Docker Space'); | |
| assert.equal( | |
| args[args.indexOf('--commit-description') + 1], | |
| 'Source: MisonL/visual-journal@bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' | |
| ); | |
| assert.equal( | |
| args.some((arg) => arg.includes('\n')), | |
| false | |
| ); | |
| }); | |
| it('uses a provided repository slug in upload commit metadata', () => { | |
| const args = buildUploadArgs({ | |
| sourceDir: '/tmp/source', | |
| localSha: 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', | |
| repoSlug: 'owner/repo' | |
| }); | |
| assert.equal( | |
| args[args.indexOf('--commit-description') + 1], | |
| 'Source: owner/repo@eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' | |
| ); | |
| }); | |
| it('parses GitHub repository slugs from common origin URL formats', () => { | |
| assert.equal( | |
| parseRepositorySlug('https://github.com/MisonL/visual-journal.git'), | |
| 'MisonL/visual-journal' | |
| ); | |
| assert.equal( | |
| parseRepositorySlug('git@github.com:MisonL/visual-journal.git'), | |
| 'MisonL/visual-journal' | |
| ); | |
| assert.throws(() => parseRepositorySlug('not-a-github-url'), /Set REPO_SLUG=owner\/repo/); | |
| }); | |
| it('requires an explicit repository slug for upload metadata', () => { | |
| assert.throws( | |
| () => | |
| buildUploadArgs({ | |
| sourceDir: '/tmp/source', | |
| localSha: 'ffffffffffffffffffffffffffffffffffffffff' | |
| }), | |
| /REPO_SLUG is required/ | |
| ); | |
| }); | |
| it('keeps enough buffer for repository archives used by Space uploads', () => { | |
| assert.ok(GIT_ARCHIVE_MAX_BUFFER_BYTES >= 128 * 1024 * 1024); | |
| }); | |
| it('excludes README documentation binaries from the Space source tree', () => { | |
| assert.equal(isSpaceDeployPath('README.md'), true); | |
| assert.equal(isSpaceDeployPath('src/app/page.tsx'), true); | |
| assert.equal(isSpaceDeployPath('readme-images/interface.jpg'), false); | |
| assert.equal(isSpaceDeployPath('readme-images/nested/example.jpg'), false); | |
| }); | |
| it('rewrites local README image sources to immutable GitHub sources for the Space', () => { | |
| const localSha = '1111111111111111111111111111111111111111'; | |
| const readme = '<img src="./readme-images/interface.jpg?v=123" alt="Interface" />'; | |
| assert.equal( | |
| rewriteSpaceReadmeImageSources(readme, 'owner/repo', localSha), | |
| `<img src="https://raw.githubusercontent.com/owner/repo/${localSha}/readme-images/interface.jpg?v=123" alt="Interface" />` | |
| ); | |
| }); | |
| it('adds delete args for remote files that are no longer in git HEAD', () => { | |
| const args = buildUploadArgs({ | |
| sourceDir: '/tmp/source', | |
| localSha: '1111111111111111111111111111111111111111', | |
| repoSlug: 'owner/repo', | |
| deletePaths: ['old-file.md', 'scripts/old-script.mjs'] | |
| }); | |
| assert.deepEqual(args.slice(-4), ['--delete', 'old-file.md', '--delete', 'scripts/old-script.mjs']); | |
| }); | |
| it('rejects unsafe delete path values before invoking hf upload', () => { | |
| assert.throws( | |
| () => | |
| buildUploadArgs({ | |
| sourceDir: '/tmp/source', | |
| localSha: '2222222222222222222222222222222222222222', | |
| repoSlug: 'owner/repo', | |
| deletePaths: ['bad\npath'] | |
| }), | |
| /deletePaths must contain/ | |
| ); | |
| }); | |
| it('finds stale remote files deterministically', () => { | |
| assert.deepEqual( | |
| findRemoteDeletePaths(new Set(['README.md', 'src/app.ts']), ['old.md', 'src/app.ts', 'README.md']), | |
| ['old.md'] | |
| ); | |
| }); | |
| it('builds a non-secret deploy marker for service-side deployment verification', () => { | |
| const marker = buildDeployMarker( | |
| '3333333333333333333333333333333333333333', | |
| new Date('2026-06-20T10:00:00.000Z'), | |
| 'deploy-333' | |
| ); | |
| assert.deepEqual(marker, { | |
| schema_version: 1, | |
| local_sha: '3333333333333333333333333333333333333333', | |
| created_at: '2026-06-20T10:00:00.000Z', | |
| deploy_id: 'deploy-333' | |
| }); | |
| assert.doesNotMatch(JSON.stringify(marker), /key|token|secret|password/i); | |
| }); | |
| it('builds a unique deploy marker id when none is provided', () => { | |
| const first = buildDeployMarker( | |
| '3333333333333333333333333333333333333333', | |
| new Date('2026-06-20T10:00:00.000Z') | |
| ); | |
| const second = buildDeployMarker( | |
| '3333333333333333333333333333333333333333', | |
| new Date('2026-06-20T10:00:00.000Z') | |
| ); | |
| assert.match(first.deploy_id, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); | |
| assert.notEqual(first.deploy_id, second.deploy_id); | |
| }); | |
| it('rejects short deploy marker commit shas before upload', () => { | |
| assert.throws(() => buildDeployMarker('3333333'), /full git commit SHA/); | |
| }); | |
| it('rejects unsafe deploy marker ids before upload', () => { | |
| assert.throws( | |
| () => | |
| buildDeployMarker( | |
| '3333333333333333333333333333333333333333', | |
| new Date('2026-06-20T10:00:00.000Z'), | |
| 'deploy\n333' | |
| ), | |
| /deployId must be/ | |
| ); | |
| }); | |
| it('rejects stale deploy markers from an older same-commit deployment', () => { | |
| const expected = buildDeployMarker( | |
| '3333333333333333333333333333333333333333', | |
| new Date('2026-06-20T10:00:00.000Z'), | |
| 'deploy-current' | |
| ); | |
| const stale = buildDeployMarker( | |
| '3333333333333333333333333333333333333333', | |
| new Date('2026-06-20T09:00:00.000Z'), | |
| 'deploy-stale' | |
| ); | |
| assert.throws(() => assertDeployMarkerMatches(stale, expected), /created_at mismatch/); | |
| assert.deepEqual(assertDeployMarkerMatches(expected, expected), expected); | |
| }); | |
| it('rejects deploy markers with a stale deploy id even when the source commit and timestamp match', () => { | |
| const expected = buildDeployMarker( | |
| '3333333333333333333333333333333333333333', | |
| new Date('2026-06-20T10:00:00.000Z'), | |
| 'deploy-current' | |
| ); | |
| const stale = { | |
| ...expected, | |
| deploy_id: 'deploy-stale' | |
| }; | |
| assert.throws(() => assertDeployMarkerMatches(stale, expected), /deploy_id mismatch/); | |
| }); | |
| it('waits for the public service marker after management reports the new Space commit running', async () => { | |
| const marker = buildDeployMarker( | |
| '5555555555555555555555555555555555555555', | |
| new Date('2026-06-20T12:00:00.000Z'), | |
| 'deploy-555' | |
| ); | |
| let verifyCount = 0; | |
| const runtime = await waitForRunning('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', marker, { | |
| attempts: 2, | |
| intervalMs: 0, | |
| readInfo: () => ({ | |
| runtime: { stage: 'RUNNING' }, | |
| sha: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' | |
| }), | |
| verifyMarker: async () => { | |
| verifyCount += 1; | |
| if (verifyCount === 1) throw new Error('deploy marker deploy_id mismatch'); | |
| return marker; | |
| }, | |
| sleep: async () => {}, | |
| log: () => {} | |
| }); | |
| assert.equal(verifyCount, 2); | |
| assert.deepEqual(runtime, { | |
| stage: 'RUNNING', | |
| sha: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', | |
| service_marker_verified: true, | |
| marker | |
| }); | |
| }); | |
| it('retries a transient Hugging Face Space status transport error before verifying the new marker', async () => { | |
| const marker = buildDeployMarker( | |
| '6666666666666666666666666666666666666666', | |
| new Date('2026-06-20T13:00:00.000Z'), | |
| 'deploy-666' | |
| ); | |
| let readCount = 0; | |
| let sleepCount = 0; | |
| const runtime = await waitForRunning('bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', marker, { | |
| attempts: 2, | |
| intervalMs: 0, | |
| readInfo: () => { | |
| readCount += 1; | |
| if (readCount === 1) { | |
| throw new Error( | |
| 'httpcore.ConnectError: [SSL: UNEXPECTED_EOF_WHILE_READING] EOF occurred in violation of protocol' | |
| ); | |
| } | |
| return { | |
| runtime: { stage: 'RUNNING' }, | |
| sha: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' | |
| }; | |
| }, | |
| verifyMarker: async () => marker, | |
| sleep: async () => { | |
| sleepCount += 1; | |
| }, | |
| log: () => {} | |
| }); | |
| assert.equal(readCount, 2); | |
| assert.equal(sleepCount, 1); | |
| assert.deepEqual(runtime, { | |
| stage: 'RUNNING', | |
| sha: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', | |
| service_marker_verified: true, | |
| marker | |
| }); | |
| }); | |
| it('does not sleep after the final transient management status error before verifying the marker', async () => { | |
| const marker = buildDeployMarker( | |
| '7777777777777777777777777777777777777777', | |
| new Date('2026-06-20T14:00:00.000Z'), | |
| 'deploy-777' | |
| ); | |
| let sleepCount = 0; | |
| const runtime = await waitForRunning('cccccccccccccccccccccccccccccccccccccccc', marker, { | |
| attempts: 1, | |
| intervalMs: 10_000, | |
| readInfo: () => { | |
| throw new Error('httpx.ReadTimeout: timed out while reading Space status'); | |
| }, | |
| verifyMarker: async (expected) => assertDeployMarkerMatches(marker, expected), | |
| sleep: async () => { | |
| sleepCount += 1; | |
| }, | |
| log: () => {} | |
| }); | |
| assert.equal(sleepCount, 0); | |
| assert.deepEqual(runtime, { | |
| stage: 'unknown', | |
| sha: 'unknown', | |
| management_status: 'target_commit_not_confirmed', | |
| verification_source: 'service_marker_after_management_timeout', | |
| service_marker_verified: true, | |
| warning: | |
| 'Hugging Face management status did not confirm RUNNING for cccccccccccccccccccccccccccccccccccccccc, but the public service returned the exact one-time deploy marker; last stage=unknown sha=unknown marker_error=unknown management_error=httpx.ReadTimeout: timed out while reading Space status', | |
| marker | |
| }); | |
| }); | |
| it('accepts an exact one-time service marker when management status remains stale', async () => { | |
| const marker = buildDeployMarker( | |
| '8888888888888888888888888888888888888888', | |
| new Date('2026-06-20T15:00:00.000Z'), | |
| 'deploy-888' | |
| ); | |
| let sleepCount = 0; | |
| const runtime = await waitForRunning('dddddddddddddddddddddddddddddddddddddddd', marker, { | |
| attempts: 1, | |
| intervalMs: 0, | |
| readInfo: () => ({ runtime: { stage: 'RUNNING' }, sha: 'stale-space-sha' }), | |
| verifyMarker: async (expected) => assertDeployMarkerMatches(marker, expected), | |
| sleep: async () => { | |
| sleepCount += 1; | |
| }, | |
| log: () => {} | |
| }); | |
| assert.equal(sleepCount, 0); | |
| assert.deepEqual(runtime, { | |
| stage: 'RUNNING', | |
| sha: 'stale-space-sha', | |
| management_status: 'target_commit_not_confirmed', | |
| verification_source: 'service_marker_after_management_timeout', | |
| service_marker_verified: true, | |
| warning: | |
| 'Hugging Face management status did not confirm RUNNING for dddddddddddddddddddddddddddddddddddddddd, but the public service returned the exact one-time deploy marker; last stage=RUNNING sha=stale-space-sha marker_error=unknown management_error=none', | |
| marker | |
| }); | |
| }); | |
| it('rejects a stale service marker after management status times out', async () => { | |
| const marker = buildDeployMarker( | |
| '9999999999999999999999999999999999999999', | |
| new Date('2026-06-20T16:00:00.000Z'), | |
| 'deploy-999' | |
| ); | |
| const staleMarker = { ...marker, deploy_id: 'deploy-stale' }; | |
| await assert.rejects( | |
| waitForRunning('eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', marker, { | |
| attempts: 1, | |
| intervalMs: 0, | |
| readInfo: () => ({ runtime: { stage: 'RUNNING' }, sha: 'stale-space-sha' }), | |
| verifyMarker: async (expected) => assertDeployMarkerMatches(staleMarker, expected), | |
| sleep: async () => {}, | |
| log: () => {} | |
| }), | |
| /deploy_id mismatch/ | |
| ); | |
| }); | |
| it('only classifies transient Hugging Face management transport errors as retryable', () => { | |
| assert.equal( | |
| isRetryableSpaceInfoReadError( | |
| new Error('httpcore.ConnectError: [SSL: UNEXPECTED_EOF_WHILE_READING] EOF occurred in violation of protocol') | |
| ), | |
| true | |
| ); | |
| assert.equal(isRetryableSpaceInfoReadError(new Error('hf spaces info failed with HTTP 401 Unauthorized')), false); | |
| }); | |
| it('does not retry a non-transient Space status failure', async () => { | |
| const marker = buildDeployMarker( | |
| '7777777777777777777777777777777777777777', | |
| new Date('2026-06-20T14:00:00.000Z'), | |
| 'deploy-777' | |
| ); | |
| await assert.rejects( | |
| waitForRunning('cccccccccccccccccccccccccccccccccccccccc', marker, { | |
| attempts: 2, | |
| intervalMs: 0, | |
| readInfo: () => { | |
| throw new Error('hf spaces info failed with HTTP 401 Unauthorized'); | |
| }, | |
| verifyMarker: async () => marker, | |
| sleep: async () => {}, | |
| log: () => {} | |
| }), | |
| /HTTP 401 Unauthorized/ | |
| ); | |
| }); | |
| it('builds a no-store API route for service-side deploy marker verification', () => { | |
| const marker = buildDeployMarker( | |
| '4444444444444444444444444444444444444444', | |
| new Date('2026-06-20T11:00:00.000Z'), | |
| 'deploy-444' | |
| ); | |
| const source = buildDeployMarkerRouteSource(marker); | |
| assert.match(source, /NextResponse\.json/); | |
| assert.match(source, /Cache-Control/); | |
| assert.match(source, /no-store/); | |
| assert.match(source, /4444444444444444444444444444444444444444/); | |
| assert.match(source, /deploy-444/); | |
| assert.doesNotMatch(source, /key|token|secret|password/i); | |
| }); | |
| it('keeps the generated deploy markers from being deleted on upload', () => { | |
| assert.deepEqual( | |
| findRemoteDeletePaths( | |
| new Set(['README.md', 'public/hf-space-deploy-marker.json', 'src/app/api/deploy-marker/route.ts']), | |
| ['README.md', 'public/hf-space-deploy-marker.json', 'src/app/api/deploy-marker/route.ts', 'old.md'] | |
| ), | |
| ['old.md'] | |
| ); | |
| }); | |
| }); | |