-
Notifications
You must be signed in to change notification settings - Fork 17
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Trigger builds when includes are modified (#60)
This triggers a build if any of the includes of a spec are modified by a pull request. This is in preparation for adding support for Bikeshed includes. ReSpec includes should already be supported.
- Loading branch information
Showing
5 changed files
with
218 additions
and
4 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,111 @@ | ||
const realFetchUrl = require("./fetch-url"); | ||
const GITHUB_SERVICE = require("./services").GITHUB; | ||
|
||
/** Resolves path relative to scopeUrl, with the restriction that path can't use | ||
* any components that might go "above" the base URL. | ||
* | ||
* @param {string} scopeUrl | ||
* @param {string} path | ||
* | ||
* @returns {string|undefined} `undefined` if either URL can't be parsed, or if | ||
* the path navigated "up" in any way. | ||
*/ | ||
function resolveSubUrl(scopeUrl, path) { | ||
if (!scopeUrl.endsWith('/')) { | ||
throw new Error(`${JSON.stringify(scopeUrl)} isn't a good scope URL.`) | ||
} | ||
if (path.startsWith('/') || | ||
path.includes('../') || | ||
// Block schemes, a bit more aggressively than necessary. | ||
path.includes(':')) { | ||
return undefined; | ||
} | ||
|
||
let resolvedUrl; | ||
try { | ||
resolvedUrl = new URL(path, scopeUrl).href; | ||
} catch { | ||
return undefined; | ||
} | ||
if (!resolvedUrl.startsWith(scopeUrl)) { | ||
throw new Error(`Path restrictions in resolveSubUrl(${JSON.stringify(scopeUrl)}, ${JSON.stringify(path)}) failed to catch an upward navigation.`); | ||
} | ||
return resolvedUrl; | ||
|
||
} | ||
|
||
/** Scans for recursively included paths from a root specification. | ||
* | ||
* @param {string} repositoryUrl The base URL of the repository. The function | ||
* won't fetch anything outside of this scope. This must end with a `/`. | ||
* @param {string} rootSpec A path relative to the `repositoryUrl` pointing to | ||
* the root specification in the `specType` format. | ||
* @param {"bikeshed"|"respec"|"html"|"wattsi"} specType | ||
* | ||
* @returns {Promise<Set<string>>} Paths relative to `repositoryUrl` that are | ||
* recursively included by the specification, not including the specification itself. | ||
*/ | ||
module.exports = async function scanIncludes(repositoryUrl, rootSpec, specType, fetchUrl = realFetchUrl) { | ||
if (!repositoryUrl.endsWith('/')) { | ||
throw new Error(`${repositoryUrl} must end with a '/'.`); | ||
} | ||
let includeRE; | ||
switch (specType) { | ||
case "bikeshed": | ||
// https://tabatkins.github.io/bikeshed/#including | ||
includeRE = /^path:([^\n]+)$/gm; | ||
break; | ||
case "respec": | ||
// https://github.com/w3c/respec/wiki/data--include | ||
includeRE = /data-include=["']?([^"'>]+)["']?/g; | ||
break; | ||
case "html": | ||
case "wattsi": | ||
// We don't support include scanning in pure HTML or Wattsi specs. | ||
return new Set(); | ||
default: | ||
throw new Error(`Unexpected specification type: ${JSON.stringify(specType)}`); | ||
} | ||
|
||
/** All the paths that successfully fetched go here. This is the result of | ||
* the overall scan. */ | ||
const recursiveIncludes = new Set(); | ||
/** All the files we ever fetched, for the purpose of short-circuiting. */ | ||
const everFetched = new Set(); | ||
|
||
/** Fetches path relative to repositoryUrl, adds it to recursiveIncludes if | ||
* the fetch worked, and then recursively scans any includes found in the | ||
* response. | ||
*/ | ||
async function scanOneFile(path) { | ||
if (everFetched.has(path)) return; | ||
everFetched.add(path); | ||
|
||
const resolvedUrl = resolveSubUrl(repositoryUrl, path); | ||
if (resolvedUrl === undefined) return; | ||
|
||
let body; | ||
try { | ||
body = await fetchUrl(resolvedUrl, GITHUB_SERVICE); | ||
console.log(`scanIncludes: Fetched ${resolvedUrl}.`) | ||
} catch (err) { | ||
// This include scan can have false positives, so if one | ||
// fails to fetch, we assume it's not a real include and | ||
// just ignore the fetch failure. | ||
console.log(`scanIncludes: Fetching ${resolvedUrl} failed: ${err.stack || JSON.stringify(err)}`) | ||
return; | ||
} | ||
recursiveIncludes.add(path); | ||
// Intentionally serial since Node's treatment of connection pooling is | ||
// hard to figure out from the documentation: | ||
for (const match of body.matchAll(includeRE)) { | ||
await scanOneFile(match[1].trim()); | ||
} | ||
} | ||
console.log(`scanIncludes: Recursively scanning ${rootSpec} in ${repositoryUrl} for includes.`) | ||
await scanOneFile(rootSpec); | ||
|
||
recursiveIncludes.delete(rootSpec); | ||
console.log(`scanIncludes: Found includes: ${JSON.stringify([...recursiveIncludes])}`) | ||
return [...recursiveIncludes].sort(); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,87 @@ | ||
const assert = require('assert'); | ||
const scanIncludes = require('../lib/scan-includes'); | ||
const GITHUB_SERVICE = require("../lib/services").GITHUB; | ||
|
||
function fakeFetch(results) { | ||
return async function fetch(url, service) { | ||
assert.equal(service, GITHUB_SERVICE); | ||
await new Promise(resolve => setImmediate(resolve)); | ||
const result = results[url]; | ||
assert(result); | ||
if (result.body !== undefined) { | ||
return result.body; | ||
} | ||
throw new Error(result.error); | ||
} | ||
} | ||
|
||
suite('scanIncludes', function () { | ||
test('No includes', async function () { | ||
assert.deepStrictEqual(await scanIncludes("https://github.example/repo/", "spec.bs", "bikeshed", fakeFetch({ | ||
"https://github.example/repo/spec.bs": { body: "bikeshed stuff" }, | ||
})), | ||
[]); | ||
}); | ||
test('None succeed', async function () { | ||
assert.deepStrictEqual(await scanIncludes("https://github.example/repo/", "spec.bs", "bikeshed", fakeFetch({ | ||
"https://github.example/repo/spec.bs": { error: "" }, | ||
})), | ||
[]); | ||
}); | ||
test('3 levels', async function () { | ||
assert.deepStrictEqual(await scanIncludes("https://github.example/repo/", "spec.bs", "bikeshed", fakeFetch({ | ||
"https://github.example/repo/spec.bs": { body: "path: helper.inc" }, | ||
"https://github.example/repo/helper.inc": { body: "path: helper2.inc\njunk\npath: doesntexist.inc" }, | ||
"https://github.example/repo/helper2.inc": { body: "path: helper3.inc " }, | ||
"https://github.example/repo/helper3.inc": { body: "" }, | ||
"https://github.example/repo/doesntexist.inc": { error: "404" }, | ||
})), | ||
[ | ||
"helper.inc", | ||
"helper2.inc", | ||
"helper3.inc", | ||
]); | ||
}); | ||
test('Outside repository', async function () { | ||
assert.deepStrictEqual(await scanIncludes("https://github.example/repo/", "spec.bs", "bikeshed", fakeFetch({ | ||
"https://github.example/repo/spec.bs": { body: "path: ../helper.inc\npath: //otherserver.example/helper2.inc\nhttps://yet.another.server.example/helper3.inc" }, | ||
"https://github.example/helper.inc": { body: "path: https://github.example/repo/poison.inc" }, | ||
"https://otherserver.example/helper2.inc": { body: "path: https://github.example/repo/poison.inc" }, | ||
"https://yet.another.server.example/helper3.inc": { body: "path: https://github.example/repo/poison.inc" }, | ||
"https://github.example/repo/poison.inc": { body: "Shouldn't fetch this." }, | ||
})), | ||
[]); | ||
}); | ||
test('Bad URL bits inside repository', async function () { | ||
assert.deepStrictEqual(await scanIncludes("https://github.example/repo/", "spec.bs", "bikeshed", fakeFetch({ | ||
"https://github.example/repo/spec.bs": { body: "path: ../repo/poison.inc\npath: /repo/poison.inc\npath: //github.example/repo/poison.inc\npath: https://github.example/repo/poison.inc" }, | ||
"https://github.example/repo/poison.inc": { body: "" }, | ||
})), | ||
[]); | ||
}); | ||
test('include loop should terminate', async function () { | ||
assert.deepStrictEqual(await scanIncludes("https://github.example/repo/", "spec.bs", "bikeshed", fakeFetch({ | ||
"https://github.example/repo/spec.bs": { body: "path: loop.inc" }, | ||
"https://github.example/repo/loop.inc": { body: "path: spec.bs" }, | ||
})), | ||
["loop.inc"]); | ||
}); | ||
test('respec-style includes', async function () { | ||
assert.deepStrictEqual(await scanIncludes("https://github.example/repo/", "index.html", "respec", fakeFetch({ | ||
"https://github.example/repo/index.html": { | ||
body: ` | ||
<section id="element-foo" | ||
data-include='single.html'> | ||
</section> | ||
<section id="element-foo" | ||
data-include="double.html"> | ||
</section>` }, | ||
"https://github.example/repo/single.html": { body: "" }, | ||
"https://github.example/repo/double.html": { body: "" }, | ||
})), | ||
[ | ||
"double.html", | ||
"single.html", | ||
]); | ||
}); | ||
}); |