mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
Publish-public and deploy failures were only visible as one opaque bucket
each (`publish_failed`, generic HTTP_400 / Error), so neither PostHog nor the
automatic diagnostics bundles could tell why they failed.
Purely additive — no existing status, error code, event value, trigger or
copy changes:
- daemon: failed publish/unpublish/deploy responses gain an optional
closed-token `failure` { stage, reason, upstreamStatus?, upstreamCode? }.
Vela CLI failures are classified from the CLI's stable stderr contract
(vela-cli 0.0.33–0.1.3); deploy failures separate provider rejection,
unreachable provider, rejected Vercel token, local file and internal
errors. Each failure also writes one structured, text-free log line.
- web: artifact_publish_result / artifact_deploy_result gain optional
failed_stage, failure_reason, upstream_status, upstream_error_code, and
(publish) daemon_error_code. `error_code` keeps its values.
- correlation: each attempt's analytics request id is sent as
x-od-request-id, logged by the daemon, and reported as request_id.
44 lines
2.7 KiB
TypeScript
44 lines
2.7 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { DeployError } from '../src/deploy.js';
|
|
import { classifyDeployFailure } from '../src/deploy/failure-detail.js';
|
|
|
|
const classify = (error: unknown) =>
|
|
classifyDeployFailure('provider', error, error instanceof DeployError);
|
|
|
|
describe('classifyDeployFailure', () => {
|
|
it('keeps a coded DeployError as coded without claiming the daemon status is upstream', () => {
|
|
expect(classify(new DeployError('Missing refs', 400, undefined, 'MISSING_REFERENCES')))
|
|
.toEqual({ stage: 'provider', reason: 'coded' });
|
|
});
|
|
|
|
it('separates a rejected Vercel token from a real permission failure', () => {
|
|
const body = { error: { code: 'forbidden', message: 'Not authorized', invalidToken: true } };
|
|
expect(classify(new DeployError("You don't have permission to create a project.", 403, body, 'PROVIDER_FORBIDDEN')))
|
|
.toEqual({ stage: 'provider', reason: 'provider_token_invalid', upstreamStatus: 403, upstreamCode: 'forbidden' });
|
|
const permission = { error: { code: 'forbidden', message: 'Team permission required' } };
|
|
expect(classify(new DeployError("You don't have permission to create a project.", 403, permission, 'PROVIDER_FORBIDDEN')))
|
|
.toEqual({ stage: 'provider', reason: 'coded', upstreamCode: 'forbidden' });
|
|
});
|
|
|
|
it('reports a provider rejection with its status and provider code', () => {
|
|
expect(classify(new DeployError('Too many requests.', 429, { error: { code: 'too_many_requests' } })))
|
|
.toEqual({ stage: 'provider', reason: 'provider_rejected', upstreamStatus: 429, upstreamCode: 'too_many_requests' });
|
|
expect(classify(new DeployError('Invalid request headers', 400, { success: false, errors: [{ code: 6003 }] })))
|
|
.toEqual({ stage: 'provider', reason: 'provider_rejected', upstreamStatus: 400, upstreamCode: '6003' });
|
|
});
|
|
|
|
it('recognises an unreachable provider behind a fetch failure', () => {
|
|
const fetchFailed = new TypeError('fetch failed', { cause: Object.assign(new Error('connect'), { code: 'ECONNREFUSED' }) });
|
|
expect(classify(fetchFailed)).toEqual({ stage: 'provider', reason: 'provider_unreachable' });
|
|
expect(classify(Object.assign(new Error('aborted'), { name: 'TimeoutError' })))
|
|
.toEqual({ stage: 'provider', reason: 'provider_unreachable' });
|
|
});
|
|
|
|
it('separates local file errors from everything else', () => {
|
|
const enoent = Object.assign(new Error('ENOENT: no such file'), { code: 'ENOENT', syscall: 'open' });
|
|
expect(classifyDeployFailure('file_plan', enoent, false)).toEqual({ stage: 'file_plan', reason: 'local_file' });
|
|
expect(classify(new Error('unexpected'))).toEqual({ stage: 'provider', reason: 'internal' });
|
|
expect(classify(undefined)).toEqual({ stage: 'provider', reason: 'internal' });
|
|
});
|
|
});
|