feat: add toolbox contract shell and testkit
This commit is contained in:
482
packages/testkit/src/check.ts
Normal file
482
packages/testkit/src/check.ts
Normal file
@@ -0,0 +1,482 @@
|
||||
import {
|
||||
loadToolboxContext,
|
||||
parseToolboxApp,
|
||||
type ToolboxAppManifest,
|
||||
} from "@add-ideas/toolbox-contract";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { lstat, readFile, realpath, stat } from "node:fs/promises";
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { extname, relative, resolve, sep } from "node:path";
|
||||
|
||||
const DEEP_PREFIX = "/__toolbox-check__/deep/nested/app/";
|
||||
const CATALOG_PATH = "/toolbox.catalog.json";
|
||||
const SEMVER_PATTERN =
|
||||
/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
||||
|
||||
export class ToolboxCheckError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "ToolboxCheckError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface ToolboxCheckOptions {
|
||||
manifestName?: string;
|
||||
}
|
||||
|
||||
export interface ToolboxCheckReport {
|
||||
root: string;
|
||||
manifestPath: string;
|
||||
app: ToolboxAppManifest;
|
||||
checkedFiles: readonly string[];
|
||||
smoke: {
|
||||
standaloneUrl: string;
|
||||
contextualUrl: string;
|
||||
fetchedUrls: readonly string[];
|
||||
};
|
||||
}
|
||||
|
||||
interface CheckedReference {
|
||||
reference: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
function decodedReference(reference: string, label: string): string {
|
||||
let decoded = reference;
|
||||
try {
|
||||
for (let count = 0; count < 3; count += 1) {
|
||||
const next = decodeURIComponent(decoded);
|
||||
if (next === decoded) break;
|
||||
decoded = next;
|
||||
}
|
||||
} catch (cause) {
|
||||
throw new ToolboxCheckError(`${label} contains invalid URL encoding`, {
|
||||
cause,
|
||||
});
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
function localReference(
|
||||
root: string,
|
||||
reference: string,
|
||||
label: string,
|
||||
): CheckedReference {
|
||||
const decoded = decodedReference(reference, label);
|
||||
const pathPart = decoded.split(/[?#]/u, 1)[0] ?? "";
|
||||
if (
|
||||
pathPart.startsWith("/") ||
|
||||
pathPart.includes("\\") ||
|
||||
/^[a-z][a-z\d+.-]*:/iu.test(pathPart) ||
|
||||
pathPart.split("/").includes("..") ||
|
||||
pathPart.includes("\0")
|
||||
) {
|
||||
throw new ToolboxCheckError(`${label} uses an unsafe path: ${reference}`);
|
||||
}
|
||||
|
||||
let filePath = resolve(root, pathPart || ".");
|
||||
if (pathPart.endsWith("/") || pathPart === "." || pathPart === "./") {
|
||||
filePath = resolve(filePath, "index.html");
|
||||
}
|
||||
const fromRoot = relative(root, filePath);
|
||||
if (fromRoot === ".." || fromRoot.startsWith(`..${sep}`)) {
|
||||
throw new ToolboxCheckError(`${label} escapes the distribution directory`);
|
||||
}
|
||||
return { reference, path: filePath };
|
||||
}
|
||||
|
||||
async function assertRegularContainedFile(
|
||||
root: string,
|
||||
item: CheckedReference,
|
||||
label: string,
|
||||
): Promise<string> {
|
||||
try {
|
||||
await lstat(item.path);
|
||||
} catch (cause) {
|
||||
throw new ToolboxCheckError(`${label} does not exist: ${item.reference}`, {
|
||||
cause,
|
||||
});
|
||||
}
|
||||
const [realRoot, realFile] = await Promise.all([
|
||||
realpath(root),
|
||||
realpath(item.path),
|
||||
]);
|
||||
const fromRealRoot = relative(realRoot, realFile);
|
||||
if (fromRealRoot === ".." || fromRealRoot.startsWith(`..${sep}`)) {
|
||||
throw new ToolboxCheckError(`${label} resolves outside the distribution`);
|
||||
}
|
||||
const info = await stat(realFile);
|
||||
if (!info.isFile()) {
|
||||
throw new ToolboxCheckError(`${label} is not a file: ${item.reference}`);
|
||||
}
|
||||
return item.path;
|
||||
}
|
||||
|
||||
function contentType(path: string): string {
|
||||
switch (extname(path).toLowerCase()) {
|
||||
case ".html":
|
||||
return "text/html; charset=utf-8";
|
||||
case ".json":
|
||||
return "application/json; charset=utf-8";
|
||||
case ".css":
|
||||
return "text/css; charset=utf-8";
|
||||
case ".js":
|
||||
case ".mjs":
|
||||
return "text/javascript; charset=utf-8";
|
||||
case ".svg":
|
||||
return "image/svg+xml";
|
||||
case ".png":
|
||||
return "image/png";
|
||||
default:
|
||||
return "application/octet-stream";
|
||||
}
|
||||
}
|
||||
|
||||
function encodedPath(path: string): string {
|
||||
return path.split("/").map(encodeURIComponent).join("/");
|
||||
}
|
||||
|
||||
function catalogDocument(manifestName: string): object {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
id: "de.add-ideas.toolbox-check",
|
||||
name: "Toolbox check",
|
||||
home: "/",
|
||||
theme: { mode: "system", brand: "Toolbox check" },
|
||||
apps: [
|
||||
{
|
||||
manifest: `${DEEP_PREFIX}${encodedPath(manifestName)}`,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function staticServer(root: string, manifestName: string): Server {
|
||||
return createServer((request, response) => {
|
||||
void (async () => {
|
||||
const requestUrl = new URL(request.url ?? "/", "http://localhost");
|
||||
if (requestUrl.pathname === CATALOG_PATH) {
|
||||
response.writeHead(200, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
});
|
||||
response.end(JSON.stringify(catalogDocument(manifestName)));
|
||||
return;
|
||||
}
|
||||
if (!requestUrl.pathname.startsWith(DEEP_PREFIX)) {
|
||||
response.writeHead(404).end("Not found");
|
||||
return;
|
||||
}
|
||||
let relativePath: string;
|
||||
try {
|
||||
relativePath = decodeURIComponent(
|
||||
requestUrl.pathname.slice(DEEP_PREFIX.length),
|
||||
);
|
||||
} catch {
|
||||
response.writeHead(400).end("Bad path");
|
||||
return;
|
||||
}
|
||||
const checked = localReference(
|
||||
root,
|
||||
relativePath || "./",
|
||||
"Request path",
|
||||
);
|
||||
try {
|
||||
await assertRegularContainedFile(root, checked, "Requested file");
|
||||
} catch {
|
||||
response.writeHead(404).end("Not found");
|
||||
return;
|
||||
}
|
||||
response.writeHead(200, { "content-type": contentType(checked.path) });
|
||||
createReadStream(checked.path).pipe(response);
|
||||
})().catch(() => {
|
||||
if (!response.headersSent) response.writeHead(500);
|
||||
response.end("Server error");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function listen(server: Server): Promise<number> {
|
||||
await new Promise<void>((resolvePromise, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => resolvePromise());
|
||||
});
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === "string") {
|
||||
throw new ToolboxCheckError("Could not determine smoke-test server port");
|
||||
}
|
||||
return address.port;
|
||||
}
|
||||
|
||||
async function close(server: Server): Promise<void> {
|
||||
await new Promise<void>((resolvePromise, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolvePromise()));
|
||||
});
|
||||
}
|
||||
|
||||
async function requireOk(url: URL, label: string): Promise<Response> {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new ToolboxCheckError(`${label} returned HTTP ${response.status}`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
interface HtmlResource {
|
||||
kind: "icon" | "image" | "manifest" | "script" | "stylesheet";
|
||||
reference: string;
|
||||
}
|
||||
|
||||
function attributes(source: string): Map<string, string> {
|
||||
const result = new Map<string, string>();
|
||||
const pattern = /([:\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/gu;
|
||||
for (const match of source.matchAll(pattern)) {
|
||||
const name = match[1]?.toLowerCase();
|
||||
const value = match[2] ?? match[3] ?? match[4];
|
||||
if (name !== undefined && value !== undefined) result.set(name, value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function htmlResources(html: string): readonly HtmlResource[] {
|
||||
const resources: HtmlResource[] = [];
|
||||
const tagPattern = /<(script|link|img)\b([^>]*)>/giu;
|
||||
for (const match of html.matchAll(tagPattern)) {
|
||||
const tag = match[1]?.toLowerCase();
|
||||
const values = attributes(match[2] ?? "");
|
||||
if (tag === "script") {
|
||||
const reference = values.get("src");
|
||||
if (reference) resources.push({ kind: "script", reference });
|
||||
} else if (tag === "img") {
|
||||
const reference = values.get("src");
|
||||
if (reference) resources.push({ kind: "image", reference });
|
||||
} else if (tag === "link") {
|
||||
const reference = values.get("href");
|
||||
const relationships = new Set(
|
||||
(values.get("rel") ?? "").toLowerCase().split(/\s+/u).filter(Boolean),
|
||||
);
|
||||
if (!reference) continue;
|
||||
if (relationships.has("manifest")) {
|
||||
resources.push({ kind: "manifest", reference });
|
||||
} else if (relationships.has("stylesheet")) {
|
||||
resources.push({ kind: "stylesheet", reference });
|
||||
} else if (
|
||||
relationships.has("icon") ||
|
||||
relationships.has("apple-touch-icon")
|
||||
) {
|
||||
resources.push({ kind: "icon", reference });
|
||||
} else if (
|
||||
relationships.has("modulepreload") ||
|
||||
relationships.has("preload")
|
||||
) {
|
||||
resources.push({ kind: "script", reference });
|
||||
}
|
||||
}
|
||||
}
|
||||
return resources;
|
||||
}
|
||||
|
||||
function nestedLocalUrl(
|
||||
reference: string,
|
||||
base: URL,
|
||||
label: string,
|
||||
): URL | undefined {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(reference, base);
|
||||
} catch (cause) {
|
||||
throw new ToolboxCheckError(`${label} has an invalid URL: ${reference}`, {
|
||||
cause,
|
||||
});
|
||||
}
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return undefined;
|
||||
if (url.origin !== base.origin) return undefined;
|
||||
if (!url.pathname.startsWith(DEEP_PREFIX)) {
|
||||
throw new ToolboxCheckError(
|
||||
`${label} is not relocatable under the nested prefix: ${reference}`,
|
||||
);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
async function inspectWebManifest(
|
||||
response: Response,
|
||||
manifestUrl: URL,
|
||||
): Promise<readonly string[]> {
|
||||
let document: unknown;
|
||||
try {
|
||||
document = await response.json();
|
||||
} catch (cause) {
|
||||
throw new ToolboxCheckError("Linked web manifest is not valid JSON", {
|
||||
cause,
|
||||
});
|
||||
}
|
||||
if (typeof document !== "object" || document === null) return [];
|
||||
const icons = (document as { icons?: unknown }).icons;
|
||||
if (icons === undefined) return [];
|
||||
if (!Array.isArray(icons)) {
|
||||
throw new ToolboxCheckError("Linked web manifest icons must be an array");
|
||||
}
|
||||
const fetched: string[] = [];
|
||||
for (const [index, icon] of icons.entries()) {
|
||||
if (typeof icon !== "object" || icon === null) {
|
||||
throw new ToolboxCheckError(
|
||||
`Web manifest icon ${index} must be an object`,
|
||||
);
|
||||
}
|
||||
const source = (icon as { src?: unknown }).src;
|
||||
if (typeof source !== "string" || source.length === 0) {
|
||||
throw new ToolboxCheckError(`Web manifest icon ${index} needs a src`);
|
||||
}
|
||||
const iconUrl = nestedLocalUrl(source, manifestUrl, "Web manifest icon");
|
||||
if (iconUrl !== undefined) {
|
||||
await requireOk(iconUrl, "Web manifest icon");
|
||||
fetched.push(iconUrl.href);
|
||||
}
|
||||
}
|
||||
return fetched;
|
||||
}
|
||||
|
||||
async function inspectEntryHtml(
|
||||
html: string,
|
||||
entryUrl: URL,
|
||||
): Promise<readonly string[]> {
|
||||
const fetched: string[] = [];
|
||||
for (const resource of htmlResources(html)) {
|
||||
const label = `HTML ${resource.kind}`;
|
||||
const url = nestedLocalUrl(resource.reference, entryUrl, label);
|
||||
if (url === undefined) continue;
|
||||
const response = await requireOk(url, label);
|
||||
fetched.push(url.href);
|
||||
if (resource.kind === "manifest") {
|
||||
fetched.push(...(await inspectWebManifest(response, url)));
|
||||
}
|
||||
}
|
||||
return fetched;
|
||||
}
|
||||
|
||||
async function smokeCheck(
|
||||
root: string,
|
||||
manifest: ToolboxAppManifest,
|
||||
manifestName: string,
|
||||
): Promise<ToolboxCheckReport["smoke"]> {
|
||||
const server = staticServer(root, manifestName);
|
||||
const port = await listen(server);
|
||||
try {
|
||||
const appBase = new URL(DEEP_PREFIX, `http://127.0.0.1:${port}/`);
|
||||
const standaloneUrl = new URL(manifest.entry, appBase);
|
||||
const standaloneResponse = await requireOk(
|
||||
standaloneUrl,
|
||||
"Standalone entry",
|
||||
);
|
||||
const fetchedUrls = await inspectEntryHtml(
|
||||
await standaloneResponse.text(),
|
||||
standaloneUrl,
|
||||
);
|
||||
const standalone = await loadToolboxContext({ location: standaloneUrl });
|
||||
if (standalone.status !== "standalone") {
|
||||
throw new ToolboxCheckError(
|
||||
"The standalone smoke URL discovered a toolbox",
|
||||
);
|
||||
}
|
||||
|
||||
const contextualUrl = new URL(standaloneUrl);
|
||||
contextualUrl.searchParams.set("toolbox", CATALOG_PATH);
|
||||
await requireOk(contextualUrl, "Contextual entry");
|
||||
const contextual = await loadToolboxContext({ location: contextualUrl });
|
||||
if (contextual.status !== "ready") {
|
||||
const reason =
|
||||
contextual.status === "error" ? `: ${contextual.error.message}` : "";
|
||||
throw new ToolboxCheckError(
|
||||
`Toolbox context smoke check failed${reason}`,
|
||||
);
|
||||
}
|
||||
const loaded = contextual.context.catalog.apps.find(
|
||||
(entry) => entry.kind === "manifest",
|
||||
);
|
||||
if (loaded?.kind !== "manifest" || loaded.app.manifest.id !== manifest.id) {
|
||||
throw new ToolboxCheckError(
|
||||
"Smoke catalog did not resolve the built app",
|
||||
);
|
||||
}
|
||||
return {
|
||||
standaloneUrl: standaloneUrl.href,
|
||||
contextualUrl: contextualUrl.href,
|
||||
fetchedUrls,
|
||||
};
|
||||
} finally {
|
||||
await close(server);
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkToolboxDist(
|
||||
dist: string,
|
||||
options: ToolboxCheckOptions = {},
|
||||
): Promise<ToolboxCheckReport> {
|
||||
const root = resolve(dist);
|
||||
let rootInfo;
|
||||
try {
|
||||
rootInfo = await stat(root);
|
||||
} catch (cause) {
|
||||
throw new ToolboxCheckError(
|
||||
`Distribution directory does not exist: ${root}`,
|
||||
{
|
||||
cause,
|
||||
},
|
||||
);
|
||||
}
|
||||
if (!rootInfo.isDirectory()) {
|
||||
throw new ToolboxCheckError(
|
||||
`Distribution path is not a directory: ${root}`,
|
||||
);
|
||||
}
|
||||
|
||||
const manifestName = options.manifestName ?? "toolbox-app.json";
|
||||
const manifestReference = localReference(root, manifestName, "Manifest");
|
||||
const manifestPath = await assertRegularContainedFile(
|
||||
root,
|
||||
manifestReference,
|
||||
"Manifest",
|
||||
);
|
||||
let document: unknown;
|
||||
try {
|
||||
document = JSON.parse(await readFile(manifestPath, "utf8"));
|
||||
} catch (cause) {
|
||||
throw new ToolboxCheckError(`Manifest is not valid JSON: ${manifestPath}`, {
|
||||
cause,
|
||||
});
|
||||
}
|
||||
let app: ToolboxAppManifest;
|
||||
try {
|
||||
app = parseToolboxApp(document);
|
||||
} catch (cause) {
|
||||
throw new ToolboxCheckError("Manifest does not satisfy toolbox app v1", {
|
||||
cause,
|
||||
});
|
||||
}
|
||||
if (!SEMVER_PATTERN.test(app.version)) {
|
||||
throw new ToolboxCheckError(
|
||||
`Manifest version is not SemVer: ${app.version}`,
|
||||
);
|
||||
}
|
||||
|
||||
const references: Array<[string, string]> = [
|
||||
[app.entry, "Application entry"],
|
||||
[app.icon, "Application icon"],
|
||||
...(app.assets ?? []).map((asset): [string, string] => [
|
||||
asset,
|
||||
"Application asset",
|
||||
]),
|
||||
];
|
||||
const checkedFiles = await Promise.all(
|
||||
references.map(([reference, label]) =>
|
||||
assertRegularContainedFile(
|
||||
root,
|
||||
localReference(root, reference, label),
|
||||
label,
|
||||
),
|
||||
),
|
||||
);
|
||||
const smoke = await smokeCheck(root, app, manifestName);
|
||||
return { root, manifestPath, app, checkedFiles, smoke };
|
||||
}
|
||||
28
packages/testkit/src/cli.ts
Normal file
28
packages/testkit/src/cli.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { checkToolboxDist } from "./check.js";
|
||||
|
||||
function usage(): void {
|
||||
console.log("Usage: toolbox-check <dist>");
|
||||
}
|
||||
|
||||
const arguments_ = process.argv.slice(2);
|
||||
if (arguments_.includes("--help") || arguments_.includes("-h")) {
|
||||
usage();
|
||||
} else if (arguments_.length !== 1) {
|
||||
usage();
|
||||
process.exitCode = 2;
|
||||
} else {
|
||||
checkToolboxDist(arguments_[0] as string)
|
||||
.then((report) => {
|
||||
console.log(
|
||||
`toolbox-check: ${report.app.id}@${report.app.version} passed (${report.checkedFiles.length} files, standalone + contextual smoke checks)`,
|
||||
);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error(
|
||||
`toolbox-check: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
2
packages/testkit/src/index.ts
Normal file
2
packages/testkit/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { checkToolboxDist, ToolboxCheckError } from "./check.js";
|
||||
export type { ToolboxCheckOptions, ToolboxCheckReport } from "./check.js";
|
||||
Reference in New Issue
Block a user