diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a25b80..21977a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,7 +53,39 @@ jobs: pnpm build diff -r "$RUNNER_TEMP/schemas-before-docs" dist + # Every code sample is run, so CI needs each sample language's runtime. + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + tools: composer + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.3' + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + cache: false + + - name: Install sample libraries + run: | + pip install requests + mkdir -p "$RUNNER_TEMP/php" + composer require guzzlehttp/guzzle:^7 --working-dir "$RUNNER_TEMP/php" --no-interaction + - name: Check reference code samples + env: + CODE_SAMPLES_PHP_DIR: ${{ runner.temp }}/php run: node tests/reference-code-samples.cjs - name: Dry run release diff --git a/build-docs.sh b/build-docs.sh index 13d0cee..d52c0df 100755 --- a/build-docs.sh +++ b/build-docs.sh @@ -25,6 +25,7 @@ node scripts/promote-request-examples.cjs "$OAS3_JSON" "$SAMPLES_JSON" # Convert OpenAPI to doc to Shins Markdown ./node_modules/.bin/widdershins \ --theme vs2015 \ + --user_templates templates/code-samples \ --language_tabs shell:Curl http:HTTP javascript--nodejs:NodeJS php:PHP ruby:Ruby python:Python java:Java go:Go \ --summary "$SAMPLES_JSON" \ --outfile "$DOCS_DIR/index.html.md" @@ -32,9 +33,11 @@ rm -f "$SAMPLES_JSON" cp "$DOCS_DIR/index.html.md" .shins/source/index.html.md -# Replace Serve, Ingest API URL's as overrides do not work -sed -i -e 's/https:\/\/api.shotstack.io\/edit\/{version}\/assets/https:\/\/api.shotstack.io\/serve\/{version}\/assets/g' .shins/source/index.html.md -sed -i -e 's/https:\/\/api.shotstack.io\/edit\/{version}\/sources/https:\/\/api.shotstack.io\/ingest\/{version}\/sources/g' .shins/source/index.html.md +# Replace Serve, Ingest API URL's as overrides do not work. Matching the path, not the full URL, also +# rewrites the HTTP samples' request lines. +sed -i -e 's/\/edit\/{version}\/assets/\/serve\/{version}\/assets/g' .shins/source/index.html.md +sed -i -e 's/\/edit\/{version}\/sources/\/ingest\/{version}\/sources/g' .shins/source/index.html.md +sed -i -e 's/\/edit\/{version}\/upload/\/ingest\/{version}\/upload/g' .shins/source/index.html.md # Build the Shins docs HTML cd .shins diff --git a/templates/code-samples/code_go.dot b/templates/code-samples/code_go.dot new file mode 100644 index 0000000..cbd4d3b --- /dev/null +++ b/templates/code-samples/code_go.dot @@ -0,0 +1,32 @@ +{{#def.sample}}package main + +import ( + "fmt" +{{? sample.returnsJson }} "io" +{{?}} "net/http" + "os" +{{? sample.hasBody }} "strings" +{{?}}) + +func main() { +{{? sample.hasBody }} body := strings.NewReader({{= sample.goBody }}) + +{{?}} req, err := http.NewRequest(http.Method{{= sample.verb }}, "{{= sample.url }}", {{= sample.hasBody ? 'body' : 'nil' }}) + if err != nil { + panic(err) + } +{{~ sample.headers('os.Getenv("SHOTSTACK_API_KEY")', JSON.stringify) :h }} req.Header.Set("{{= h.name }}", {{= h.value }}) +{{~}} + resp, err := http.DefaultClient.Do(req) + if err != nil { + panic(err) + } + defer resp.Body.Close() + +{{? sample.returnsJson }} out, err := io.ReadAll(resp.Body) + if err != nil { + panic(err) + } + fmt.Println(resp.Status, string(out)) +{{??}} fmt.Println(resp.Status) +{{?}}} \ No newline at end of file diff --git a/templates/code-samples/code_http.dot b/templates/code-samples/code_http.dot new file mode 100644 index 0000000..915e450 --- /dev/null +++ b/templates/code-samples/code_http.dot @@ -0,0 +1,6 @@ +{{#def.sample}}{{= sample.method }} {{= sample.path }} HTTP/1.1 +Host: {{= data.host }}{{~ sample.headers('YOUR_API_KEY', sample.raw) :h }} +{{= h.name }}: {{= h.value }}{{~}}{{? sample.hasBody }} +Content-Length: {{= Buffer.byteLength(sample.json) }} + +{{= sample.json }}{{?}} \ No newline at end of file diff --git a/templates/code-samples/code_java.dot b/templates/code-samples/code_java.dot new file mode 100644 index 0000000..91fe1f0 --- /dev/null +++ b/templates/code-samples/code_java.dot @@ -0,0 +1,20 @@ +{{#def.sample}}import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +public class Main { + public static void main(String[] args) throws Exception { +{{? sample.hasBody }} var body = """ +{{= sample.javaTextBlock }} + """; + +{{?}} var request = HttpRequest.newBuilder(URI.create("{{= sample.url }}")) +{{~ sample.headers('System.getenv("SHOTSTACK_API_KEY")', JSON.stringify) :h }} .header("{{= h.name }}", {{= h.value }}) +{{~}} .{{= sample.javaMethod }} + .build(); + + var response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + System.out.println({{= sample.returnsJson ? 'response.body()' : 'response.statusCode()' }}); + } +} \ No newline at end of file diff --git a/templates/code-samples/code_nodejs.dot b/templates/code-samples/code_nodejs.dot new file mode 100644 index 0000000..d2a6c58 --- /dev/null +++ b/templates/code-samples/code_nodejs.dot @@ -0,0 +1,11 @@ +{{#def.sample}}{{? sample.hasBody }}const body = {{= sample.json }}; + +{{?}}const response = await fetch("{{= sample.url }}", { +{{? sample.method !== 'GET' }} method: "{{= sample.method }}", +{{?}} headers: { +{{~ sample.headers('process.env.SHOTSTACK_API_KEY', JSON.stringify) :h }} "{{= h.name }}": {{= h.value }}, +{{~}} }, +{{? sample.hasBody }} body: JSON.stringify(body), +{{?}}}); + +console.log({{= sample.returnsJson ? 'await response.json()' : 'response.status' }}); \ No newline at end of file diff --git a/templates/code-samples/code_php.dot b/templates/code-samples/code_php.dot new file mode 100644 index 0000000..80e05af --- /dev/null +++ b/templates/code-samples/code_php.dot @@ -0,0 +1,16 @@ +{{#def.sample}}request('{{= sample.method }}', {{= sample.single(sample.url) }}, [ + 'headers' => [ +{{~ sample.headers("getenv('SHOTSTACK_API_KEY')", sample.single, true) :h }} {{= sample.single(h.name) }} => {{= h.value }}, +{{~}} ], +{{? sample.hasBody }} 'json' => {{= sample.literal(sample.body, sample.php, ' ') }}, +{{?}}]); + +echo {{= sample.returnsJson ? '$response->getBody()' : '$response->getStatusCode()' }}, PHP_EOL; \ No newline at end of file diff --git a/templates/code-samples/code_python.dot b/templates/code-samples/code_python.dot new file mode 100644 index 0000000..3bb9676 --- /dev/null +++ b/templates/code-samples/code_python.dot @@ -0,0 +1,16 @@ +{{#def.sample}}import os + +import requests + +{{? sample.hasBody }}body = {{= sample.literal(sample.body, sample.python) }} + +{{?}}response = requests.{{= data.method.verb }}( + "{{= sample.url }}", + headers={ +{{~ sample.headers('os.environ["SHOTSTACK_API_KEY"]', JSON.stringify, true) :h }} "{{= h.name }}": {{= h.value }}, +{{~}} }, +{{? sample.hasBody }} json=body, +{{?}} timeout=30, +) +response.raise_for_status() +print({{= sample.returnsJson ? 'response.json()' : 'response.status_code' }}) \ No newline at end of file diff --git a/templates/code-samples/code_ruby.dot b/templates/code-samples/code_ruby.dot new file mode 100644 index 0000000..6be91de --- /dev/null +++ b/templates/code-samples/code_ruby.dot @@ -0,0 +1,16 @@ +{{#def.sample}}{{? sample.hasBody }}require 'json' +{{?}}require 'net/http' + +uri = URI({{= sample.single(sample.url) }}) +{{? sample.hasBody }}body = {{= sample.literal(sample.body, sample.ruby) }} +{{?}} +request = Net::HTTP::{{= sample.verb }}.new(uri, { +{{~ sample.headers("ENV.fetch('SHOTSTACK_API_KEY')", sample.single) :h }} {{= sample.single(h.name) }} => {{= h.value }}, +{{~}}}) +{{? sample.hasBody }}request.body = body.to_json +{{?}} +response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http| + http.request(request) +end + +puts {{= sample.returnsJson ? 'response.body' : 'response.code' }} \ No newline at end of file diff --git a/templates/code-samples/code_shell.dot b/templates/code-samples/code_shell.dot new file mode 100644 index 0000000..587b844 --- /dev/null +++ b/templates/code-samples/code_shell.dot @@ -0,0 +1,4 @@ +{{#def.sample}}{{? sample.hasBody }}# Save the body parameter below as {{= sample.bodyFile }} +{{?}}curl {{? sample.method !== 'GET' }}-X {{= sample.method }} {{?}}"{{= sample.url }}"{{~ sample.headers('$SHOTSTACK_API_KEY', sample.raw) :h }} \ + -H "{{= h.name }}: {{= h.value }}"{{~}}{{? sample.hasBody }} \ + -d @{{= sample.bodyFile }}{{?}} \ No newline at end of file diff --git a/templates/code-samples/sample.def b/templates/code-samples/sample.def new file mode 100644 index 0000000..092b342 --- /dev/null +++ b/templates/code-samples/sample.def @@ -0,0 +1,74 @@ +{{ + /* Shared by the code_*.dot templates, which include it at their very start so it adds no output. + The templates read like the samples they produce; anything computed lives here. */ + var sample = {}; + sample.method = data.methodUpper; + sample.verb = data.methodUpper.charAt(0) + data.methodUpper.slice(1).toLowerCase(); + sample.url = data.url + data.requiredQueryString; + sample.path = sample.url.replace(/^https?:\/\/[^\/]+/, ''); + sample.hasBody = !!data.bodyParameter.present; + sample.body = data.bodyParameter.exampleValues.object; + sample.json = sample.hasBody ? JSON.stringify(sample.body, null, 2) : ''; + sample.returnsJson = data.produces.length > 0; + var ref = data.bodyParameter.refName; + sample.bodyFile = (ref ? ref.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase() : 'body') + '.json'; + + sample.raw = function (text) { return text; }; + sample.single = function (text) { return "'" + text.replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'"; }; + + /* Optional header parameters are left out: a sample would send their placeholder values. */ + var optional = (data.method.operation.parameters || []) + .filter(function (p) { return p.in === 'header' && !p.required; }) + .map(function (p) { return p.name; }); + sample.headers = function (apiKey, quote, dropContentType) { + return data.allHeaders + .filter(function (p) { return optional.indexOf(p.name) === -1; }) + .filter(function (p) { return !(dropContentType && sample.hasBody && p.name === 'Content-Type'); }) + .map(function (p) { + var value = p.name.toLowerCase() === 'x-api-key' ? apiKey : quote(String(p.exampleValues.object)); + return { name: p.name, value: value }; + }); + }; + + /* Renders a JSON value in a language's literal syntax. */ + sample.literal = function (value, syntax, indent) { + indent = indent || ''; + var inner = indent + syntax.indent; + if (value === null) return syntax.nil; + if (typeof value === 'boolean') return syntax.bool(value); + if (typeof value === 'number') return String(value); + if (typeof value === 'string') return syntax.string(value); + var list = Array.isArray(value); + var items = list + ? value.map(function (item) { return inner + sample.literal(item, syntax, inner) + ','; }) + : Object.keys(value).map(function (key) { return inner + syntax.key(key) + sample.literal(value[key], syntax, inner) + ','; }); + var brackets = list ? syntax.list : syntax.map; + if (!items.length) return list ? brackets.join('') : syntax.emptyMap; + return brackets[0] + '\n' + items.join('\n') + '\n' + indent + brackets[1]; + }; + sample.python = { + nil: 'None', bool: function (b) { return b ? 'True' : 'False'; }, string: JSON.stringify, + key: function (k) { return JSON.stringify(k) + ': '; }, list: ['[', ']'], map: ['{', '}'], emptyMap: '{}', indent: ' ' + }; + /* An empty object is (object) [], because Guzzle would encode an empty PHP array as a JSON list. */ + sample.php = { + nil: 'null', bool: String, string: sample.single, + key: function (k) { return sample.single(k) + ' => '; }, list: ['[', ']'], map: ['[', ']'], emptyMap: '(object) []', indent: ' ' + }; + sample.ruby = { + nil: 'nil', bool: String, string: sample.single, + key: function (k) { return /^[A-Za-z_][A-Za-z0-9_]*$/.test(k) ? k + ': ' : sample.single(k) + ' => '; }, + list: ['[', ']'], map: ['{', '}'], emptyMap: '{}', indent: ' ' + }; + + /* Java text blocks interpret backslashes, so JSON escapes are doubled to survive. */ + sample.javaTextBlock = sample.json.replace(/\\/g, '\\\\').split('\n').map(function (line) { return ' ' + line; }).join('\n'); + var publisher = sample.hasBody ? 'HttpRequest.BodyPublishers.ofString(body)' : 'HttpRequest.BodyPublishers.noBody()'; + sample.javaMethod = sample.method === 'GET' ? 'GET()' + : sample.method === 'DELETE' && !sample.hasBody ? 'DELETE()' + : sample.method === 'POST' || sample.method === 'PUT' ? sample.method + '(' + publisher + ')' + : 'method("' + sample.method + '", ' + publisher + ')'; + + /* A Go raw string can't contain a backtick, so such a body falls back to an interpreted string. */ + sample.goBody = sample.json.indexOf('`') === -1 ? '`' + sample.json + '`' : JSON.stringify(sample.json); +}} \ No newline at end of file diff --git a/tests/reference-code-samples.cjs b/tests/reference-code-samples.cjs index d2a30bb..4003002 100644 --- a/tests/reference-code-samples.cjs +++ b/tests/reference-code-samples.cjs @@ -1,47 +1,212 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); +const http = require('node:http'); +const net = require('node:net'); +const os = require('node:os'); const path = require('node:path'); +const { execFile } = require('node:child_process'); +const { promisify } = require('node:util'); +const widdershins = require('widdershins'); -// Reads the reference produced by `pnpm build:docs`. -const html = fs.readFileSync(path.resolve(__dirname, '..', 'build/docs/index.html'), 'utf8'); +// Runs every code sample in the reference built by `pnpm build:docs` against a local server +// and checks each one sends the request it documents. +const exec = promisify(execFile); +const root = path.resolve(__dirname, '..'); +const html = fs.readFileSync(path.join(root, 'build/docs/index.html'), 'utf8'); +const api = require(path.join(root, 'build/docs/api.bundled.json')); +const templates = path.join(root, 'templates/code-samples'); +const renderId = 'd2b46ed6-998a-4d6b-9d91-b8cf0193a655'; +const languageTabs = ['shell', 'http', 'javascript--nodejs', 'php', 'ruby', 'python', 'java', 'go']; + +const decode = (code) => code.replace(/<[^>]+>/g, '').replace(/</g, '<').replace(/>/g, '>') + .replace(/"/g, '"').replace(/'|'/g, "'").replace(/&/g, '&'); const samples = (lang) => html.split('

([\\s\\S]*?)`, 'g'); - return [...section.matchAll(pattern)].map((match) => ({ id, code: match[1] })); + return [...section.matchAll(pattern)].map((match) => ({ id, section, code: match[1] })); }); -const php = samples('php'); -assert.ok(php.length > 0, 'no PHP samples in build/docs/index.html'); -const truncated = php.filter(({ code }) => !code.startsWith('<?php')); -assert.equal(truncated.length, 0, - `PHP samples missing their opening lines: ${truncated.map(({ id }) => id).join(', ')}`); -console.log(`PHP samples: ${php.length} complete`); - -// Widdershins marks objects it reaches through a $ref; the mark must never reach the page. -const annotated = html.split('

url.replace('https://api.shotstack.io', origin) + .replace(/\{version\}/g, 'stage').replace(/\{[^}]+\}/g, renderId); -// Each request body panel shows the spec's example for that body. -const api = require(path.resolve(__dirname, '..', 'build/docs/api.bundled.json')); -const decode = (code) => code.replace(/<[^>]+>/g, '').replace(/</g, '<').replace(/>/g, '>') - .replace(/"/g, '"').replace(/'|'/g, "'").replace(/&/g, '&'); -const panels = html.split('

([\s\S]*?)<\/code><\/pre>/); - return example === undefined ? [] : [{ operationId: operation.operationId, example, shown: panel && decode(panel[1]) }]; + const [, base] = section.match(/Base URL:<\/strong> ([^<]+)<\/a>/) ?? []; + assert.ok(method && base, `${id}: no method, path or base URL in the rendered section`); + const operation = api.paths[route]?.[method.toLowerCase()]; + // A sample that sends an optional header sends its placeholder value, which the API then acts on. + const optionalHeaders = (operation?.parameters ?? []).filter((p) => p.in === 'header' && !p.required).map((p) => p.name.toLowerCase()); + return { method, path: fill(base.replace(/^https:\/\/[^/]+/, '') + route, ''), body: operation?.requestBody?.content?.['application/json']?.example, operationId: operation?.operationId, optionalHeaders }; +}; + +// Every sample reads the key from SHOTSTACK_API_KEY, so a per-run key ties each request to its sample. +const requests = new Map(); +const server = http.createServer((req, res) => { + const chunks = []; + req.on('data', (chunk) => chunks.push(chunk)).on('end', () => { + const key = req.headers['x-api-key'] ?? ''; + requests.set(key, [...(requests.get(key) ?? []), { method: req.method, path: req.url, headers: req.headers, body: Buffer.concat(chunks).toString('utf8') }]); + res.writeHead(200, { 'Content-Type': 'application/json' }).end('{"success":true}'); + }); }); -const stale = panels.filter(({ example, shown }) => { + +const phpDir = process.env.CODE_SAMPLES_PHP_DIR; +const runners = { + shell: { check: ['bash', ['--version']], file: 'sample.sh', run: (file) => ['bash', [file]] }, + 'javascript--nodejs': { check: ['node', ['--version']], file: 'sample.js', run: (file) => ['node', [file]] }, + python: { check: ['python3', ['-c', 'import requests']], file: 'sample.py', run: (file) => ['python3', [file]] }, + php: { check: ['php', ['-r', `require '${phpDir}/vendor/autoload.php';`]], file: 'sample.php', run: (file) => ['php', [file]] }, + ruby: { check: ['ruby', ['--version']], file: 'sample.rb', run: (file) => ['ruby', [file]] }, + java: { check: ['java', ['--version']], file: 'Main.java', run: (file) => ['java', [file]] }, + go: { check: ['go', ['version']], file: 'sample.go', run: (file) => ['go', ['run', file]] }, +}; + +const available = async (lang) => { + if (lang === 'http') return true; + if (lang === 'php' && !phpDir) return false; + const [bin, args] = runners[lang].check; + return exec(bin, args).then(() => true, () => false); +}; + +// Raw HTTP samples are sent over a socket exactly as written, with the request line pointed at the mock. +const sendRaw = (code, port) => new Promise((done, fail) => { + const [head, ...rest] = code.split(/\n\n/); + const lines = head.split('\n').map((line, index) => (index === 0 ? fill(line, '') : line)); + const socket = net.connect(port, '127.0.0.1', () => socket.end(`${lines.join('\r\n')}\r\n\r\n${rest.join('\n\n')}`)); + socket.on('data', () => socket.destroy()).on('close', done).on('error', fail); +}); + +let counter = 0; +const runSample = async (lang, code, body, port) => { + const key = `sample-key-${++counter}`; + const origin = `http://127.0.0.1:${port}`; + const source = code.replace(/https:\/\/api\.shotstack\.io[^\s'"`)]*/g, (url) => fill(url, origin)) + .replace(/YOUR_API_KEY/g, key); + if (lang === 'http') { + await sendRaw(source, port); + } else { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'code-sample-')); + const bodyFile = source.match(/-d @([\w.-]+)/)?.[1]; + if (bodyFile) fs.writeFileSync(path.join(dir, bodyFile), JSON.stringify(body, null, 2)); + if (lang === 'php') fs.symlinkSync(path.join(phpDir, 'vendor'), path.join(dir, 'vendor')); + fs.writeFileSync(path.join(dir, runners[lang].file), source); + const [bin, args] = runners[lang].run(runners[lang].file); + try { + await exec(bin, args, { cwd: dir, timeout: 120000, env: { ...process.env, SHOTSTACK_API_KEY: key } }); + } catch (error) { + return { error: (error.stderr || error.stdout || error.message).trim().split('\n').slice(-3).join(' | ') }; + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + } + return { received: requests.get(key) ?? [] }; +}; + +const verify = (label, outcome, expected) => { + if (outcome.error) return `${label}: failed to run: ${outcome.error}`; + const [request, ...extra] = outcome.received; + if (!request) return `${label}: no request carried the API key`; + if (extra.length) return `${label}: sent ${outcome.received.length} requests`; + if (request.method !== expected.method || request.path !== expected.path) { + return `${label}: sent ${request.method} ${request.path}, expected ${expected.method} ${expected.path}`; + } + const optional = (expected.optionalHeaders ?? []).filter((name) => name in request.headers); + if (optional.length) return `${label}: sent optional header ${optional.join(', ')}`; + if (!expected.body) return request.body ? `${label}: sent a body to an operation that takes none` : null; try { - assert.deepEqual(JSON.parse(shown), example); - return false; + assert.deepEqual(JSON.parse(request.body), expected.body); + return null; } catch { - return true; + return `${label}: body differs from the documented example: ${request.body.slice(0, 120) || '(empty)'}`; } +}; + +// Characters that end or interpolate a string literal in at least one of the languages. +const tricky = { + text: 'it\'s "quoted" \\ back\\slash $HOME #{x} `tick` """ end', + empty: {}, list: [], nested: { a: [{ b: {} }] }, n: 1.5, flag: true, none: null, unicode: 'é ✓', +}; +const syntheticSamples = async () => { + const spec = { + openapi: '3.0.3', info: { title: 'Samples', version: 'v1' }, + servers: [{ url: 'https://api.shotstack.io/edit/{version}', variables: { version: { default: 'v1' } } }], + security: [{ DeveloperKey: [] }], + components: { securitySchemes: { DeveloperKey: { type: 'apiKey', in: 'header', name: 'x-api-key' } } }, + paths: { '/render': { post: { + operationId: 'postRender', + requestBody: { content: { 'application/json': { schema: { type: 'object' }, examples: { tricky: { value: tricky } } } } }, + responses: { 201: { description: 'Created', content: { 'application/json': { schema: { type: 'object' } } } } }, + } } }, + }; + const markdown = await widdershins.convert(spec, { + codeSamples: true, sample: true, user_templates: templates, + language_tabs: languageTabs.map((lang) => ({ [lang]: lang })), + }); + return Object.fromEntries(languageTabs.map((lang) => [lang, + markdown.match(new RegExp('```' + lang.replace(/-/g, '\\-') + '\\n([\\s\\S]*?)\\n```'))?.[1]])); +}; + +const limit = async (items, size, task) => { + const results = []; + for (let i = 0; i < items.length; i += size) results.push(...await Promise.all(items.slice(i, i + size).map(task))); + return results; +}; + +(async () => { + const failures = []; + + const php = samples('php'); + assert.ok(php.length > 0, 'no PHP samples in build/docs/index.html'); + php.filter(({ code }) => !code.startsWith('<?php')) + .forEach(({ id }) => failures.push(`${id} php: sample is missing its opening lines`)); + + // Widdershins marks objects it reaches through a $ref; the mark must never reach the page. + html.split('

([\s\S]*?)<\/code><\/pre>/); + try { + assert.deepEqual(JSON.parse(decode(panel[1])), example); + } catch { + failures.push(`${section.slice(0, section.indexOf('"'))}: body panel is not the request example`); + } + } + if (!shown) failures.push('no request body panels on the page'); + + await new Promise((ready) => server.listen(0, '127.0.0.1', ready)); + const { port } = server.address(); + + const skipped = []; + const langs = []; + for (const lang of languageTabs) (await available(lang) ? langs : skipped).push(lang); + if (skipped.length && process.env.CI) failures.push(`runtime missing in CI: ${skipped.join(', ')}`); + + const jobs = langs.flatMap((lang) => samples(lang).map((sample) => ({ lang, sample, expected: expectation(sample) }))); + const results = await limit(jobs, 6, async ({ lang, sample, expected }) => + verify(`${sample.id} ${lang}`, await runSample(lang, decode(sample.code), expected.body, port), expected)); + + const synthetic = await syntheticSamples(); + const expected = { method: 'POST', path: '/edit/stage/render', body: tricky }; + const trickyResults = await limit(langs, 6, async (lang) => (synthetic[lang] + ? verify(`quoting ${lang}`, await runSample(lang, synthetic[lang], tricky, port), expected) + : `quoting ${lang}: no sample generated`)); + + server.close(); + failures.push(...results.filter(Boolean), ...trickyResults.filter(Boolean)); + const run = jobs.length + langs.length; + console.log(`Code samples: ${run - failures.length} of ${run} ran correctly (${langs.join(', ')})`); + if (skipped.length) console.log(`Skipped, runtime not installed: ${skipped.join(', ')}`); + assert.equal(failures.length, 0, `\n${failures.join('\n')}`); +})().catch((error) => { + console.error(error.message); + process.exit(1); }); -assert.ok(panels.length > 0, 'no request body panels on the page'); -assert.equal(stale.length, 0, `body panels not showing the spec's example: ${stale.map(({ operationId }) => operationId).join(', ')}`); -console.log(`Request body panels: ${panels.length} show their example`); diff --git a/tests/reference-versions.cjs b/tests/reference-versions.cjs index 9506fc7..768e243 100644 --- a/tests/reference-versions.cjs +++ b/tests/reference-versions.cjs @@ -29,7 +29,7 @@ const versions = [ const catalogue = (entries) => write('docs/reference/versions.json', JSON.stringify({ versions: entries })); try { - for (const file of ['build-docs.sh', 'scripts', 'assets', '.shins']) { + for (const file of ['build-docs.sh', 'scripts', 'assets', '.shins', 'templates']) { fs.cpSync(path.join(root, file), path.join(workspace, file), { recursive: true, filter: (source) => path.basename(source) !== 'node_modules', });