Repository navigation
Expand file tree
/
Copy pathtest-negotiation-e2e.ts
More file actions
172 lines (165 loc) · 5.28 KB
/
Copy pathtest-negotiation-e2e.ts
File metadata and controls
172 lines (165 loc) · 5.28 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
import { mkdir, writeFile } from 'node:fs/promises'
import { dirname } from 'node:path'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { sleep } from '@sim/utils/helpers'
const logger = createLogger('DocsNegotiationE2E')
const origin = process.env.DOCS_E2E_BASE_URL
const reportPath = process.env.DOCS_NEGOTIATION_REPORT_PATH
if (!origin || !reportPath) {
throw new Error('DOCS_E2E_BASE_URL and DOCS_NEGOTIATION_REPORT_PATH are required')
}
const url = new URL('/introduction', origin)
if (!['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) {
throw new Error('The documentation server must use a loopback origin')
}
interface CheckResult {
name: string
status: 'passed' | 'failed'
durationMs: number
contentType?: string
vary?: string
statusCode?: number
error?: string
}
interface RepresentationCase {
name: string
accept: string
expected: 'text/html' | 'text/markdown'
method?: 'GET' | 'HEAD'
statusCode?: number
useHtmlValidator?: boolean
}
const cases: readonly RepresentationCase[] = [
{
name: 'HTML advertises its representation and router cache dimensions',
accept: 'text/html',
expected: 'text/html',
},
{
name: 'Markdown negotiates at the same URL',
accept: 'text/markdown',
expected: 'text/markdown',
},
{
name: 'HTML remains available after a Markdown request',
accept: 'text/html',
expected: 'text/html',
},
{
name: 'Higher Markdown quality wins',
accept: 'text/html;q=0.4,text/markdown;q=0.8',
expected: 'text/markdown',
},
{
name: 'Equal quality defaults to HTML',
accept: 'text/markdown;q=0.8,text/html;q=0.8',
expected: 'text/html',
},
{
name: 'Explicit Markdown rejection overrides wildcard',
accept: 'text/markdown;q=0,*/*;q=1',
expected: 'text/html',
},
{
name: 'Explicit HTML rejection permits Markdown wildcard',
accept: 'text/html;q=0,text/*;q=0.5',
expected: 'text/markdown',
},
{
name: 'HTML HEAD retains representation headers',
accept: 'text/html',
expected: 'text/html',
method: 'HEAD',
},
{
name: 'Markdown HEAD retains representation headers',
accept: 'text/markdown',
expected: 'text/markdown',
method: 'HEAD',
},
{
name: 'Matching HTML validator returns a 304 with cache dimensions',
accept: 'text/html',
expected: 'text/html',
statusCode: 304,
useHtmlValidator: true,
},
{
name: 'Conditional HTML HEAD returns a 304',
accept: 'text/html',
expected: 'text/html',
method: 'HEAD',
statusCode: 304,
useHtmlValidator: true,
},
{
name: 'An HTML validator cannot suppress the Markdown representation',
accept: 'text/markdown',
expected: 'text/markdown',
useHtmlValidator: true,
},
] as const
for (let attempt = 0; attempt < 20; attempt += 1) {
try {
const response = await fetch(url, { signal: AbortSignal.timeout(1000) })
await response.body?.cancel()
if (response.ok) break
} catch {}
await sleep(250)
}
const checks: CheckResult[] = []
let htmlValidator: string | undefined
for (const entry of cases) {
const started = performance.now()
const result: CheckResult = { name: entry.name, status: 'failed', durationMs: 0 }
try {
const headers = new Headers({ Accept: entry.accept })
if (entry.useHtmlValidator) {
if (!htmlValidator) throw new Error('HTML did not provide a cache validator')
headers.set('If-None-Match', htmlValidator)
}
const response = await fetch(url, {
method: entry.method ?? 'GET',
headers,
signal: AbortSignal.timeout(10000),
})
await response.body?.cancel()
result.statusCode = response.status
result.contentType = response.headers.get('Content-Type') ?? ''
result.vary = response.headers.get('Vary') ?? ''
const fields = result.vary.split(',').map((field) => field.trim().toLowerCase())
if (response.status !== (entry.statusCode ?? 200))
throw new Error(`Unexpected HTTP status ${response.status}`)
if (response.status !== 304 && !result.contentType.startsWith(entry.expected)) {
throw new Error(`Expected ${entry.expected}; received ${result.contentType}`)
}
if (!fields.includes('accept')) throw new Error('The response lost Vary: Accept')
if (entry.expected === 'text/html') {
if (response.status === 200) htmlValidator = response.headers.get('etag') ?? undefined
for (const field of [
'rsc',
'next-router-state-tree',
'next-router-prefetch',
'next-router-segment-prefetch',
]) {
if (!fields.includes(field)) throw new Error(`The response lost Vary: ${field}`)
}
}
if (fields.length !== new Set(fields).size) throw new Error('Vary contains duplicate fields')
result.status = 'passed'
} catch (error) {
result.error = getErrorMessage(error)
}
result.durationMs = Math.round(performance.now() - started)
checks.push(result)
}
await mkdir(dirname(reportPath), { recursive: true })
await writeFile(reportPath, `${JSON.stringify({ checks }, null, 2)}\n`)
const failures = checks.filter((check) => check.status === 'failed')
if (failures.length) {
logger.error('Documentation negotiation failed', { failures })
process.exitCode = 1
} else {
logger.info('Documentation negotiation passed', { checks: checks.length })
}