diff --git a/.github/workflows/fetch-info.yml b/.github/workflows/fetch-info.yml new file mode 100644 index 00000000..05686033 --- /dev/null +++ b/.github/workflows/fetch-info.yml @@ -0,0 +1,30 @@ +on: + schedule: + - cron: '10 */6 * * *' + push: + branches: + - master +name: Fetch additional spec info from external sources +jobs: + fetch: + runs-on: ubuntu-18.04 + steps: + - uses: actions/checkout@v1 + - uses: actions/setup-node@v1 + with: + node-version: 12.x + - name: Setup environment + run: | + echo "${{ secrets.CONFIG_JSON }}" | base64 --decode > config.json + npm ci + - name: Fetch info + run: npm run fetch-info + - name: Commit updates + run: | + git config user.name "fetch-info bot" + git config user.email "<>" + git commit -m "[data] Update spec info" -a + - name: Push changes + uses: ad-m/github-push-action@v0.5.0 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/lint-pr.yml b/.github/workflows/lint-pr.yml new file mode 100644 index 00000000..acb87843 --- /dev/null +++ b/.github/workflows/lint-pr.yml @@ -0,0 +1,13 @@ +on: pull_request +name: lint +jobs: + lint: + runs-on: ubuntu-18.04 + steps: + - uses: actions/checkout@v1 + - uses: actions/setup-node@v1 + with: + node-version: 12.x + - run: npm ci + - run: npm run test-pr + - run: npm run lint diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 0fcb335b..b7f654c6 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,4 +1,4 @@ -on: [push, pull_request] +on: push name: lint jobs: lint: @@ -8,6 +8,7 @@ jobs: - uses: actions/setup-node@v1 with: node-version: 12.x + - run: echo "${{ secrets.CONFIG_JSON }}" | base64 --decode > config.json - run: npm ci - run: npm run test - run: npm run lint diff --git a/.gitignore b/.gitignore index c2658d7d..1687d79e 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ node_modules/ +config.json diff --git a/index-schema.json b/index-schema.json deleted file mode 100644 index ff59c9cb..00000000 --- a/index-schema.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "$schema": "http://json-schema.org/schema#", - "type": "array", - "items": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "name": { - "type": "string", - "pattern": "^[\\w\\-]+((?<=\\-\\d+)\\.\\d+)?$" - }, - "shortname": { - "type": "string", - "pattern": "^[\\w\\-]+$" - }, - "level": { - "type": "number", - "minimum": 1 - }, - "levelComposition": { - "type": "string", - "enum": ["full", "delta"] - }, - "currentLevel": { - "type": "string", - "pattern": "^[\\w\\-]+((?<=\\-\\d+)\\.\\d+)?$" - }, - "previousLevel": { - "type": "string", - "pattern": "^[\\w\\-]+((?<=\\-\\d+)\\.\\d+)?$" - }, - "nextLevel": { - "type": "string", - "pattern": "^[\\w\\-]+((?<=\\-\\d+)\\.\\d+)?$" - } - }, - "required": ["url", "name", "shortname", "currentLevel"], - "additionalProperties": false - }, - "minItems": 1 -} diff --git a/index.js b/index.js index 9e89164f..4ca6de64 100644 --- a/index.js +++ b/index.js @@ -4,6 +4,16 @@ const computeShortname = require("./src/compute-shortname.js"); const computePrevNext = require("./src/compute-prevnext.js"); const computeCurrentLevel = require("./src/compute-currentlevel.js"); +// Retrieve generated spec info (if file was properly generated) +const specInfo = (function () { + try { + return require("./specs-info.json"); + } + catch (err) { + return {}; + } +})(); + const specs = require("./specs.json") // Turn all specs into objects // (and handle syntactic sugar notation for delta/current flags) @@ -37,7 +47,10 @@ const specs = require("./specs.json") .map(spec => { delete spec.forceCurrent; return spec; }) // Complete information with previous/next level links - .map((spec, _, list) => Object.assign(spec, computePrevNext(spec, list))); + .map((spec, _, list) => Object.assign(spec, computePrevNext(spec, list))) + + // Complete information with title and link to TR/ED URLs, when known + .map(spec => Object.assign(spec, specInfo[spec.name])); if (require.main === module) { @@ -55,7 +68,11 @@ if (require.main === module) { s.url === id || s.name === id || s.shortname === id || - s.levelComposition === id); + s.levelComposition === id || + s.title === id || + s.trUrl === id || + s.edUrl === id || + s.source === id); console.log(JSON.stringify(res.length === 1 ? res[0] : res, null, 2)); } else { diff --git a/lint.js b/lint.js index e1bde9b4..afbb9e41 100644 --- a/lint.js +++ b/lint.js @@ -1,17 +1,28 @@ "use strict"; const fs = require("fs").promises; -const schema = require("./specs-schema.json"); const computeShortname = require("./src/compute-shortname.js"); const computePrevNext = require("./src/compute-prevnext.js"); + +const schema = require("./schema/specs.json"); +const dfnsSchema = require("./schema/definitions.json"); const Ajv = require("ajv"); const ajv = new Ajv(); +const validate = ajv.addSchema(dfnsSchema).compile(schema); // When an entry is invalid, the schema validator returns one error for each // "oneOf" option and one error on overall "oneOf" problem. This is confusing // for humans. The following function improves the error being returned. const clarifyErrors = errors => { - if (!errors || errors.length < 2) { + if (!errors) { + return errors; + } + + // Update dataPath to drop misleading "[object Object]" + errors.forEach(err => + err.dataPath = err.dataPath.replace(/^\[object Object\]/, '')); + + if (errors.length < 2) { return errors; } @@ -96,12 +107,12 @@ function lintStr(specsStr) { const isSchemaValid = ajv.validateSchema(schema); if (!isSchemaValid) { - throw "The specs-schema.json file must be a valid JSON Schema file"; + throw "The schema/specs.json file must be a valid JSON Schema file"; } - var isValid = ajv.validate(schema, specs, { format: "full" }); + const isValid = validate(specs, { format: "full" }); if (!isValid) { - throw ajv.errorsText(clarifyErrors(ajv.errors), { + throw ajv.errorsText(clarifyErrors(validate.errors), { dataVar: "specs", separator: "\n" }); } diff --git a/package.json b/package.json index ee7cba65..d2c1cec8 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,11 @@ }, "main": "index.js", "scripts": { + "fetch-info": "node src/fetch-info.js > specs-info.json", "lint": "node lint.js", "lint-fix": "node lint.js --fix", - "test": "mocha" + "test": "mocha", + "test-pr": "mocha --exclude test/fetch-info-w3c.js" }, "devDependencies": { "ajv": "^6.11.0", diff --git a/schema/definitions.json b/schema/definitions.json new file mode 100644 index 00000000..a79308bb --- /dev/null +++ b/schema/definitions.json @@ -0,0 +1,44 @@ +{ + "$schema": "http://json-schema.org/schema#", + "$id": "https://github.com/w3c/browser-specs/tree/master/schema/definitions.json", + + "proptype": { + "url": { + "type": "string", + "format": "uri" + }, + + "name": { + "type": "string", + "pattern": "^[\\w\\-]+((?<=\\-\\d+)\\.\\d+)?$" + }, + + "shortname": { + "type": "string", + "pattern": "^[\\w\\-]+$" + }, + + "level": { + "type": "number", + "minimum": 1 + }, + + "levelComposition": { + "type": "string", + "enum": ["full", "delta"] + }, + + "forceCurrent": { + "type": "boolean" + }, + + "title": { + "type": "string" + }, + + "source": { + "type": "string", + "enum": ["w3c", "specref", "spec"] + } + } +} \ No newline at end of file diff --git a/schema/index.json b/schema/index.json new file mode 100644 index 00000000..a359fc63 --- /dev/null +++ b/schema/index.json @@ -0,0 +1,26 @@ +{ + "$schema": "http://json-schema.org/schema#", + "$id": "https://github.com/w3c/browser-specs/tree/master/schema/index.json", + + "type": "array", + "items": { + "type": "object", + "properties": { + "url": { "$ref": "definitions.json#/proptype/url" }, + "name": { "$ref": "definitions.json#/proptype/name" }, + "shortname": { "$ref": "definitions.json#/proptype/shortname" }, + "level": { "$ref": "definitions.json#/proptype/level" }, + "levelComposition": { "$ref": "definitions.json#/proptype/levelComposition" }, + "currentLevel": { "$ref": "definitions.json#/proptype/name" }, + "previousLevel": { "$ref": "definitions.json#/proptype/name" }, + "nextLevel": { "$ref": "definitions.json#/proptype/name" }, + "edUrl": { "$ref": "definitions.json#/proptype/url" }, + "trUrl": { "$ref": "definitions.json#/proptype/url" }, + "title": { "$ref": "definitions.json#/proptype/title" }, + "source": { "$ref": "definitions.json#/proptype/source" } + }, + "required": ["url", "name", "shortname", "currentLevel", "title", "source"], + "additionalProperties": false + }, + "minItems": 1 +} diff --git a/schema/specs-info.json b/schema/specs-info.json new file mode 100644 index 00000000..acfb0827 --- /dev/null +++ b/schema/specs-info.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/schema#", + "$id": "https://github.com/w3c/browser-specs/tree/master/schema/specs-info.json", + + "type": "object", + "propertyNames": { + "pattern": "^[\\w\\-]+((?<=\\-\\d+)\\.\\d+)?$" + }, + "additionalProperties": { + "type": "object", + "properties": { + "edUrl": { "$ref": "definitions.json#/proptype/url" }, + "trUrl": { "$ref": "definitions.json#/proptype/url" }, + "title": { "$ref": "definitions.json#/proptype/title" }, + "source": { "$ref": "definitions.json#/proptype/source" } + }, + "required": ["title", "source"], + "additionalProperties": false + }, + "minItems": 1 +} diff --git a/schema/specs.json b/schema/specs.json new file mode 100644 index 00000000..cf8aa23a --- /dev/null +++ b/schema/specs.json @@ -0,0 +1,28 @@ +{ + "$schema": "http://json-schema.org/schema#", + "$id": "https://github.com/w3c/browser-specs/tree/master/schema/specs.json", + + "type": "array", + "items": { + "oneOf": [ + { + "type": "string", + "pattern": "^https://[^\\s]+(\\s(delta|current))?$" + }, + { + "type": "object", + "properties": { + "url": { "$ref": "definitions.json#/proptype/url" }, + "name": { "$ref": "definitions.json#/proptype/name" }, + "shortname": { "$ref": "definitions.json#/proptype/shortname" }, + "level": { "$ref": "definitions.json#/proptype/level" }, + "levelComposition": { "$ref": "definitions.json#/proptype/levelComposition" }, + "forceCurrent": { "type": "boolean" } + }, + "required": ["url"], + "additionalProperties": false + } + ] + }, + "minItems": 1 +} diff --git a/specs-info.json b/specs-info.json new file mode 100644 index 00000000..1ce7efe1 --- /dev/null +++ b/specs-info.json @@ -0,0 +1,7 @@ +{ + "compat": { + "edUrl": "https://compat.spec.whatwg.org/", + "title": "Compatibility Standard", + "source": "specref" + } +} diff --git a/specs-schema.json b/specs-schema.json deleted file mode 100644 index 990c519a..00000000 --- a/specs-schema.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "$schema": "http://json-schema.org/schema#", - "type": "array", - "items": { - "oneOf": [ - { - "type": "string", - "pattern": "^https://[^\\s]+(\\s(delta|current))?$" - }, - { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "name": { - "type": "string", - "pattern": "^[\\w\\-]+((?<=\\-\\d+)\\.\\d+)?$" - }, - "shortname": { - "type": "string", - "pattern": "^[\\w\\-]+$" - }, - "level": { - "type": "number", - "minimum": 1 - }, - "levelComposition": { - "type": "string", - "enum": ["full", "delta"] - }, - "forceCurrent": { - "type": "boolean" - } - }, - "required": ["url"], - "additionalProperties": false - } - ] - }, - "minItems": 1 -} diff --git a/src/fetch-info.js b/src/fetch-info.js new file mode 100644 index 00000000..b2240b4c --- /dev/null +++ b/src/fetch-info.js @@ -0,0 +1,264 @@ +/** + * Module that exports a function that takes an array of specifications objects + * that each have at least a "url" and a "name" property. The function returns + * an object indexed by specification "name" with additional information + * about the specification fetched from the W3C API, Specref, or from the spec + * itself. Object returned for each specification contains the following + * properties: + * + * - "edUrl": the URL of the Editor's Draft of the specification. Always set. + * - "trUrl": the URL of the latest version of the specification published to + * /TR/. Only set when the specification has been published there. + * - "title": the title of the specification. Always set. + * - "source": one of "w3c", "specref", "spec", depending on how the information + * was determined. + * + * The function throws when something goes wrong, e.g. if the given spec object + * describes a /TR/ specification but the specification has actually not been + * published to /TR/, if the specification cannot be fetched, or if no W3C API + * key was specified for a /TR/ URL. + * + * The function will start by querying the W3C API, using the given "name" + * properties. For specifications where this fails, the function will query + * SpecRef, using the given "name" as well. If that too fails, the function + * assumes that the given "url" is the URL of the Editor's Draft, and will fetch + * it to determine the title. + * + * The function needs an API key to fetch the W3C API, which can be passed + * within an "options" object with a "w3cApiKey" property. + * + * If the function needs to retrieve the spec itself, note that it will parse + * the HTTP response body as a string, applying regular expressions to extract + * the title. It will not parse it as HTML in particular. This means that the + * function will fail if the title cannot easily be extracted for some reason. + * + * Note: the function operates on a list of specs and not only on one spec to + * bundle requests to Specref. + */ + +const https = require("https"); + + +async function fetchInfoFromW3CApi(specs, options) { + // Cannot query the W3C API if API key was not given + if (!options || !options.w3cApiKey) { + return []; + } + options.headers = options.headers || {}; + options.headers.Authorization = `W3C-API apikey="${options.w3cApiKey}"`; + + const info = await Promise.all(specs.map(async spec => { + // Skip specs when the known URL is not a /TR/ URL, because there may still + // be a spec with the same name published to /TR/ but that is probably an + // outdated version (e.g. WHATWG specs such as DOM or Fullscreen, or CSS + // drafts published a long long time ago) + if (!spec.url.match(/^https?:\/\/(?:www\.)?w3\.org\/TR\/([^\/]+)\/$/)) { + return; + } + + const url = `https://api.w3.org/specifications/${spec.name}`; + return new Promise((resolve, reject) => { + const request = https.get(url, options, res => { + if (res.statusCode === 404) { + resolve(null); + } + if (res.statusCode !== 200) { + reject(`W3C API returned an error, status code is ${res.statusCode}`); + return; + } + res.setEncoding("utf8"); + let data = ""; + res.on("data", chunk => data += chunk); + res.on("end", () => { + try { + resolve(JSON.parse(data)); + } + catch (err) { + reject("Specref returned invalid JSON"); + } + }); + }); + request.on("error", err => reject(err)); + request.end(); + }); + })); + + const results = {}; + specs.forEach((spec, idx) => { + if (info[idx]) { + results[spec.name] = { + trUrl: info[idx].shortlink, + edUrl: info[idx]["editor-draft"], + title: info[idx].title + }; + } + }); + return results; +} + + +async function fetchInfoFromSpecref(specs, options) { + function chunkArray(arr, len) { + let chunks = []; + let i = 0; + let n = arr.length; + while (i < n) { + chunks.push(arr.slice(i, i += len)); + } + return chunks; + } + + const chunks = chunkArray(specs, 50); + const chunksRes = await Promise.all(chunks.map(async chunk => { + let specrefUrl = "https://api.specref.org/bibrefs?refs=" + + chunk.map(spec => spec.name).join(','); + + return new Promise((resolve, reject) => { + const request = https.get(specrefUrl, options, res => { + if (res.statusCode !== 200) { + reject(`Could not query Specref, status code is ${res.statusCode}`); + return; + } + res.setEncoding("utf8"); + let data = ""; + res.on("data", chunk => data += chunk); + res.on("end", () => { + try { + resolve(JSON.parse(data)); + } + catch (err) { + reject("Specref returned invalid JSON"); + } + }); + }); + request.on("error", err => reject(err)); + request.end(); + }); + })); + + const results = {}; + chunksRes.forEach(chunkRes => { + + // Specref manages aliases, let's follow the chain to the final spec + function resolveAlias(name, counter) { + counter = counter || 0; + if (counter > 100) { + throw "Too many aliases returned by Respec"; + } + if (chunkRes[name].aliasOf) { + return resolveAlias(chunkRes[name].aliasOf, counter + 1); + } + else { + return name; + } + } + Object.keys(chunkRes).forEach(name => { + if (specs.find(spec => spec.name === name)) { + const info = chunkRes[resolveAlias(name)]; + results[name] = { + edUrl: info.edDraft || info.href, + title: info.title + }; + } + }); + }); + + return results; +} + + +async function fetchInfoFromSpecs(specs, options) { + const info = await Promise.all(specs.map(async spec => { + const html = await new Promise((resolve, reject) => { + const request = https.get(spec.url, options, res => { + if (res.statusCode !== 200) { + reject(`Could not fetch URL ${spec.url} for spec "${spec.name}", ` + + `status code is ${res.statusCode}`); + return; + } + res.setEncoding("utf8"); + let data = ""; + res.on("data", chunk => data += chunk); + res.on("end", () => { + resolve(data); + }); + }); + request.on("error", err => reject(err)); + request.end(); + }); + + // Extract first heading + const h1Match = html.match(/]*?>(.*?)<\/h1>/mis); + if (h1Match) { + return { + edUrl: spec.url, + title: h1Match[1].replace(/\n/g, '').trim() + }; + } + + // Use the document's title if first heading could not be found + // (that typically happens in Respec specs) + const titleMatch = html.match(/]*?>(.*?)<\/title>/mis); + if (titleMatch) { + return { + edUrl: spec.url, + title: titleMatch[1].replace(/\n/g, '').trim() + }; + } + + throw `Could not find title in ${spec.url} for spec "${spec.name}"`; + })); + + const results = {}; + specs.forEach((spec, idx) => results[spec.name] = info[idx]); + return results; +} + + +/** + * Main function that takes a list of specifications and returns an object + * indexed by specification "name" that provides, for each specification, the + * URL of the Editor's Draft, of the /TR/ version, and the title. + */ +async function fetchInfo(specs, options) { + if (!specs || specs.find(spec => !spec.name || !spec.url)) { + throw "Invalid list of specifications passed as parameter"; + } + + options = Object.assign({}, options); + options.timeout = options.timeout || 30000; + + // Compute information from W3C API + let remainingSpecs = specs; + const w3cInfo = await fetchInfoFromW3CApi(remainingSpecs, options); + + // Compute information from Specref for remaining specs + remainingSpecs = remainingSpecs.filter(spec => !w3cInfo[spec.name]); + const specrefInfo = await fetchInfoFromSpecref(remainingSpecs, options); + + // Extract information directly from the spec for remaining specs + remainingSpecs = remainingSpecs.filter(spec => !specrefInfo[spec.name]); + const specInfo = await fetchInfoFromSpecs(remainingSpecs, options); + + // Merge results + const results = {}; + specs.map(spec => spec.name).forEach(name => results[name] = + (w3cInfo[name] ? Object.assign(w3cInfo[name], { source: "w3c" }) : null) || + (specrefInfo[name] ? Object.assign(specrefInfo[name], { source: "specref" }) : null) || + (specInfo[name] ? Object.assign(specInfo[name], { source: "spec" }) : null)); + + return results; +} + + +if (require.main === module) { + // Code used as command-line interface (CLI), fetch info about all specs + const { specs } = require("../index.js"); + const { w3cApiKey } = require("../config.json"); + fetchInfo(specs, { w3cApiKey }) + .then(res => console.log(JSON.stringify(res, null, 2))); +} +else { + // Code referenced from another JS module, export fetch function + module.exports = fetchInfo; +} \ No newline at end of file diff --git a/test/fetch-info-w3c.js b/test/fetch-info-w3c.js new file mode 100644 index 00000000..58c7df52 --- /dev/null +++ b/test/fetch-info-w3c.js @@ -0,0 +1,118 @@ +/** + * Tests for the fetch-info module that require a W3C API key + * + * These tests are separated from the tests that do not require a W3C API key + * because the key cannot be exposed on pull requests from forked repositories + * on GitHub. + */ + +const assert = require("assert"); +const fetchInfo = require("../src/fetch-info.js"); + +const w3cApiKey = (function () { + try { + return require("../config.json").w3cApiKey; + } + catch (err) { + return null; + } +})(); + + +describe("fetch-info module (with W3C API key)", function () { + // Tests need to send network requests + this.slow(5000); + this.timeout(30000); + + function getW3CSpec(name) { + return { name, url: `https://www.w3.org/TR/${name}/` }; + } + + describe("W3C API key", () => { + it("is defined otherwise tests cannot pass", () => { + assert.ok(w3cApiKey); + }); + }); + + + describe("fetch from W3C API", () => { + it("works on a TR spec", async () => { + const spec = getW3CSpec("hr-time-2"); + const info = await fetchInfo([spec], { w3cApiKey }); + assert.ok(info[spec.name]); + assert.equal(info[spec.name].source, "w3c"); + assert.equal(info[spec.name].trUrl, spec.url); + assert.equal(info[spec.name].edUrl, "https://w3c.github.io/hr-time/"); + assert.equal(info[spec.name].title, "High Resolution Time Level 2"); + }); + + it("can operate on multiple specs at once", async () => { + const spec = getW3CSpec("hr-time-2"); + const other = getW3CSpec("presentation-api"); + const info = await fetchInfo([spec, other], { w3cApiKey }); + assert.ok(info[spec.name]); + assert.equal(info[spec.name].source, "w3c"); + assert.equal(info[spec.name].trUrl, spec.url); + assert.equal(info[spec.name].edUrl, "https://w3c.github.io/hr-time/"); + assert.equal(info[spec.name].title, "High Resolution Time Level 2"); + + assert.ok(info[other.name]); + assert.equal(info[other.name].source, "w3c"); + assert.equal(info[other.name].trUrl, other.url); + assert.equal(info[other.name].edUrl, "https://w3c.github.io/presentation-api/"); + assert.equal(info[other.name].title, "Presentation API"); + }); + + it("throws when W3C API key is invalid", async () => { + assert.rejects( + fetchInfo([getW3CSpec("selectors-3")], { w3cApiKey: "invalid" }), + /^W3C API returned an error, status code is 403$/); + }); + }); + + + describe("fetch from Specref", () => { + it("works on a WHATWG spec", async () => { + const spec = { + url: "https://dom.spec.whatwg.org/", + name: "dom" + }; + const info = await fetchInfo([spec], { w3cApiKey }); + assert.ok(info[spec.name]); + assert.equal(info[spec.name].source, "specref"); + assert.equal(info[spec.name].edUrl, "https://dom.spec.whatwg.org/"); + assert.equal(info[spec.name].title, "DOM Standard"); + }); + }); + + + describe("fetch from all sources", () => { + it("merges info from sources", async () => { + const w3c = getW3CSpec("presentation-api"); + const whatwg = { + url: "https://html.spec.whatwg.org/multipage/", + name: "html" + }; + const other = { + url: "https://tabatkins.github.io/bikeshed/", + name: "bikeshed" + }; + const info = await fetchInfo([w3c, whatwg, other], { w3cApiKey }); + assert.ok(info[w3c.name]); + assert.equal(info[w3c.name].source, "w3c"); + assert.equal(info[w3c.name].trUrl, w3c.url); + assert.equal(info[w3c.name].edUrl, "https://w3c.github.io/presentation-api/"); + assert.equal(info[w3c.name].title, "Presentation API"); + + assert.ok(info[whatwg.name]); + assert.equal(info[whatwg.name].source, "specref"); + assert.equal(info[whatwg.name].edUrl, whatwg.url); + assert.equal(info[whatwg.name].title, "HTML Standard"); + + assert.ok(info[other.name]); + assert.equal(info[other.name].source, "spec"); + assert.equal(info[other.name].edUrl, other.url); + assert.equal(info[other.name].title, "Bikeshed Documentation"); + }); + }); +}); \ No newline at end of file diff --git a/test/fetch-info.js b/test/fetch-info.js new file mode 100644 index 00000000..285fe4c0 --- /dev/null +++ b/test/fetch-info.js @@ -0,0 +1,106 @@ +/** + * Tests for the fetch-info module that do not require a W3C API key + * + * These tests are separated from the tests that require a W3C API key because + * the key cannot be exposed on pull requests from forked repositories on + * GitHub. + */ + +const assert = require("assert"); +const fetchInfo = require("../src/fetch-info.js"); + +describe("fetch-info module (without W3C API key)", function () { + // Tests need to send network requests + this.slow(5000); + this.timeout(30000); + + function getW3CSpec(name) { + return { name, url: `https://www.w3.org/TR/${name}/` }; + } + + + describe("fetch from Specref", () => { + it("works on a TR spec in the absence of W3C API key", async () => { + const spec = getW3CSpec("presentation-api"); + const info = await fetchInfo([spec]); + assert.ok(info[spec.name]); + assert.equal(info[spec.name].source, "specref"); + assert.equal(info[spec.name].edUrl, "https://w3c.github.io/presentation-api/"); + assert.equal(info[spec.name].title, "Presentation API"); + }); + + it("works on a WHATWG spec", async () => { + const spec = { + url: "https://dom.spec.whatwg.org/", + name: "dom" + }; + const info = await fetchInfo([spec]); + assert.ok(info[spec.name]); + assert.equal(info[spec.name].source, "specref"); + assert.equal(info[spec.name].edUrl, "https://dom.spec.whatwg.org/"); + assert.equal(info[spec.name].title, "DOM Standard"); + }); + + it("can operate on multiple specs at once", async () => { + const spec = getW3CSpec("presentation-api"); + const other = getW3CSpec("hr-time-2"); + const info = await fetchInfo([spec, other]); + assert.ok(info[spec.name]); + assert.equal(info[spec.name].source, "specref"); + assert.equal(info[spec.name].edUrl, "https://w3c.github.io/presentation-api/"); + assert.equal(info[spec.name].title, "Presentation API"); + + assert.ok(info[other.name]); + assert.equal(info[other.name].source, "specref"); + assert.equal(info[other.name].edUrl, "https://w3c.github.io/hr-time/"); + assert.equal(info[other.name].title, "High Resolution Time Level 2"); + }); + }); + + + describe("fetch from spec", () => { + it("extracts spec info from a Bikeshed spec when needed", async () => { + const spec = { + url: "https://tabatkins.github.io/bikeshed/", + name: "bikeshed" + }; + const info = await fetchInfo([spec]); + assert.ok(info[spec.name]); + assert.equal(info[spec.name].source, "spec"); + assert.equal(info[spec.name].edUrl, spec.url); + assert.equal(info[spec.name].title, "Bikeshed Documentation"); + }); + + it("extracts spec info from a Respec spec when needed", async () => { + const spec = { + url: "https://w3c.github.io/respec/examples/tpac_2019.html", + name: "respec" + }; + const info = await fetchInfo([spec]); + assert.ok(info[spec.name]); + assert.equal(info[spec.name].source, "spec"); + assert.equal(info[spec.name].edUrl, spec.url); + assert.equal(info[spec.name].title, "TPAC 2019 - New Features"); + }); + }); + + + describe("specs-info.json file", () => { + const schema = require("../schema/specs-info.json"); + const dfnsSchema = require("../schema/definitions.json"); + const info = require("../specs-info.json"); + const Ajv = require("ajv"); + const ajv = new Ajv(); + + it("has a valid JSON schema", () => { + const isSchemaValid = ajv.validateSchema(schema); + assert.ok(isSchemaValid); + }); + + it("respects the JSON schema", () => { + const validate = ajv.addSchema(dfnsSchema).compile(schema); + const isValid = ajv.validate(info, { format: "full" }); + assert.ok(isValid); + }); + }); +}); \ No newline at end of file diff --git a/test/index.js b/test/index.js index 4c4c786b..e7b51e25 100644 --- a/test/index.js +++ b/test/index.js @@ -6,7 +6,8 @@ const assert = require("assert"); const source = require("../specs.json"); const { specs } = require("../index.js"); -const schema = require("../index-schema.json"); +const schema = require("../schema/index.json"); +const dfnsSchema = require("../schema/definitions.json"); const Ajv = require("ajv"); const ajv = new Ajv(); @@ -17,7 +18,8 @@ describe("List of specs", () => { }); it("respects the JSON schema", () => { - const isValid = ajv.validate(schema, specs, { format: "full" }); + const validate = ajv.addSchema(dfnsSchema).compile(schema); + const isValid = validate(specs, { format: "full" }); assert.ok(isValid); });