diff --git a/.github/actions/ax-score/action.yml b/.github/actions/ax-score/action.yml new file mode 100644 index 0000000..a399871 --- /dev/null +++ b/.github/actions/ax-score/action.yml @@ -0,0 +1,110 @@ +name: 'AX Score Audit' +description: 'Run an AX Score audit to measure how agent-friendly your website or API is' +branding: + icon: 'activity' + color: 'purple' + +inputs: + url: + description: 'URL to audit' + required: true + threshold: + description: 'Minimum acceptable score (0-100). The action fails if the score is below this value.' + required: false + default: '50' + upload: + description: 'Whether to upload results to the AgentGram hosted API' + required: false + default: 'false' + api-key: + description: 'AgentGram API key for uploading results (required if upload is true)' + required: false + api-url: + description: 'Custom API endpoint for uploading results' + required: false + default: 'https://agentgram.co/api/v1/ax-score/scan' + format: + description: 'Output format (cli, json)' + required: false + default: 'cli' + timeout: + description: 'Request timeout in milliseconds' + required: false + default: '30000' + version: + description: 'Version of @agentgram/ax-score to install' + required: false + default: 'latest' + +outputs: + score: + description: 'The overall AX score (0-100)' + value: ${{ steps.audit.outputs.score }} + report: + description: 'Full JSON report' + value: ${{ steps.audit.outputs.report }} + +runs: + using: 'composite' + steps: + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install ax-score + shell: bash + run: npm install -g @agentgram/ax-score@${{ inputs.version }} + + - name: Run AX Score audit + id: audit + shell: bash + env: + AGENTGRAM_API_KEY: ${{ inputs.api-key }} + run: | + set +e + + UPLOAD_FLAGS="" + if [ "${{ inputs.upload }}" = "true" ]; then + UPLOAD_FLAGS="--upload --api-url ${{ inputs.api-url }}" + fi + + JSON_OUTPUT=$(ax-score "${{ inputs.url }}" \ + --format json \ + --timeout "${{ inputs.timeout }}" \ + $UPLOAD_FLAGS) + EXIT_CODE=$? + + SCORE=$(echo "$JSON_OUTPUT" | node -e " + let data = ''; + process.stdin.on('data', chunk => data += chunk); + process.stdin.on('end', () => { + try { + const report = JSON.parse(data); + console.log(report.score); + } catch { + console.log('0'); + } + }); + ") + + echo "score=$SCORE" >> "$GITHUB_OUTPUT" + + { + echo "report<> "$GITHUB_OUTPUT" + + # Also print human-readable output + if [ "${{ inputs.format }}" = "cli" ]; then + ax-score "${{ inputs.url }}" --timeout "${{ inputs.timeout }}" || true + else + echo "$JSON_OUTPUT" + fi + + # Check threshold + if [ "$SCORE" -lt "${{ inputs.threshold }}" ]; then + echo "::error::AX Score ($SCORE) is below the threshold (${{ inputs.threshold }})" + exit 1 + fi diff --git a/docs/ci-integration.md b/docs/ci-integration.md new file mode 100644 index 0000000..6a6e107 --- /dev/null +++ b/docs/ci-integration.md @@ -0,0 +1,142 @@ +# CI Integration Guide + +Run AX Score audits automatically in your CI/CD pipeline using the provided GitHub Action. + +--- + +## GitHub Action + +### Basic Usage + +Add the following to a workflow file (e.g., `.github/workflows/ax-score.yml`): + +```yaml +name: AX Score Audit +on: + schedule: + - cron: '0 6 * * 1' # Every Monday at 06:00 UTC + push: + branches: [main] + workflow_dispatch: + +jobs: + audit: + runs-on: ubuntu-latest + steps: + - name: Run AX Score + uses: agentgram/ax-score/.github/actions/ax-score@main + with: + url: 'https://your-api.example.com' +``` + +### With Score Threshold + +Fail the workflow if the score drops below a minimum value: + +```yaml + - name: Run AX Score + uses: agentgram/ax-score/.github/actions/ax-score@main + with: + url: 'https://your-api.example.com' + threshold: '70' +``` + +### With Upload to AgentGram + +Upload results to the AgentGram hosted platform to track score over time: + +```yaml + - name: Run AX Score + uses: agentgram/ax-score/.github/actions/ax-score@main + with: + url: 'https://your-api.example.com' + upload: 'true' + api-key: ${{ secrets.AGENTGRAM_API_KEY }} +``` + +### Using Outputs + +Access the score and full report in subsequent steps: + +```yaml + - name: Run AX Score + id: ax + uses: agentgram/ax-score/.github/actions/ax-score@main + with: + url: 'https://your-api.example.com' + + - name: Comment on PR + if: github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: `## AX Score: ${{ steps.ax.outputs.score }}/100` + }) +``` + +### Full Configuration + +All available inputs: + +```yaml + - name: Run AX Score + uses: agentgram/ax-score/.github/actions/ax-score@main + with: + url: 'https://your-api.example.com' # Required + threshold: '50' # Minimum score (default: 50) + upload: 'false' # Upload results (default: false) + api-key: ${{ secrets.AGENTGRAM_API_KEY }} # Required if upload=true + api-url: 'https://agentgram.co/api/v1/ax-score/scan' # Custom API URL + format: 'cli' # Output format: cli or json + timeout: '30000' # Timeout in ms (default: 30000) + version: 'latest' # ax-score version (default: latest) +``` + +--- + +## Inputs Reference + +| Input | Required | Default | Description | +| ----------- | -------- | ------------------------------------------------ | -------------------------------------------- | +| `url` | Yes | - | URL to audit | +| `threshold` | No | `50` | Minimum acceptable score (0-100) | +| `upload` | No | `false` | Upload results to AgentGram API | +| `api-key` | No | - | AgentGram API key (required if upload=true) | +| `api-url` | No | `https://agentgram.co/api/v1/ax-score/scan` | Custom upload API endpoint | +| `format` | No | `cli` | Output format (cli, json) | +| `timeout` | No | `30000` | Request timeout in milliseconds | +| `version` | No | `latest` | Version of @agentgram/ax-score to install | + +## Outputs Reference + +| Output | Description | +| -------- | ------------------------------- | +| `score` | The overall AX score (0-100) | +| `report` | The full JSON report as a string | + +--- + +## Manual CLI in CI + +If you prefer not to use the composite action, you can install and run ax-score directly: + +```yaml +jobs: + audit: + runs-on: ubuntu-latest + steps: + - name: Install ax-score + run: npm install -g @agentgram/ax-score + + - name: Run audit + run: ax-score https://your-api.example.com --format json + + - name: Run audit with upload + env: + AGENTGRAM_API_KEY: ${{ secrets.AGENTGRAM_API_KEY }} + run: ax-score https://your-api.example.com --upload +``` diff --git a/docs/json-output-contract.md b/docs/json-output-contract.md new file mode 100644 index 0000000..4cb096f --- /dev/null +++ b/docs/json-output-contract.md @@ -0,0 +1,200 @@ +# JSON Output Contract + +This document describes the JSON output schema returned by `runAudit()` and the `--format json` CLI flag. + +The output follows the `AXReport` TypeScript interface defined in `src/types.ts`. + +--- + +## Top-Level Schema: `AXReport` + +| Field | Type | Description | +| ----------------- | ----------------------------- | ------------------------------------------------ | +| `url` | `string` | The URL that was audited | +| `timestamp` | `string` | ISO 8601 timestamp of when the audit was run | +| `version` | `string` | The ax-score version used to generate the report | +| `score` | `number` | Overall AX score (0-100) | +| `categories` | `AXCategory[]` | Array of category scores | +| `audits` | `Record` | Map of audit ID to audit result | +| `recommendations` | `Recommendation[]` | Actionable recommendations sorted by impact | + +--- + +## `AXCategory` + +| Field | Type | Description | +| ------------- | ------------ | ---------------------------------------------------- | +| `id` | `string` | Category identifier (e.g., `"discovery"`) | +| `title` | `string` | Human-readable category name | +| `description` | `string` | What this category measures | +| `score` | `number` | Category score (0-100) | +| `weight` | `number` | How much this category contributes to overall score | +| `auditRefs` | `AuditRef[]` | References to the audits that belong to this category | + +## `AuditRef` + +| Field | Type | Description | +| -------- | -------- | ------------------------------------------------ | +| `id` | `string` | References an audit ID in the `audits` map | +| `weight` | `number` | Weight of this audit within its parent category | + +--- + +## `AuditResult` + +| Field | Type | Description | +| ------------------ | --------------------------------------------- | ---------------------------------------- | +| `id` | `string` | Unique audit identifier | +| `title` | `string` | Pass or fail title text | +| `description` | `string` | What this audit checks | +| `score` | `number` | Score between 0 and 1 (0=fail, 1=pass) | +| `weight` | `number` | Always `0` in results (weight is on ref) | +| `scoreDisplayMode` | `"numeric" \| "binary" \| "informative"` | How to interpret the score | +| `details` | `AuditDetails \| undefined` | Optional structured diagnostic details | + +## `AuditDetails` + +| Field | Type | Description | +| --------- | ------------------------------------- | ----------------------------------- | +| `type` | `"table" \| "list" \| "text"` | How the details should be rendered | +| `items` | `Array>` | Optional data rows | +| `summary` | `string \| undefined` | Optional human-readable summary | + +--- + +## `Recommendation` + +| Field | Type | Description | +| --------- | -------- | --------------------------------------------- | +| `audit` | `string` | The audit ID this recommendation relates to | +| `message` | `string` | The audit description explaining the issue | +| `impact` | `number` | Potential score improvement (higher = better) | + +--- + +## Audit IDs + +There are 19 audits organized into 6 categories: + +### Discovery +- `llms-txt` -- Checks for `/llms.txt` +- `openapi-spec` -- Checks for `/openapi.json` +- `robots-ai` -- Checks if robots.txt allows AI agents +- `ai-plugin` -- Checks for `/.well-known/ai-plugin.json` +- `schema-org` -- Checks for JSON-LD Schema.org data + +### API Quality +- `openapi-valid` -- Validates OpenAPI spec structure +- `response-format` -- Checks for JSON content type +- `response-examples` -- Checks for examples in OpenAPI spec +- `content-negotiation` -- Checks content negotiation support + +### Structured Data +- `json-ld` -- Checks for JSON-LD structured data +- `meta-tags` -- Checks essential meta tags +- `semantic-html` -- Checks for semantic HTML5 elements + +### Auth & Onboarding +- `self-service-auth` -- Checks for programmatic auth endpoints +- `no-captcha` -- Verifies no CAPTCHA is required + +### Error Handling +- `error-codes` -- Checks for structured error codes +- `rate-limit-headers` -- Checks for rate limit headers +- `retry-after` -- Checks for Retry-After header + +### Documentation +- `machine-readable-docs` -- Checks for machine-readable documentation +- `sdk-available` -- Checks for SDK/library references + +--- + +## Example Output + +```json +{ + "url": "https://api.example.com", + "timestamp": "2026-02-20T12:00:00.000Z", + "version": "0.3.0", + "score": 62, + "categories": [ + { + "id": "discovery", + "title": "Discovery", + "description": "Can AI agents find and understand your platform?", + "score": 72, + "weight": 25, + "auditRefs": [ + { "id": "llms-txt", "weight": 8 }, + { "id": "openapi-spec", "weight": 8 }, + { "id": "robots-ai", "weight": 4 }, + { "id": "ai-plugin", "weight": 3 }, + { "id": "schema-org", "weight": 2 } + ] + }, + { + "id": "api-quality", + "title": "API Quality", + "description": "Can AI agents effectively use your API?", + "score": 80, + "weight": 25, + "auditRefs": [ + { "id": "openapi-valid", "weight": 10 }, + { "id": "response-format", "weight": 8 }, + { "id": "response-examples", "weight": 4 }, + { "id": "content-negotiation", "weight": 3 } + ] + } + ], + "audits": { + "llms-txt": { + "id": "llms-txt", + "title": "Site provides an llms.txt file", + "description": "An llms.txt file helps AI agents understand the site purpose...", + "score": 1, + "weight": 0, + "scoreDisplayMode": "binary", + "details": { + "type": "text", + "summary": "Found /llms.txt with 342 characters of content." + } + }, + "openapi-spec": { + "id": "openapi-spec", + "title": "Site does not provide an OpenAPI specification", + "description": "An OpenAPI specification allows AI agents...", + "score": 0, + "weight": 0, + "scoreDisplayMode": "binary", + "details": { + "type": "text", + "summary": "No /openapi.json file was found at the target URL." + } + } + }, + "recommendations": [ + { + "audit": "openapi-spec", + "message": "An OpenAPI specification allows AI agents to discover and understand API endpoints...", + "impact": 8 + }, + { + "audit": "rate-limit-headers", + "message": "Rate limit headers inform AI agents about request quotas...", + "impact": 3 + } + ] +} +``` + +--- + +## Score Interpretation + +| Score Range | Label | Meaning | +| ----------- | ------ | ------------------------------------------ | +| 90-100 | PASS | Highly agent-friendly | +| 50-89 | WARN | Partially agent-friendly, room to improve | +| 0-49 | FAIL | Not agent-friendly, major improvements needed | + +The CLI exits with code `0` if the overall score is >= 50, and `1` otherwise. diff --git a/package.json b/package.json index 4f14913..6038387 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@agentgram/ax-score", - "version": "0.2.0", + "version": "0.3.0", "description": "AX Score - The Lighthouse for AI Agent Experience. Measure how agent-friendly your website or API is.", "type": "module", "main": "./dist/index.js", diff --git a/src/audits/__tests__/ai-plugin.test.ts b/src/audits/__tests__/ai-plugin.test.ts new file mode 100644 index 0000000..03f9001 --- /dev/null +++ b/src/audits/__tests__/ai-plugin.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest'; +import { AiPluginAudit } from '../ai-plugin.js'; +import { makeHttpArtifact } from './fixtures.js'; + +describe('AiPluginAudit', () => { + const audit = new AiPluginAudit(); + + it('should have the correct audit id', () => { + expect(audit.meta.id).toBe('ai-plugin'); + }); + + it('should pass when a valid ai-plugin.json is found', async () => { + const manifest = JSON.stringify({ + schema_version: 'v1', + name_for_human: 'My Plugin', + name_for_model: 'my_plugin', + }); + const artifacts = makeHttpArtifact({ + aiPlugin: { found: true, content: manifest, statusCode: 200 }, + }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(1); + }); + + it('should fail when ai-plugin.json is not found', async () => { + const artifacts = makeHttpArtifact({ + aiPlugin: { found: false, content: null, statusCode: 404 }, + }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(0); + }); + + it('should fail when ai-plugin.json is empty', async () => { + const artifacts = makeHttpArtifact({ + aiPlugin: { found: true, content: '', statusCode: 200 }, + }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(0); + }); + + it('should fail when ai-plugin.json contains invalid JSON', async () => { + const artifacts = makeHttpArtifact({ + aiPlugin: { found: true, content: 'not-valid-json', statusCode: 200 }, + }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(0); + }); + + it('should return partial score when ai-plugin.json is missing required fields', async () => { + const manifest = JSON.stringify({ some_field: 'value' }); + const artifacts = makeHttpArtifact({ + aiPlugin: { found: true, content: manifest, statusCode: 200 }, + }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(0.5); + }); +}); diff --git a/src/audits/__tests__/content-negotiation.test.ts b/src/audits/__tests__/content-negotiation.test.ts new file mode 100644 index 0000000..1f24679 --- /dev/null +++ b/src/audits/__tests__/content-negotiation.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; +import { ContentNegotiationAudit } from '../content-negotiation.js'; +import { makeHttpArtifact } from './fixtures.js'; + +describe('ContentNegotiationAudit', () => { + const audit = new ContentNegotiationAudit(); + + it('should have the correct audit id', () => { + expect(audit.meta.id).toBe('content-negotiation'); + }); + + it('should pass when content-type includes application/json', async () => { + const artifacts = makeHttpArtifact({ + headers: { 'content-type': 'application/json; charset=utf-8' }, + }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(1); + }); + + it('should pass when Vary header includes Accept', async () => { + const artifacts = makeHttpArtifact({ + headers: { + 'content-type': 'text/html', + vary: 'Accept, Accept-Encoding', + }, + }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(1); + }); + + it('should fail when no content-type header is present', async () => { + const artifacts = makeHttpArtifact({ + headers: {}, + }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(0); + }); + + it('should fail when content-type exists but no JSON support', async () => { + const artifacts = makeHttpArtifact({ + headers: { 'content-type': 'text/plain' }, + }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(0); + }); +}); diff --git a/src/audits/__tests__/error-codes.test.ts b/src/audits/__tests__/error-codes.test.ts new file mode 100644 index 0000000..4e31897 --- /dev/null +++ b/src/audits/__tests__/error-codes.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; +import { ErrorCodesAudit } from '../error-codes.js'; +import { makeApiArtifact } from './fixtures.js'; + +describe('ErrorCodesAudit', () => { + const audit = new ErrorCodesAudit(); + + it('should have the correct audit id', () => { + expect(audit.meta.id).toBe('error-codes'); + }); + + it('should pass when structured error codes are found', async () => { + const artifacts = makeApiArtifact({ errorCodeStructured: true }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(1); + }); + + it('should fail when no structured error codes are found', async () => { + const artifacts = makeApiArtifact({ errorCodeStructured: false }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(0); + }); +}); diff --git a/src/audits/__tests__/fixtures.ts b/src/audits/__tests__/fixtures.ts new file mode 100644 index 0000000..97be7b9 --- /dev/null +++ b/src/audits/__tests__/fixtures.ts @@ -0,0 +1,93 @@ +/** + * Shared test fixtures for audit tests. + * + * These provide pre-built artifact objects that simulate gatherer output + * so individual audit tests can focus on their scoring logic. + */ +import type { HttpGatherResult, FileProbe } from '../../gatherers/http-gatherer.js'; +import type { HtmlGatherResult, MetaTags, SemanticElements } from '../../gatherers/html-gatherer.js'; +import type { ApiGatherResult } from '../../gatherers/api-gatherer.js'; + +const EMPTY_FILE_PROBE: FileProbe = { + found: false, + content: null, + statusCode: null, +}; + +export function makeHttpArtifact( + overrides: Partial = {} +): Record { + return { + http: { + url: 'https://example.com', + statusCode: 200, + headers: {}, + body: '', + robotsTxt: { ...EMPTY_FILE_PROBE }, + llmsTxt: { ...EMPTY_FILE_PROBE }, + openapiSpec: { ...EMPTY_FILE_PROBE }, + aiPlugin: { ...EMPTY_FILE_PROBE }, + sitemapXml: { ...EMPTY_FILE_PROBE }, + securityTxt: { ...EMPTY_FILE_PROBE }, + ...overrides, + }, + }; +} + +const EMPTY_META_TAGS: MetaTags = { + title: null, + description: null, + ogTitle: null, + ogDescription: null, + ogImage: null, + canonical: null, + robots: null, +}; + +const EMPTY_SEMANTIC_ELEMENTS: SemanticElements = { + hasNav: false, + hasMain: false, + hasArticle: false, + hasHeader: false, + hasFooter: false, + hasH1: false, + headingCount: 0, +}; + +export function makeHtmlArtifact( + overrides: Partial = {} +): Record { + return { + html: { + html: '', + jsonLd: [], + metaTags: { ...EMPTY_META_TAGS }, + semanticElements: { ...EMPTY_SEMANTIC_ELEMENTS }, + links: [], + ...overrides, + }, + }; +} + +export function makeApiArtifact( + overrides: Partial = {} +): Record { + return { + api: { + hasOpenApi: false, + openapiVersion: null, + endpointCount: 0, + hasJsonContentType: false, + hasAuthEndpoint: false, + hasCaptcha: false, + errorCodeStructured: false, + hasRateLimitHeaders: false, + rateLimitHeaders: {}, + hasRetryAfter: false, + hasExamples: false, + hasSdkLinks: false, + hasMachineReadableDocs: false, + ...overrides, + }, + }; +} diff --git a/src/audits/__tests__/json-ld.test.ts b/src/audits/__tests__/json-ld.test.ts new file mode 100644 index 0000000..b819aa4 --- /dev/null +++ b/src/audits/__tests__/json-ld.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import { JsonLdAudit } from '../json-ld.js'; +import { makeHtmlArtifact } from './fixtures.js'; + +describe('JsonLdAudit', () => { + const audit = new JsonLdAudit(); + + it('should have the correct audit id', () => { + expect(audit.meta.id).toBe('json-ld'); + }); + + it('should pass when JSON-LD blocks exist', async () => { + const artifacts = makeHtmlArtifact({ + jsonLd: [{ '@type': 'WebSite', name: 'Example' }], + }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(1); + }); + + it('should fail when no JSON-LD blocks exist', async () => { + const artifacts = makeHtmlArtifact({ + jsonLd: [], + }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(0); + }); + + it('should report the count and types of JSON-LD blocks', async () => { + const artifacts = makeHtmlArtifact({ + jsonLd: [ + { '@type': 'Organization', name: 'Org' }, + { '@type': 'WebPage', name: 'Page' }, + ], + }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(1); + expect(result.details?.items?.[0]).toHaveProperty('blockCount', 2); + }); +}); diff --git a/src/audits/__tests__/llms-txt.test.ts b/src/audits/__tests__/llms-txt.test.ts new file mode 100644 index 0000000..af4460c --- /dev/null +++ b/src/audits/__tests__/llms-txt.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { LlmsTxtAudit } from '../llms-txt.js'; +import { makeHttpArtifact } from './fixtures.js'; + +describe('LlmsTxtAudit', () => { + const audit = new LlmsTxtAudit(); + + it('should have the correct audit id', () => { + expect(audit.meta.id).toBe('llms-txt'); + }); + + it('should pass when llms.txt is found with content', async () => { + const artifacts = makeHttpArtifact({ + llmsTxt: { found: true, content: '# My Site\nThis is a description.', statusCode: 200 }, + }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(1); + expect(result.id).toBe('llms-txt'); + }); + + it('should fail when llms.txt is not found', async () => { + const artifacts = makeHttpArtifact({ + llmsTxt: { found: false, content: null, statusCode: 404 }, + }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(0); + }); + + it('should return partial score when llms.txt is empty', async () => { + const artifacts = makeHttpArtifact({ + llmsTxt: { found: true, content: '', statusCode: 200 }, + }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(0.5); + }); + + it('should return partial score when llms.txt contains only whitespace', async () => { + const artifacts = makeHttpArtifact({ + llmsTxt: { found: true, content: ' \n ', statusCode: 200 }, + }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(0.5); + }); +}); diff --git a/src/audits/__tests__/machine-readable-docs.test.ts b/src/audits/__tests__/machine-readable-docs.test.ts new file mode 100644 index 0000000..b67d8d5 --- /dev/null +++ b/src/audits/__tests__/machine-readable-docs.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; +import { MachineReadableDocsAudit } from '../machine-readable-docs.js'; +import { makeApiArtifact } from './fixtures.js'; + +describe('MachineReadableDocsAudit', () => { + const audit = new MachineReadableDocsAudit(); + + it('should have the correct audit id', () => { + expect(audit.meta.id).toBe('machine-readable-docs'); + }); + + it('should pass when machine-readable docs are found', async () => { + const artifacts = makeApiArtifact({ hasMachineReadableDocs: true }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(1); + }); + + it('should fail when no machine-readable docs are found', async () => { + const artifacts = makeApiArtifact({ hasMachineReadableDocs: false }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(0); + }); +}); diff --git a/src/audits/__tests__/meta-tags.test.ts b/src/audits/__tests__/meta-tags.test.ts new file mode 100644 index 0000000..6de368f --- /dev/null +++ b/src/audits/__tests__/meta-tags.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest'; +import { MetaTagsAudit } from '../meta-tags.js'; +import { makeHtmlArtifact } from './fixtures.js'; + +describe('MetaTagsAudit', () => { + const audit = new MetaTagsAudit(); + + it('should have the correct audit id', () => { + expect(audit.meta.id).toBe('meta-tags'); + }); + + it('should pass when all essential meta tags are present', async () => { + const artifacts = makeHtmlArtifact({ + metaTags: { + title: 'My Site', + description: 'A description', + ogTitle: 'My Site OG', + ogDescription: 'OG Description', + ogImage: 'https://example.com/image.png', + canonical: 'https://example.com', + robots: 'index, follow', + }, + }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(1); + }); + + it('should fail when no meta tags are present', async () => { + const artifacts = makeHtmlArtifact({ + metaTags: { + title: null, + description: null, + ogTitle: null, + ogDescription: null, + ogImage: null, + canonical: null, + robots: null, + }, + }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(0); + }); + + it('should return partial score when some meta tags are missing', async () => { + const artifacts = makeHtmlArtifact({ + metaTags: { + title: 'My Site', + description: 'A description', + ogTitle: null, + ogDescription: null, + ogImage: null, + canonical: null, + robots: null, + }, + }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(0.5); + }); + + it('should treat empty strings as missing', async () => { + const artifacts = makeHtmlArtifact({ + metaTags: { + title: '', + description: '', + ogTitle: '', + ogDescription: '', + ogImage: null, + canonical: null, + robots: null, + }, + }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(0); + }); +}); diff --git a/src/audits/__tests__/no-captcha.test.ts b/src/audits/__tests__/no-captcha.test.ts new file mode 100644 index 0000000..3019cdd --- /dev/null +++ b/src/audits/__tests__/no-captcha.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; +import { NoCaptchaAudit } from '../no-captcha.js'; +import { makeApiArtifact } from './fixtures.js'; + +describe('NoCaptchaAudit', () => { + const audit = new NoCaptchaAudit(); + + it('should have the correct audit id', () => { + expect(audit.meta.id).toBe('no-captcha'); + }); + + it('should pass when no CAPTCHA is detected', async () => { + const artifacts = makeApiArtifact({ hasCaptcha: false }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(1); + }); + + it('should fail when CAPTCHA is detected', async () => { + const artifacts = makeApiArtifact({ hasCaptcha: true }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(0); + }); +}); diff --git a/src/audits/__tests__/openapi-spec.test.ts b/src/audits/__tests__/openapi-spec.test.ts new file mode 100644 index 0000000..747873a --- /dev/null +++ b/src/audits/__tests__/openapi-spec.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { OpenapiSpecAudit } from '../openapi-spec.js'; +import { makeHttpArtifact } from './fixtures.js'; + +describe('OpenapiSpecAudit', () => { + const audit = new OpenapiSpecAudit(); + + it('should have the correct audit id', () => { + expect(audit.meta.id).toBe('openapi-spec'); + }); + + it('should pass when a valid openapi.json is found', async () => { + const spec = JSON.stringify({ openapi: '3.0.0', info: {}, paths: {} }); + const artifacts = makeHttpArtifact({ + openapiSpec: { found: true, content: spec, statusCode: 200 }, + }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(1); + }); + + it('should fail when openapi.json is not found', async () => { + const artifacts = makeHttpArtifact({ + openapiSpec: { found: false, content: null, statusCode: 404 }, + }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(0); + }); + + it('should return partial score when openapi.json is empty', async () => { + const artifacts = makeHttpArtifact({ + openapiSpec: { found: true, content: '', statusCode: 200 }, + }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(0.5); + }); + + it('should return partial score when openapi.json contains invalid JSON', async () => { + const artifacts = makeHttpArtifact({ + openapiSpec: { found: true, content: 'not-json{{{', statusCode: 200 }, + }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(0.3); + }); +}); diff --git a/src/audits/__tests__/openapi-valid.test.ts b/src/audits/__tests__/openapi-valid.test.ts new file mode 100644 index 0000000..8f1dcad --- /dev/null +++ b/src/audits/__tests__/openapi-valid.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import { OpenapiValidAudit } from '../openapi-valid.js'; +import { makeHttpArtifact } from './fixtures.js'; + +describe('OpenapiValidAudit', () => { + const audit = new OpenapiValidAudit(); + + it('should have the correct audit id', () => { + expect(audit.meta.id).toBe('openapi-valid'); + }); + + it('should pass when OpenAPI spec has all required fields', async () => { + const spec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Test', version: '1.0' }, + paths: { '/users': {} }, + }); + const artifacts = makeHttpArtifact({ + openapiSpec: { found: true, content: spec, statusCode: 200 }, + }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(1); + }); + + it('should fail when no OpenAPI spec is found', async () => { + const artifacts = makeHttpArtifact({ + openapiSpec: { found: false, content: null, statusCode: 404 }, + }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(0); + }); + + it('should fail when OpenAPI spec is invalid JSON', async () => { + const artifacts = makeHttpArtifact({ + openapiSpec: { found: true, content: 'invalid', statusCode: 200 }, + }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(0); + }); + + it('should return partial score when some fields are missing', async () => { + const spec = JSON.stringify({ openapi: '3.0.0' }); + const artifacts = makeHttpArtifact({ + openapiSpec: { found: true, content: spec, statusCode: 200 }, + }); + + const result = await audit.audit(artifacts); + expect(result.score).toBeGreaterThan(0); + expect(result.score).toBeLessThan(1); + }); + + it('should recognize swagger version field', async () => { + const spec = JSON.stringify({ + swagger: '2.0', + info: { title: 'Test', version: '1.0' }, + paths: { '/users': {} }, + }); + const artifacts = makeHttpArtifact({ + openapiSpec: { found: true, content: spec, statusCode: 200 }, + }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(1); + }); +}); diff --git a/src/audits/__tests__/rate-limit-headers.test.ts b/src/audits/__tests__/rate-limit-headers.test.ts new file mode 100644 index 0000000..ac0b7a5 --- /dev/null +++ b/src/audits/__tests__/rate-limit-headers.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import { RateLimitHeadersAudit } from '../rate-limit-headers.js'; +import { makeApiArtifact } from './fixtures.js'; + +describe('RateLimitHeadersAudit', () => { + const audit = new RateLimitHeadersAudit(); + + it('should have the correct audit id', () => { + expect(audit.meta.id).toBe('rate-limit-headers'); + }); + + it('should pass when rate limit headers are present', async () => { + const artifacts = makeApiArtifact({ + hasRateLimitHeaders: true, + rateLimitHeaders: { + 'x-ratelimit-limit': '100', + 'x-ratelimit-remaining': '99', + }, + }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(1); + expect(result.details?.items).toHaveLength(2); + }); + + it('should fail when no rate limit headers are present', async () => { + const artifacts = makeApiArtifact({ + hasRateLimitHeaders: false, + rateLimitHeaders: {}, + }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(0); + }); +}); diff --git a/src/audits/__tests__/response-examples.test.ts b/src/audits/__tests__/response-examples.test.ts new file mode 100644 index 0000000..0440ad0 --- /dev/null +++ b/src/audits/__tests__/response-examples.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; +import { ResponseExamplesAudit } from '../response-examples.js'; +import { makeApiArtifact } from './fixtures.js'; + +describe('ResponseExamplesAudit', () => { + const audit = new ResponseExamplesAudit(); + + it('should have the correct audit id', () => { + expect(audit.meta.id).toBe('response-examples'); + }); + + it('should pass when OpenAPI spec contains examples', async () => { + const artifacts = makeApiArtifact({ hasOpenApi: true, hasExamples: true }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(1); + }); + + it('should fail when OpenAPI spec lacks examples', async () => { + const artifacts = makeApiArtifact({ hasOpenApi: true, hasExamples: false }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(0); + }); + + it('should fail when no OpenAPI spec is found', async () => { + const artifacts = makeApiArtifact({ hasOpenApi: false }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(0); + }); +}); diff --git a/src/audits/__tests__/response-format.test.ts b/src/audits/__tests__/response-format.test.ts new file mode 100644 index 0000000..4d12ed4 --- /dev/null +++ b/src/audits/__tests__/response-format.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; +import { ResponseFormatAudit } from '../response-format.js'; +import { makeApiArtifact } from './fixtures.js'; + +describe('ResponseFormatAudit', () => { + const audit = new ResponseFormatAudit(); + + it('should have the correct audit id', () => { + expect(audit.meta.id).toBe('response-format'); + }); + + it('should pass when API returns JSON content type', async () => { + const artifacts = makeApiArtifact({ hasJsonContentType: true }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(1); + }); + + it('should fail when API does not return JSON content type', async () => { + const artifacts = makeApiArtifact({ hasJsonContentType: false }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(0); + }); +}); diff --git a/src/audits/__tests__/retry-after.test.ts b/src/audits/__tests__/retry-after.test.ts new file mode 100644 index 0000000..780ad22 --- /dev/null +++ b/src/audits/__tests__/retry-after.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; +import { RetryAfterAudit } from '../retry-after.js'; +import { makeApiArtifact } from './fixtures.js'; + +describe('RetryAfterAudit', () => { + const audit = new RetryAfterAudit(); + + it('should have the correct audit id', () => { + expect(audit.meta.id).toBe('retry-after'); + }); + + it('should pass when Retry-After header support is detected', async () => { + const artifacts = makeApiArtifact({ hasRetryAfter: true }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(1); + }); + + it('should fail when no Retry-After support is detected', async () => { + const artifacts = makeApiArtifact({ hasRetryAfter: false }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(0); + }); +}); diff --git a/src/audits/__tests__/robots-ai.test.ts b/src/audits/__tests__/robots-ai.test.ts new file mode 100644 index 0000000..fa6d86e --- /dev/null +++ b/src/audits/__tests__/robots-ai.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; +import { RobotsAiAudit } from '../robots-ai.js'; +import { makeHttpArtifact } from './fixtures.js'; + +describe('RobotsAiAudit', () => { + const audit = new RobotsAiAudit(); + + it('should have the correct audit id', () => { + expect(audit.meta.id).toBe('robots-ai'); + }); + + it('should pass when robots.txt allows all AI agents', async () => { + const robotsTxt = `User-agent: *\nAllow: /`; + const artifacts = makeHttpArtifact({ + robotsTxt: { found: true, content: robotsTxt, statusCode: 200 }, + }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(1); + }); + + it('should fail when robots.txt is not found', async () => { + const artifacts = makeHttpArtifact({ + robotsTxt: { found: false, content: null, statusCode: 404 }, + }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(0); + }); + + it('should fail when robots.txt blocks all user agents', async () => { + const robotsTxt = `User-agent: *\nDisallow: /`; + const artifacts = makeHttpArtifact({ + robotsTxt: { found: true, content: robotsTxt, statusCode: 200 }, + }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(0); + }); + + it('should return partial score when some AI agents are blocked', async () => { + const robotsTxt = `User-agent: GPTBot\nDisallow: /\n\nUser-agent: *\nAllow: /`; + const artifacts = makeHttpArtifact({ + robotsTxt: { found: true, content: robotsTxt, statusCode: 200 }, + }); + + const result = await audit.audit(artifacts); + expect(result.score).toBeGreaterThan(0); + expect(result.score).toBeLessThan(1); + }); + + it('should pass when robots.txt has rules but none block AI agents', async () => { + const robotsTxt = `User-agent: BadBot\nDisallow: /\n\nUser-agent: *\nAllow: /`; + const artifacts = makeHttpArtifact({ + robotsTxt: { found: true, content: robotsTxt, statusCode: 200 }, + }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(1); + }); +}); diff --git a/src/audits/__tests__/schema-org.test.ts b/src/audits/__tests__/schema-org.test.ts new file mode 100644 index 0000000..d9ea85a --- /dev/null +++ b/src/audits/__tests__/schema-org.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; +import { SchemaOrgAudit } from '../schema-org.js'; +import { makeHtmlArtifact } from './fixtures.js'; + +describe('SchemaOrgAudit', () => { + const audit = new SchemaOrgAudit(); + + it('should have the correct audit id', () => { + expect(audit.meta.id).toBe('schema-org'); + }); + + it('should pass when JSON-LD blocks are present', async () => { + const artifacts = makeHtmlArtifact({ + jsonLd: [{ '@type': 'WebSite', name: 'Example' }], + }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(1); + }); + + it('should fail when no JSON-LD blocks are found', async () => { + const artifacts = makeHtmlArtifact({ + jsonLd: [], + }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(0); + }); + + it('should report multiple @type values', async () => { + const artifacts = makeHtmlArtifact({ + jsonLd: [ + { '@type': 'WebSite', name: 'Example' }, + { '@type': 'Organization', name: 'Org' }, + ], + }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(1); + expect(result.details?.summary).toContain('2 JSON-LD block(s)'); + }); + + it('should handle JSON-LD blocks with array @type', async () => { + const artifacts = makeHtmlArtifact({ + jsonLd: [{ '@type': ['WebSite', 'CreativeWork'] }], + }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(1); + expect(result.details?.summary).toContain('WebSite'); + expect(result.details?.summary).toContain('CreativeWork'); + }); +}); diff --git a/src/audits/__tests__/sdk-available.test.ts b/src/audits/__tests__/sdk-available.test.ts new file mode 100644 index 0000000..1fae961 --- /dev/null +++ b/src/audits/__tests__/sdk-available.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; +import { SdkAvailableAudit } from '../sdk-available.js'; +import { makeApiArtifact } from './fixtures.js'; + +describe('SdkAvailableAudit', () => { + const audit = new SdkAvailableAudit(); + + it('should have the correct audit id', () => { + expect(audit.meta.id).toBe('sdk-available'); + }); + + it('should pass when SDK links are found', async () => { + const artifacts = makeApiArtifact({ hasSdkLinks: true }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(1); + }); + + it('should fail when no SDK links are found', async () => { + const artifacts = makeApiArtifact({ hasSdkLinks: false }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(0); + }); +}); diff --git a/src/audits/__tests__/self-service-auth.test.ts b/src/audits/__tests__/self-service-auth.test.ts new file mode 100644 index 0000000..23c59e9 --- /dev/null +++ b/src/audits/__tests__/self-service-auth.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; +import { SelfServiceAuthAudit } from '../self-service-auth.js'; +import { makeApiArtifact } from './fixtures.js'; + +describe('SelfServiceAuthAudit', () => { + const audit = new SelfServiceAuthAudit(); + + it('should have the correct audit id', () => { + expect(audit.meta.id).toBe('self-service-auth'); + }); + + it('should pass when auth endpoints are found', async () => { + const artifacts = makeApiArtifact({ hasAuthEndpoint: true }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(1); + }); + + it('should fail when no auth endpoints are found', async () => { + const artifacts = makeApiArtifact({ hasAuthEndpoint: false }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(0); + }); +}); diff --git a/src/audits/__tests__/semantic-html.test.ts b/src/audits/__tests__/semantic-html.test.ts new file mode 100644 index 0000000..e1b5eb2 --- /dev/null +++ b/src/audits/__tests__/semantic-html.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from 'vitest'; +import { SemanticHtmlAudit } from '../semantic-html.js'; +import { makeHtmlArtifact } from './fixtures.js'; + +describe('SemanticHtmlAudit', () => { + const audit = new SemanticHtmlAudit(); + + it('should have the correct audit id', () => { + expect(audit.meta.id).toBe('semantic-html'); + }); + + it('should pass when all semantic elements are present', async () => { + const artifacts = makeHtmlArtifact({ + semanticElements: { + hasNav: true, + hasMain: true, + hasArticle: true, + hasHeader: true, + hasFooter: true, + hasH1: true, + headingCount: 5, + }, + }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(1); + }); + + it('should fail when no semantic elements are present', async () => { + const artifacts = makeHtmlArtifact({ + semanticElements: { + hasNav: false, + hasMain: false, + hasArticle: false, + hasHeader: false, + hasFooter: false, + hasH1: false, + headingCount: 0, + }, + }); + + const result = await audit.audit(artifacts); + expect(result.score).toBe(0); + }); + + it('should return partial score when some elements are present', async () => { + const artifacts = makeHtmlArtifact({ + semanticElements: { + hasNav: true, + hasMain: true, + hasArticle: false, + hasHeader: false, + hasFooter: false, + hasH1: true, + headingCount: 3, + }, + }); + + const result = await audit.audit(artifacts); + // 3 out of 5 checked elements: nav, main, header, footer, h1 + expect(result.score).toBeGreaterThan(0); + expect(result.score).toBeLessThan(1); + }); +}); diff --git a/src/bin/ax-score.ts b/src/bin/ax-score.ts index 0018772..3ceb451 100644 --- a/src/bin/ax-score.ts +++ b/src/bin/ax-score.ts @@ -5,8 +5,20 @@ import ora from 'ora'; import { runAudit } from '../runner.js'; import { renderReport } from '../reporter/cli.js'; import { renderJSON } from '../reporter/json.js'; +import { uploadReport } from '../upload.js'; import { VERSION } from '../config/default.js'; +interface CliOptions { + format: string; + timeout: string; + verbose: boolean; + upload: boolean; + apiUrl: string; + apiKey?: string; +} + +const DEFAULT_API_URL = 'https://agentgram.co/api/v1/ax-score/scan'; + const program = new Command(); program @@ -19,7 +31,10 @@ program .option('-f, --format ', 'Output format (cli, json)', 'cli') .option('-t, --timeout ', 'Request timeout in milliseconds', '30000') .option('-v, --verbose', 'Show detailed audit results', false) - .action(async (url: string, options: { format: string; timeout: string; verbose: boolean }) => { + .option('-u, --upload', 'Upload results to AgentGram hosted API', false) + .option('--api-url ', 'API endpoint for uploading results', DEFAULT_API_URL) + .option('--api-key ', 'API key for authentication (or set AGENTGRAM_API_KEY env var)') + .action(async (url: string, options: CliOptions) => { const spinner = ora(`Auditing ${url}...`).start(); try { @@ -37,6 +52,35 @@ program console.log(renderReport(report)); } + // Upload results if --upload flag is set + if (options.upload) { + const apiKey = options.apiKey ?? process.env['AGENTGRAM_API_KEY']; + + if (!apiKey) { + console.error( + 'Error: --upload requires an API key. ' + + 'Provide one via --api-key or the AGENTGRAM_API_KEY environment variable.' + ); + process.exit(1); + } + + const uploadSpinner = ora('Uploading results...').start(); + + try { + await uploadReport(report, { + apiUrl: options.apiUrl, + apiKey, + }); + uploadSpinner.succeed('Results uploaded successfully.'); + } catch (uploadError) { + uploadSpinner.fail('Failed to upload results.'); + console.error( + uploadError instanceof Error ? uploadError.message : String(uploadError) + ); + // Upload failure is non-fatal: still exit based on score + } + } + process.exit(report.score >= 50 ? 0 : 1); } catch (error) { spinner.fail(`Failed to audit ${url}`); diff --git a/src/config/default.ts b/src/config/default.ts index 1b84dfd..9d574a8 100644 --- a/src/config/default.ts +++ b/src/config/default.ts @@ -79,4 +79,4 @@ export const DEFAULT_CATEGORIES: CategoryConfig[] = [ ]; export const DEFAULT_TIMEOUT = 30_000; -export const VERSION = '0.0.1'; +export const VERSION = '0.3.0'; diff --git a/src/gatherers/__tests__/api-gatherer.test.ts b/src/gatherers/__tests__/api-gatherer.test.ts new file mode 100644 index 0000000..5a5eaf6 --- /dev/null +++ b/src/gatherers/__tests__/api-gatherer.test.ts @@ -0,0 +1,181 @@ +import { describe, it, expect } from 'vitest'; +import { ApiGatherer } from '../api-gatherer.js'; +import type { HttpGatherResult } from '../http-gatherer.js'; + +function makeHttpResult(overrides: Partial = {}): Record { + return { + http: { + url: 'https://example.com', + statusCode: 200, + headers: {}, + body: '', + robotsTxt: { found: false, content: null, statusCode: null }, + llmsTxt: { found: false, content: null, statusCode: null }, + openapiSpec: { found: false, content: null, statusCode: null }, + aiPlugin: { found: false, content: null, statusCode: null }, + sitemapXml: { found: false, content: null, statusCode: null }, + securityTxt: { found: false, content: null, statusCode: null }, + ...overrides, + }, + }; +} + +describe('ApiGatherer', () => { + const gatherer = new ApiGatherer(); + + it('should have the name "api"', () => { + expect(gatherer.name).toBe('api'); + }); + + it('should detect valid OpenAPI spec', async () => { + const spec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Test' }, + paths: { '/users': {}, '/posts': {} }, + }); + const result = await gatherer.gather( + { url: 'https://example.com' }, + makeHttpResult({ + openapiSpec: { found: true, content: spec, statusCode: 200 }, + }) + ); + + expect(result.hasOpenApi).toBe(true); + expect(result.openapiVersion).toBe('3.0.0'); + expect(result.endpointCount).toBe(2); + }); + + it('should handle invalid OpenAPI JSON', async () => { + const result = await gatherer.gather( + { url: 'https://example.com' }, + makeHttpResult({ + openapiSpec: { found: true, content: 'not-json', statusCode: 200 }, + }) + ); + + expect(result.hasOpenApi).toBe(false); + }); + + it('should detect JSON content type', async () => { + const result = await gatherer.gather( + { url: 'https://example.com' }, + makeHttpResult({ + headers: { 'content-type': 'application/json' }, + }) + ); + + expect(result.hasJsonContentType).toBe(true); + }); + + it('should detect auth endpoints in body', async () => { + const result = await gatherer.gather( + { url: 'https://example.com' }, + makeHttpResult({ body: 'Login' }) + ); + + expect(result.hasAuthEndpoint).toBe(true); + }); + + it('should detect CAPTCHA', async () => { + const result = await gatherer.gather( + { url: 'https://example.com' }, + makeHttpResult({ body: '
' }) + ); + + expect(result.hasCaptcha).toBe(true); + }); + + it('should detect rate limit headers', async () => { + const result = await gatherer.gather( + { url: 'https://example.com' }, + makeHttpResult({ + headers: { + 'x-ratelimit-limit': '100', + 'x-ratelimit-remaining': '99', + }, + }) + ); + + expect(result.hasRateLimitHeaders).toBe(true); + expect(result.rateLimitHeaders['x-ratelimit-limit']).toBe('100'); + }); + + it('should detect retry-after header', async () => { + const result = await gatherer.gather( + { url: 'https://example.com' }, + makeHttpResult({ + headers: { 'retry-after': '60' }, + }) + ); + + expect(result.hasRetryAfter).toBe(true); + }); + + it('should detect SDK links', async () => { + const result = await gatherer.gather( + { url: 'https://example.com' }, + makeHttpResult({ body: '

Install with npm install @example/sdk

' }) + ); + + expect(result.hasSdkLinks).toBe(true); + }); + + it('should detect machine-readable docs when OpenAPI is present', async () => { + const spec = JSON.stringify({ openapi: '3.0.0', paths: {} }); + const result = await gatherer.gather( + { url: 'https://example.com' }, + makeHttpResult({ + openapiSpec: { found: true, content: spec, statusCode: 200 }, + }) + ); + + expect(result.hasMachineReadableDocs).toBe(true); + }); + + it('should detect machine-readable docs when llms.txt is present', async () => { + const result = await gatherer.gather( + { url: 'https://example.com' }, + makeHttpResult({ + llmsTxt: { found: true, content: '# Site info', statusCode: 200 }, + }) + ); + + expect(result.hasMachineReadableDocs).toBe(true); + }); + + it('should handle missing artifacts gracefully', async () => { + const result = await gatherer.gather({ url: 'https://example.com' }); + + expect(result.hasOpenApi).toBe(false); + expect(result.hasJsonContentType).toBe(false); + }); + + it('should detect examples in OpenAPI spec', async () => { + const spec = JSON.stringify({ + openapi: '3.0.0', + paths: { + '/users': { + get: { + responses: { + '200': { + content: { + 'application/json': { + example: { id: 1, name: 'John' }, + }, + }, + }, + }, + }, + }, + }, + }); + const result = await gatherer.gather( + { url: 'https://example.com' }, + makeHttpResult({ + openapiSpec: { found: true, content: spec, statusCode: 200 }, + }) + ); + + expect(result.hasExamples).toBe(true); + }); +}); diff --git a/src/gatherers/__tests__/html-gatherer.test.ts b/src/gatherers/__tests__/html-gatherer.test.ts new file mode 100644 index 0000000..f70b6d1 --- /dev/null +++ b/src/gatherers/__tests__/html-gatherer.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from 'vitest'; +import { HtmlGatherer } from '../html-gatherer.js'; +import type { HttpGatherResult } from '../http-gatherer.js'; + +function makeHttpResult(body: string): Record { + return { + http: { + url: 'https://example.com', + statusCode: 200, + headers: {}, + body, + robotsTxt: { found: false, content: null, statusCode: null }, + llmsTxt: { found: false, content: null, statusCode: null }, + openapiSpec: { found: false, content: null, statusCode: null }, + aiPlugin: { found: false, content: null, statusCode: null }, + sitemapXml: { found: false, content: null, statusCode: null }, + securityTxt: { found: false, content: null, statusCode: null }, + }, + }; +} + +describe('HtmlGatherer', () => { + const gatherer = new HtmlGatherer(); + + it('should have the name "html"', () => { + expect(gatherer.name).toBe('html'); + }); + + it('should extract title from HTML', async () => { + const html = 'Test Site'; + const result = await gatherer.gather({ url: 'https://example.com' }, makeHttpResult(html)); + + expect(result.metaTags.title).toBe('Test Site'); + }); + + it('should extract meta description', async () => { + const html = ''; + const result = await gatherer.gather({ url: 'https://example.com' }, makeHttpResult(html)); + + expect(result.metaTags.description).toBe('A test page'); + }); + + it('should extract og:title', async () => { + const html = ''; + const result = await gatherer.gather({ url: 'https://example.com' }, makeHttpResult(html)); + + expect(result.metaTags.ogTitle).toBe('OG Title'); + }); + + it('should extract JSON-LD blocks', async () => { + const html = ` + + `; + const result = await gatherer.gather({ url: 'https://example.com' }, makeHttpResult(html)); + + expect(result.jsonLd).toHaveLength(1); + expect(result.jsonLd[0]).toEqual({ '@type': 'WebSite', name: 'Example' }); + }); + + it('should skip malformed JSON-LD', async () => { + const html = ` + + `; + const result = await gatherer.gather({ url: 'https://example.com' }, makeHttpResult(html)); + + expect(result.jsonLd).toHaveLength(0); + }); + + it('should detect semantic HTML elements', async () => { + const html = '

Title

'; + const result = await gatherer.gather({ url: 'https://example.com' }, makeHttpResult(html)); + + expect(result.semanticElements.hasNav).toBe(true); + expect(result.semanticElements.hasMain).toBe(true); + expect(result.semanticElements.hasHeader).toBe(true); + expect(result.semanticElements.hasFooter).toBe(true); + expect(result.semanticElements.hasH1).toBe(true); + }); + + it('should extract links from anchor tags', async () => { + const html = 'AboutContact'; + const result = await gatherer.gather({ url: 'https://example.com' }, makeHttpResult(html)); + + expect(result.links).toContain('https://example.com/about'); + expect(result.links).toContain('/contact'); + }); + + it('should handle empty HTML gracefully', async () => { + const result = await gatherer.gather({ url: 'https://example.com' }, makeHttpResult('')); + + expect(result.html).toBe(''); + expect(result.jsonLd).toHaveLength(0); + expect(result.metaTags.title).toBeNull(); + expect(result.links).toHaveLength(0); + }); + + it('should handle missing artifacts gracefully', async () => { + const result = await gatherer.gather({ url: 'https://example.com' }); + + expect(result.html).toBe(''); + expect(result.jsonLd).toHaveLength(0); + }); +}); diff --git a/src/gatherers/__tests__/http-gatherer.test.ts b/src/gatherers/__tests__/http-gatherer.test.ts new file mode 100644 index 0000000..bf28ef1 --- /dev/null +++ b/src/gatherers/__tests__/http-gatherer.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { HttpGatherer } from '../http-gatherer.js'; + +describe('HttpGatherer', () => { + const gatherer = new HttpGatherer(); + + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('should have the name "http"', () => { + expect(gatherer.name).toBe('http'); + }); + + it('should gather data from a URL by fetching main page and well-known files', async () => { + const mockResponse = (body: string, status = 200, headers: Record = {}) => { + const headerMap = new Map(Object.entries(headers)); + return Promise.resolve({ + ok: status >= 200 && status < 300, + status, + text: () => Promise.resolve(body), + headers: { + forEach: (cb: (value: string, key: string) => void) => { + headerMap.forEach((v, k) => cb(v, k)); + }, + }, + } as unknown as Response); + }; + + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation((input) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : (input as Request).url; + + if (url.endsWith('/robots.txt')) { + return mockResponse('User-agent: *\nAllow: /'); + } + if (url.endsWith('/llms.txt')) { + return mockResponse('# My Site'); + } + if (url.endsWith('/openapi.json')) { + return mockResponse('', 404); + } + if (url.includes('ai-plugin.json')) { + return mockResponse('', 404); + } + if (url.endsWith('/sitemap.xml')) { + return mockResponse('', 404); + } + if (url.includes('security.txt')) { + return mockResponse('', 404); + } + // Main page or HEAD request + return mockResponse('Hello', 200, { + 'content-type': 'text/html; charset=utf-8', + }); + }); + + const result = await gatherer.gather({ url: 'https://example.com' }); + + expect(result.url).toBe('https://example.com'); + expect(result.robotsTxt.found).toBe(true); + expect(result.robotsTxt.content).toContain('User-agent'); + expect(result.llmsTxt.found).toBe(true); + expect(result.llmsTxt.content).toContain('# My Site'); + expect(result.openapiSpec.found).toBe(false); + + // Verify fetch was called (main page + 6 well-known files + 1 HEAD request) + expect(fetchSpy).toHaveBeenCalled(); + }); + + it('should handle network errors gracefully', async () => { + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network error')); + + const result = await gatherer.gather({ url: 'https://unreachable.example.com' }); + + expect(result.url).toBe('https://unreachable.example.com'); + expect(result.statusCode).toBe(0); + expect(result.robotsTxt.found).toBe(false); + expect(result.llmsTxt.found).toBe(false); + }); +}); diff --git a/src/index.ts b/src/index.ts index e50d841..9fbcc6c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,3 +28,7 @@ export type { HtmlGatherResult, MetaTags, SemanticElements } from './gatherers/h export { ApiGatherer } from './gatherers/api-gatherer.js'; export type { ApiGatherResult } from './gatherers/api-gatherer.js'; + +// Upload +export { uploadReport } from './upload.js'; +export type { UploadOptions } from './upload.js'; diff --git a/src/runner.test.ts b/src/runner.test.ts new file mode 100644 index 0000000..81dcc73 --- /dev/null +++ b/src/runner.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { runAudit } from './runner.js'; + +describe('runAudit', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('should return a complete AXReport with all required fields', async () => { + // Mock all fetch calls to return predictable data + vi.spyOn(globalThis, 'fetch').mockImplementation((input) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : (input as Request).url; + + const headers = new Map(); + headers.set('content-type', 'text/html; charset=utf-8'); + + if (url.endsWith('/robots.txt')) { + return Promise.resolve({ + ok: true, + status: 200, + text: () => Promise.resolve('User-agent: *\nAllow: /'), + headers: { forEach: (cb: (v: string, k: string) => void) => headers.forEach((v, k) => cb(v, k)) }, + } as unknown as Response); + } + if (url.endsWith('/llms.txt')) { + return Promise.resolve({ + ok: true, + status: 200, + text: () => Promise.resolve('# Example site info'), + headers: { forEach: (cb: (v: string, k: string) => void) => headers.forEach((v, k) => cb(v, k)) }, + } as unknown as Response); + } + // All other probes return 404 + if ( + url.endsWith('/openapi.json') || + url.includes('ai-plugin.json') || + url.endsWith('/sitemap.xml') || + url.includes('security.txt') + ) { + return Promise.resolve({ + ok: false, + status: 404, + text: () => Promise.resolve(''), + headers: { forEach: (cb: (v: string, k: string) => void) => headers.forEach((v, k) => cb(v, k)) }, + } as unknown as Response); + } + + // Main page + const body = ` + + Example + + + + + +

Welcome

+ + `; + + return Promise.resolve({ + ok: true, + status: 200, + text: () => Promise.resolve(body), + headers: { forEach: (cb: (v: string, k: string) => void) => headers.forEach((v, k) => cb(v, k)) }, + } as unknown as Response); + }); + + const report = await runAudit({ url: 'https://example.com', timeout: 5000 }); + + // Verify report structure + expect(report.url).toBe('https://example.com'); + expect(report.timestamp).toBeDefined(); + expect(typeof report.version).toBe('string'); + expect(typeof report.score).toBe('number'); + expect(report.score).toBeGreaterThanOrEqual(0); + expect(report.score).toBeLessThanOrEqual(100); + + // Verify categories + expect(report.categories).toBeInstanceOf(Array); + expect(report.categories.length).toBeGreaterThan(0); + for (const cat of report.categories) { + expect(cat.id).toBeDefined(); + expect(cat.title).toBeDefined(); + expect(typeof cat.score).toBe('number'); + } + + // Verify audits + expect(typeof report.audits).toBe('object'); + expect(Object.keys(report.audits).length).toBe(19); + + // Verify recommendations + expect(report.recommendations).toBeInstanceOf(Array); + }); + + it('should handle audits that throw errors gracefully', async () => { + // Mock fetch to reject on all calls + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Simulated network failure')); + + const report = await runAudit({ url: 'https://unreachable.test', timeout: 1000 }); + + // Even if all gathers fail, the report should still be produced + expect(report.url).toBe('https://unreachable.test'); + expect(typeof report.score).toBe('number'); + expect(report.categories).toBeInstanceOf(Array); + expect(typeof report.audits).toBe('object'); + }); +}); diff --git a/src/scoring.test.ts b/src/scoring.test.ts new file mode 100644 index 0000000..e6fb392 --- /dev/null +++ b/src/scoring.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect } from 'vitest'; +import { calculateCategoryScore, calculateOverallScore, generateRecommendations } from './scoring.js'; +import type { AuditResult, AuditRef, AXCategory } from './types.js'; + +function makeAudit(id: string, score: number): AuditResult { + return { + id, + title: `Audit ${id}`, + description: `Description for ${id}`, + score, + weight: 0, + scoreDisplayMode: 'numeric', + }; +} + +describe('calculateCategoryScore', () => { + it('should return 0 when there are no audit refs', () => { + const score = calculateCategoryScore({}, []); + expect(score).toBe(0); + }); + + it('should return 0 when all refs have zero weight', () => { + const audits: Record = { + 'test-1': makeAudit('test-1', 1), + }; + const refs: AuditRef[] = [{ id: 'test-1', weight: 0 }]; + const score = calculateCategoryScore(audits, refs); + expect(score).toBe(0); + }); + + it('should calculate weighted average correctly', () => { + const audits: Record = { + a: makeAudit('a', 1), + b: makeAudit('b', 0), + }; + const refs: AuditRef[] = [ + { id: 'a', weight: 1 }, + { id: 'b', weight: 1 }, + ]; + // (1*1 + 0*1) / (1+1) * 100 = 50 + const score = calculateCategoryScore(audits, refs); + expect(score).toBe(50); + }); + + it('should respect different weights', () => { + const audits: Record = { + a: makeAudit('a', 1), + b: makeAudit('b', 0), + }; + const refs: AuditRef[] = [ + { id: 'a', weight: 3 }, + { id: 'b', weight: 1 }, + ]; + // (1*3 + 0*1) / (3+1) * 100 = 75 + const score = calculateCategoryScore(audits, refs); + expect(score).toBe(75); + }); + + it('should skip missing audits', () => { + const audits: Record = { + a: makeAudit('a', 1), + }; + const refs: AuditRef[] = [ + { id: 'a', weight: 1 }, + { id: 'missing', weight: 1 }, + ]; + // Only 'a' is counted: (1*1) / 1 * 100 = 100 + const score = calculateCategoryScore(audits, refs); + expect(score).toBe(100); + }); + + it('should return 100 when all audits pass', () => { + const audits: Record = { + a: makeAudit('a', 1), + b: makeAudit('b', 1), + }; + const refs: AuditRef[] = [ + { id: 'a', weight: 5 }, + { id: 'b', weight: 5 }, + ]; + const score = calculateCategoryScore(audits, refs); + expect(score).toBe(100); + }); +}); + +describe('calculateOverallScore', () => { + it('should return 0 when there are no categories', () => { + const score = calculateOverallScore([]); + expect(score).toBe(0); + }); + + it('should return 0 when all categories have zero weight', () => { + const categories: AXCategory[] = [ + { id: 'cat', title: 'Cat', description: 'Desc', score: 100, weight: 0, auditRefs: [] }, + ]; + const score = calculateOverallScore(categories); + expect(score).toBe(0); + }); + + it('should calculate weighted average of category scores', () => { + const categories: AXCategory[] = [ + { id: 'a', title: 'A', description: '', score: 100, weight: 1, auditRefs: [] }, + { id: 'b', title: 'B', description: '', score: 0, weight: 1, auditRefs: [] }, + ]; + const score = calculateOverallScore(categories); + expect(score).toBe(50); + }); + + it('should respect different category weights', () => { + const categories: AXCategory[] = [ + { id: 'a', title: 'A', description: '', score: 80, weight: 3, auditRefs: [] }, + { id: 'b', title: 'B', description: '', score: 20, weight: 1, auditRefs: [] }, + ]; + // (80*3 + 20*1) / (3+1) = 65 + const score = calculateOverallScore(categories); + expect(score).toBe(65); + }); +}); + +describe('generateRecommendations', () => { + it('should return empty array when all audits pass', () => { + const audits: Record = { + a: makeAudit('a', 1), + b: makeAudit('b', 1), + }; + const refs: AuditRef[] = [ + { id: 'a', weight: 5 }, + { id: 'b', weight: 5 }, + ]; + const recs = generateRecommendations(audits, refs); + expect(recs).toHaveLength(0); + }); + + it('should generate recommendations for failing audits', () => { + const audits: Record = { + a: makeAudit('a', 0), + b: makeAudit('b', 1), + }; + const refs: AuditRef[] = [ + { id: 'a', weight: 5 }, + { id: 'b', weight: 5 }, + ]; + const recs = generateRecommendations(audits, refs); + expect(recs).toHaveLength(1); + expect(recs[0]?.audit).toBe('a'); + }); + + it('should sort recommendations by impact (highest first)', () => { + const audits: Record = { + a: makeAudit('a', 0), + b: makeAudit('b', 0.5), + }; + const refs: AuditRef[] = [ + { id: 'a', weight: 3 }, + { id: 'b', weight: 10 }, + ]; + const recs = generateRecommendations(audits, refs); + expect(recs).toHaveLength(2); + // b has higher impact: (1-0.5)*10 = 5, a has: (1-0)*3 = 3 + expect(recs[0]?.audit).toBe('b'); + expect(recs[1]?.audit).toBe('a'); + }); + + it('should handle missing audits gracefully', () => { + const audits: Record = {}; + const refs: AuditRef[] = [{ id: 'missing', weight: 5 }]; + const recs = generateRecommendations(audits, refs); + expect(recs).toHaveLength(0); + }); +}); diff --git a/src/upload.test.ts b/src/upload.test.ts new file mode 100644 index 0000000..42fdf12 --- /dev/null +++ b/src/upload.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { uploadReport } from './upload.js'; +import type { AXReport } from './types.js'; + +function makeReport(overrides: Partial = {}): AXReport { + return { + url: 'https://example.com', + timestamp: '2026-02-20T00:00:00.000Z', + version: '0.3.0', + score: 75, + categories: [], + audits: {}, + recommendations: [], + ...overrides, + }; +} + +describe('uploadReport', () => { + const defaultOptions = { + apiUrl: 'https://agentgram.co/api/v1/ax-score/scan', + apiKey: 'test-api-key-123', + }; + + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('should upload successfully and return the response', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ success: true, data: { id: 'scan-abc' } }), + } as unknown as Response); + + const result = await uploadReport(makeReport(), defaultOptions); + + expect(result.success).toBe(true); + expect(result.data.id).toBe('scan-abc'); + }); + + it('should send the report as JSON with correct headers', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ success: true, data: { id: 'scan-abc' } }), + } as unknown as Response); + + const report = makeReport(); + await uploadReport(report, defaultOptions); + + expect(fetchSpy).toHaveBeenCalledWith( + defaultOptions.apiUrl, + expect.objectContaining({ + method: 'POST', + body: JSON.stringify(report), + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + Authorization: 'Bearer test-api-key-123', + }), + }) + ); + }); + + it('should throw on 401 unauthorized', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: false, + status: 401, + json: () => Promise.resolve({ success: false, error: { code: 'UNAUTHORIZED', message: 'Bad key' } }), + } as unknown as Response); + + await expect( + uploadReport(makeReport(), defaultOptions) + ).rejects.toThrow('Invalid or expired API key'); + }); + + it('should throw on 403 forbidden', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: false, + status: 403, + json: () => Promise.resolve({ success: false, error: { code: 'FORBIDDEN', message: 'No access' } }), + } as unknown as Response); + + await expect( + uploadReport(makeReport(), defaultOptions) + ).rejects.toThrow('Invalid or expired API key'); + }); + + it('should throw on server error with message', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: false, + status: 500, + json: () => Promise.resolve({ success: false, error: { code: 'INTERNAL', message: 'Server broke' } }), + } as unknown as Response); + + await expect( + uploadReport(makeReport(), defaultOptions) + ).rejects.toThrow('status 500: Server broke'); + }); + + it('should throw on network error', async () => { + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network error')); + + await expect( + uploadReport(makeReport(), defaultOptions) + ).rejects.toThrow('Upload failed: Network error'); + }); + + it('should throw when server returns success: false', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + json: () => + Promise.resolve({ success: false, error: { code: 'INVALID', message: 'Bad report' } }), + } as unknown as Response); + + await expect( + uploadReport(makeReport(), defaultOptions) + ).rejects.toThrow('Upload rejected: Bad report'); + }); +}); diff --git a/src/upload.ts b/src/upload.ts new file mode 100644 index 0000000..021bbbb --- /dev/null +++ b/src/upload.ts @@ -0,0 +1,90 @@ +import type { AXReport } from './types.js'; + +export interface UploadOptions { + apiUrl: string; + apiKey: string; + timeout?: number; +} + +interface UploadSuccessResponse { + success: true; + data: { id: string }; +} + +interface UploadErrorResponse { + success: false; + error: { code: string; message: string }; +} + +type UploadResponse = UploadSuccessResponse | UploadErrorResponse; + +const DEFAULT_UPLOAD_TIMEOUT = 15_000; + +/** + * Upload an AX report to the AgentGram hosted API. + * + * Sends a POST request with the full report JSON. Throws on network + * errors, authentication failures, or unexpected server responses. + */ +export async function uploadReport( + report: AXReport, + options: UploadOptions +): Promise { + const { apiUrl, apiKey, timeout = DEFAULT_UPLOAD_TIMEOUT } = options; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeout); + + let res: Response; + try { + res = await fetch(apiUrl, { + method: 'POST', + signal: controller.signal, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + 'User-Agent': `AX-Score/${report.version}`, + }, + body: JSON.stringify(report), + }); + } catch (err) { + clearTimeout(timer); + if (err instanceof DOMException && err.name === 'AbortError') { + throw new Error(`Upload timed out after ${timeout}ms.`); + } + throw new Error( + `Upload failed: ${err instanceof Error ? err.message : 'Unknown network error'}` + ); + } finally { + clearTimeout(timer); + } + + if (res.status === 401 || res.status === 403) { + throw new Error( + 'Upload failed: Invalid or expired API key. Check your AGENTGRAM_API_KEY.' + ); + } + + if (!res.ok) { + let errorMessage = `Upload failed with status ${res.status}`; + try { + const body = (await res.json()) as UploadErrorResponse; + if (body.error?.message) { + errorMessage += `: ${body.error.message}`; + } + } catch { + // Could not parse error body + } + throw new Error(errorMessage); + } + + const body = (await res.json()) as UploadResponse; + + if (!body.success) { + throw new Error( + `Upload rejected: ${body.error?.message ?? 'Unknown error from server'}` + ); + } + + return body; +}