Skip to content

Commit c9e2288

Browse files
committed
Add 'describeAgentDocsPerCheck' and CI-friendly reporting
1 parent ed4c81f commit c9e2288

4 files changed

Lines changed: 174 additions & 44 deletions

File tree

README.md

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -124,32 +124,90 @@ const result = await check.run(ctx);
124124

125125
## Test helpers
126126

127-
afdocs includes vitest helpers so you can add agent-friendliness checks to your docs site's test suite.
127+
afdocs includes vitest helpers so you can add agent-friendliness checks to your docs site's CI pipeline.
128128

129-
### Config-driven
129+
### Setup
130130

131-
Create `agent-docs.config.yml`:
131+
Install afdocs and vitest:
132+
133+
```bash
134+
npm install -D afdocs vitest
135+
```
136+
137+
Create `agent-docs.config.yml` in your project root (or a `tests/` subdirectory):
138+
139+
```yaml
140+
url: https://docs.example.com
141+
```
142+
143+
Create a test file:
144+
145+
```ts
146+
import { describeAgentDocsPerCheck } from 'afdocs/helpers';
147+
148+
describeAgentDocsPerCheck();
149+
```
150+
151+
Run it:
152+
153+
```bash
154+
npx vitest run agent-docs.test.ts
155+
```
156+
157+
Each check appears as its own test in the output, so you can see exactly what passed, warned, failed, or was skipped:
158+
159+
```
160+
✓ Agent-Friendly Documentation > llms-txt-exists
161+
✓ Agent-Friendly Documentation > llms-txt-valid
162+
✓ Agent-Friendly Documentation > llms-txt-size
163+
× Agent-Friendly Documentation > markdown-url-support
164+
↓ Agent-Friendly Documentation > page-size-markdown
165+
```
166+
167+
Checks that fail cause the test to fail. Checks that warn still pass (they're informational). Checks skipped due to unmet dependencies or config filtering show as skipped.
168+
169+
### Running a subset of checks
170+
171+
If your platform doesn't support certain checks (for example, you can't serve markdown), you can limit which checks run via the config:
132172

133173
```yaml
134174
url: https://docs.example.com
135175
checks:
136176
- llms-txt-exists
137177
- llms-txt-valid
138178
- llms-txt-size
179+
- http-status-codes
180+
- auth-gate-detection
139181
```
140182
141-
Then in your test file:
183+
Only the listed checks will run. The rest show as skipped in the test output.
184+
185+
### Config resolution
186+
187+
The helpers look for `agent-docs.config.yml` (or `.yaml`) starting from `process.cwd()` and walking up the directory tree, so the config works whether your test file is at the project root or in a subdirectory. You can also pass an explicit directory:
188+
189+
```ts
190+
describeAgentDocsPerCheck(__dirname);
191+
```
192+
193+
### Timeouts
194+
195+
The helpers set a 120-second timeout on the check run automatically. No vitest timeout configuration is needed.
196+
197+
### Summary helper
198+
199+
If you don't need per-check granularity, `describeAgentDocs` provides a simpler two-test suite (one to run checks, one to assert no failures):
142200

143201
```ts
144202
import { describeAgentDocs } from 'afdocs/helpers';
145203
146204
describeAgentDocs();
147205
```
148206

149-
This reads the config and generates one test assertion covering all specified checks.
150-
151207
### Direct imports
152208

209+
For full control, use the programmatic API directly:
210+
153211
```ts
154212
import { createContext, getCheck } from 'afdocs';
155213
import { describe, it, expect } from 'vitest';

src/helpers/config.ts

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,37 @@
11
import { readFile } from 'node:fs/promises';
2-
import { resolve } from 'node:path';
2+
import { dirname, resolve } from 'node:path';
33
import { parse as parseYaml } from 'yaml';
44
import type { AgentDocsConfig } from '../types.js';
55

66
const CONFIG_FILENAMES = ['agent-docs.config.yml', 'agent-docs.config.yaml'];
77

8+
/**
9+
* Search for an agent-docs config file starting from `dir` and walking up
10+
* to the filesystem root (like eslint, prettier, etc.).
11+
* If `dir` is omitted, starts from `process.cwd()`.
12+
*/
813
export async function loadConfig(dir?: string): Promise<AgentDocsConfig> {
9-
const searchDir = dir ?? process.cwd();
14+
let searchDir = resolve(dir ?? process.cwd());
1015

11-
for (const filename of CONFIG_FILENAMES) {
12-
const filepath = resolve(searchDir, filename);
13-
try {
14-
const content = await readFile(filepath, 'utf-8');
15-
const parsed = parseYaml(content) as AgentDocsConfig;
16-
if (!parsed.url) {
17-
throw new Error(`Config file ${filepath} is missing required "url" field`);
16+
while (true) {
17+
for (const filename of CONFIG_FILENAMES) {
18+
const filepath = resolve(searchDir, filename);
19+
try {
20+
const content = await readFile(filepath, 'utf-8');
21+
const parsed = parseYaml(content) as AgentDocsConfig;
22+
if (!parsed.url) {
23+
throw new Error(`Config file ${filepath} is missing required "url" field`);
24+
}
25+
return parsed;
26+
} catch (err) {
27+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') continue;
28+
throw err;
1829
}
19-
return parsed;
20-
} catch (err) {
21-
if ((err as NodeJS.ErrnoException).code === 'ENOENT') continue;
22-
throw err;
2330
}
31+
32+
const parent = dirname(searchDir);
33+
if (parent === searchDir) break; // reached filesystem root
34+
searchDir = parent;
2435
}
2536

2637
throw new Error(

src/helpers/vitest-runner.ts

Lines changed: 59 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,34 @@
1-
import { describe, it, expect } from 'vitest';
1+
import { describe, it, expect, beforeAll } from 'vitest';
22
import { runChecks } from '../runner.js';
33
import { loadConfig } from './config.js';
4-
import type { AgentDocsConfig, CheckResult } from '../types.js';
4+
import { getChecksSorted } from '../checks/registry.js';
5+
import type { AgentDocsConfig, CheckResult, ReportResult } from '../types.js';
56

67
// Ensure all checks are registered
78
import '../checks/index.js';
89

10+
const STATUS_ICON: Record<string, string> = {
11+
pass: '\u2713',
12+
warn: '\u26A0',
13+
fail: '\u2717',
14+
skip: '\u2192',
15+
error: '!',
16+
};
17+
18+
function resolveConfig(
19+
configOrDir: AgentDocsConfig | string | undefined,
20+
): Promise<AgentDocsConfig> {
21+
if (typeof configOrDir === 'object') return Promise.resolve(configOrDir);
22+
return loadConfig(typeof configOrDir === 'string' ? configOrDir : undefined);
23+
}
24+
925
/**
1026
* Auto-generates vitest tests from an agent-docs config file.
27+
* Produces a single pass/fail test with a summary of all check results.
1128
*
12-
* Usage in a test file:
29+
* Usage:
1330
* ```ts
14-
* import { describeAgentDocs } from 'agent-docs-testing/helpers';
31+
* import { describeAgentDocs } from 'afdocs/helpers';
1532
* describeAgentDocs();
1633
* ```
1734
*/
@@ -20,17 +37,14 @@ export function describeAgentDocs(configOrDir?: AgentDocsConfig | string): void
2037
let results: CheckResult[];
2138

2239
it('should run checks', async () => {
23-
const config =
24-
typeof configOrDir === 'object'
25-
? configOrDir
26-
: await loadConfig(typeof configOrDir === 'string' ? configOrDir : undefined);
40+
const config = await resolveConfig(configOrDir);
2741

2842
const report = await runChecks(config.url, {
2943
checkIds: config.checks,
3044
...config.options,
3145
});
3246
results = report.results;
33-
});
47+
}, 120_000);
3448

3549
it('should have no failing checks', () => {
3650
expect(results).toBeDefined();
@@ -45,34 +59,54 @@ export function describeAgentDocs(configOrDir?: AgentDocsConfig | string): void
4559

4660
/**
4761
* Auto-generates individual vitest tests per check from an agent-docs config.
62+
* Each check appears as its own test line in CI output, giving clear visibility
63+
* into which checks passed, warned, failed, or were skipped.
4864
*
4965
* Usage:
5066
* ```ts
51-
* import { describeAgentDocsPerCheck } from 'agent-docs-testing/helpers';
67+
* import { describeAgentDocsPerCheck } from 'afdocs/helpers';
5268
* describeAgentDocsPerCheck();
5369
* ```
5470
*/
5571
export function describeAgentDocsPerCheck(configOrDir?: AgentDocsConfig | string): void {
5672
describe('Agent-Friendly Documentation', () => {
57-
let _results: Map<string, CheckResult>;
58-
59-
// Run all checks once, then assert per-check
60-
it('should run checks', async () => {
61-
const config =
62-
typeof configOrDir === 'object'
63-
? configOrDir
64-
: await loadConfig(typeof configOrDir === 'string' ? configOrDir : undefined);
73+
let report: ReportResult;
74+
let resultsByCheck: Map<string, CheckResult>;
6575

66-
const report = await runChecks(config.url, {
76+
// Run all checks once upfront
77+
beforeAll(async () => {
78+
const config = await resolveConfig(configOrDir);
79+
report = await runChecks(config.url, {
6780
checkIds: config.checks,
6881
...config.options,
6982
});
70-
_results = new Map(report.results.map((r) => [r.id, r]));
71-
});
83+
resultsByCheck = new Map(report.results.map((r) => [r.id, r]));
84+
}, 120_000);
85+
86+
// Register a test per known check. Checks not included in the run
87+
// (filtered via config.checks) are reported as skipped rather than
88+
// silently passing, so CI output accurately reflects what was tested.
89+
const allChecks = getChecksSorted();
7290

73-
// Individual check assertions will be generated after the setup test runs.
74-
// Since vitest doesn't support dynamic test generation after suite setup,
75-
// users should use describeAgentDocs() for the simple case or import
76-
// checks individually for per-check control.
91+
for (const check of allChecks) {
92+
it(check.id, (ctx) => {
93+
const result = resultsByCheck?.get(check.id);
94+
if (!result) {
95+
// Check was filtered out by config
96+
ctx.skip();
97+
return;
98+
}
99+
100+
const icon = STATUS_ICON[result.status] ?? '?';
101+
console.log(`${icon} [${result.status}] ${result.message}`);
102+
103+
if (result.status === 'fail') {
104+
expect.fail(`${result.message}`);
105+
}
106+
if (result.status === 'error') {
107+
expect.fail(`Check error: ${result.message}`);
108+
}
109+
});
110+
}
77111
});
78112
}

test/unit/helpers/config.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,31 @@ describe('loadConfig', () => {
5151

5252
await expect(loadConfig(TMP_DIR)).rejects.toThrow('missing required "url" field');
5353
});
54+
55+
it('walks up directories to find config', async () => {
56+
const parentDir = TMP_DIR;
57+
const childDir = resolve(TMP_DIR, 'sub/nested');
58+
await mkdir(childDir, { recursive: true });
59+
await writeFile(
60+
resolve(parentDir, 'agent-docs.config.yml'),
61+
'url: https://parent.example.com\n',
62+
);
63+
64+
const config = await loadConfig(childDir);
65+
expect(config.url).toBe('https://parent.example.com');
66+
});
67+
68+
it('finds config in immediate dir before walking up', async () => {
69+
const parentDir = TMP_DIR;
70+
const childDir = resolve(TMP_DIR, 'sub');
71+
await mkdir(childDir, { recursive: true });
72+
await writeFile(
73+
resolve(parentDir, 'agent-docs.config.yml'),
74+
'url: https://parent.example.com\n',
75+
);
76+
await writeFile(resolve(childDir, 'agent-docs.config.yml'), 'url: https://child.example.com\n');
77+
78+
const config = await loadConfig(childDir);
79+
expect(config.url).toBe('https://child.example.com');
80+
});
5481
});

0 commit comments

Comments
 (0)