Files
open-design/apps/daemon/tests/deploy-failure-detail.test.ts
PerishFireandGitHub 7bad1a45f2 feat(telemetry): classify publish and deploy failures additively (#8402)
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.
2026-09-23 12:19:20 +00:00

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' });
});
});