diff --git a/cli/browser.ts b/cli/browser.ts new file mode 100644 index 0000000..765af3d --- /dev/null +++ b/cli/browser.ts @@ -0,0 +1,27 @@ +import { unimplemented } from "../deps/std/assert.ts" +import { Buffer } from "../deps/std/io.ts" + +export interface BrowserRunFlags { + browser?: string + project: string + reload: string +} + +export default function run(_: BrowserRunFlags) { + return async (pathname: string, logs: Buffer): Promise => { + unimplemented() + + // TODO + // const project = rest.project ?? await (async () => { + // for (const pathname of ["deno.json", "deno.jsonc"]) { + // try { + // return JSON.parse(await Deno.readTextFile(pathname)) + // } catch (_e) {} + // } + // return + // })() + // const _importMapURL = project.importMap + // ? path.toFileUrl(path.join(Deno.cwd(), project.importMap)) + // : undefined + } +} diff --git a/cli/process.ts b/cli/process.ts new file mode 100644 index 0000000..fb0e4aa --- /dev/null +++ b/cli/process.ts @@ -0,0 +1,41 @@ +import { Buffer, readLines } from "../deps/std/io.ts" +import { readerFromStreamReader, writeAll } from "../deps/std/streams.ts" + +export interface DenoRunFlags { + reload?: string +} + +export function runDeno({ reload }: DenoRunFlags) { + return async (pathname: string, logs: Buffer): Promise => { + const flags = reload ? [`-r${reload === "" ? "" : `=${reload}`}`] : [] + const process = new Deno.Command(Deno.execPath(), { + args: ["run", "-A", ...flags, pathname], + stdout: "piped", + stderr: "piped", + }).spawn() + return collectResults(process, logs) + } +} + +export function runNode({}: Record) { + return async (pathname: string, logs: Buffer): Promise => { + const process = new Deno.Command("node", { + args: [pathname], + stdout: "piped", + stderr: "piped", + }).spawn() + return collectResults(process, logs) + } +} + +async function collectResults(process: Deno.ChildProcess, logs: Buffer) { + const [{ code }] = await Promise.all([ + process.status, + ...[process.stdout, process.stderr].map(async (stream) => { + const lineIter = readLines(readerFromStreamReader(stream.getReader())) + const encoder = new TextEncoder() + for await (const line of lineIter) await writeAll(logs, encoder.encode(`${line}\n`)) + }), + ]) + return code +} diff --git a/deps/cliffy.ts b/deps/cliffy.ts new file mode 100644 index 0000000..2b1db6a --- /dev/null +++ b/deps/cliffy.ts @@ -0,0 +1 @@ +export * from "https://deno.land/x/cliffy@v0.25.7/command/mod.ts" diff --git a/deps/esbuild.ts b/deps/esbuild.ts deleted file mode 100644 index a0282ad..0000000 --- a/deps/esbuild.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "https://deno.land/x/esbuild@v0.17.15/mod.js" diff --git a/deps/esbuild_deno_loader.ts b/deps/esbuild_deno_loader.ts deleted file mode 100644 index 5905de0..0000000 --- a/deps/esbuild_deno_loader.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "https://deno.land/x/esbuild_deno_loader@0.6.0/mod.ts" diff --git a/main.ts b/main.ts new file mode 100644 index 0000000..de31ef3 --- /dev/null +++ b/main.ts @@ -0,0 +1,106 @@ +import runBrowser from "./cli/browser.ts" +import { runDeno, runNode } from "./cli/process.ts" +import { Command } from "./deps/cliffy.ts" +import { blue, dim, gray, green, red, yellow } from "./deps/std/fmt/colors.ts" +import { walk } from "./deps/std/fs.ts" +import { Buffer } from "./deps/std/io.ts" +import * as path from "./deps/std/path.ts" +import { parseFrontmatter } from "./frontmatter.ts" +import { runWithConcurrency } from "./util.ts" + +interface GlobalRunnerParams { + concurrency: number + skip: boolean +} + +type Run = ( + t: Omit, +) => (pathname: string, logs: Buffer) => Promise + +const globalRunner = (f: Run) => { + return async ({ concurrency, skip, ...rest }: T, includePatterns: string) => { + const include: string[] = [] + for await ( + const { path: pathname } of walk(".", { + exts: [".ts", ".tsx", ".js"], + followSymlinks: true, + includeDirs: false, + match: includePatterns.split(" ").map((value) => { + if (typeof value !== "string") { + throw new Error( + `Specified an invalid include \`${value}\` (expected a glob or path to example file)`, + ) + } + return path.isGlob(value) ? path.globToRegExp(value) : new RegExp(value) + }), + }) + ) include.push(pathname) + + const failed: string[] = [] + let passed = 0 + let skipped = 0 + + await runWithConcurrency( + include.map((pathname) => async () => { + const { frontmatter } = parseFrontmatter(pathname, await Deno.readTextFile(pathname), { + test_skip(value) { + return value !== undefined + }, + }) + const quotedPathname = `"${pathname}"` + if (skip && frontmatter.test_skip) { + console.log(yellow("Skipped"), quotedPathname) + skipped++ + return + } + console.log(gray("Testing"), quotedPathname) + const logs = new Buffer() + const code = await f(rest)(pathname, logs) + passed++ + const progress = dim(`(${passed + skipped}/${include.length})`) + if (code) { + failed.push(pathname) + console.log(red("Failed"), progress, quotedPathname) + console.log(new TextDecoder().decode(logs.bytes())) + } else { + console.log(green("Passed"), progress, quotedPathname) + } + }), + concurrency, + ) + + if (failed.length) { + console.log(`${red("Erroring examples")}: \n - "${failed.join(`"\n - "`)}"`) + Deno.exit(1) + } else { + if (passed) console.log(blue(`${passed} examples passed`)) + if (skipped) console.log(gray(`${skipped} examples skipped`)) + Deno.exit(0) + } + } +} + +await new Command() + .name("egts") + .description("Example-related utilities used in Capi") + .command( + "test", + new Command() + .name("test") + .globalOption("-c, --concurrency ", "concurrency", { default: Infinity }) + .globalOption("--no-skip", "ignore skip frontmatter") + .command("deno") + .arguments("") + .option("-r, --reload ", "reload") + .action(globalRunner(runDeno)) + .command("node") + .arguments("") + .action(globalRunner(runNode)) + .command("browser") + .arguments("") + .option("-b, --browser ", "browser binary") + .option("-p, --project ", "project", { required: true }) + .option("-r, --reload ", "reload", { required: true }) + .action(globalRunner(runBrowser)), + ) + .parse(Deno.args) diff --git a/test.ts b/test.ts deleted file mode 100644 index ae1580a..0000000 --- a/test.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { unimplemented } from "./deps/std/assert.ts" -import { deferred } from "./deps/std/async.ts" -import { parse as parseFlags } from "./deps/std/flags.ts" -import { blue, dim, gray, green, red, yellow } from "./deps/std/fmt/colors.ts" -import { walk } from "./deps/std/fs.ts" -import { Buffer, readLines } from "./deps/std/io.ts" -import * as path from "./deps/std/path.ts" -import { readerFromStreamReader, writeAll } from "./deps/std/streams.ts" -import { parseFrontmatter } from "./frontmatter.ts" - -const { _: includePatterns, reload, "no-skip": noSkip, ...rest } = parseFlags(Deno.args, { - alias: { - b: "browser", - c: "concurrency", - p: "project", - r: "reload", - }, - boolean: ["no-skip"], - string: ["browser", "concurrency", "project", "reload"], - default: { - concurrency: Infinity, - }, -}) - -const include: string[] = [] -for await ( - const { path: pathname } of walk(".", { - exts: [".ts", ".tsx"], - followSymlinks: true, - includeDirs: false, - match: includePatterns.map((value) => { - if (typeof value !== "string") { - throw new Error( - `Specified an invalid include \`${value}\` (expected a glob or path to example file)`, - ) - } - return path.isGlob(value) ? path.globToRegExp(value) : new RegExp(value) - }), - }) -) include.push(pathname) - -const concurrency = +rest.concurrency - -// const project = rest.project ?? await (async () => { -// for (const pathname of ["deno.json", "deno.jsonc"]) { -// try { -// return JSON.parse(await Deno.readTextFile(pathname)) -// } catch (_e) {} -// } -// return -// })() -// const _importMapURL = project.importMap -// ? path.toFileUrl(path.join(Deno.cwd(), project.importMap)) -// : undefined - -const browser = rest.browser === undefined ? undefined : rest.browser || "chromium" - -const failed: string[] = [] -let passed = 0 -let skipped = 0 - -await runWithConcurrency( - include.map((pathname) => async () => { - const { frontmatter } = parseFrontmatter(pathname, await Deno.readTextFile(pathname), { - test_skip(value) { - return value !== undefined - }, - }) - const quotedPathname = `"${pathname}"` - if (!noSkip && frontmatter.test_skip) { - console.log(yellow("Skipped"), quotedPathname) - skipped++ - return - } - console.log(gray("Testing"), quotedPathname) - const logs = new Buffer() - const code = await (browser ? runBrowser : runDeno)(pathname, logs) - passed++ - const progress = dim(`(${passed + skipped}/${include.length})`) - if (code) { - failed.push(pathname) - console.log(red("Failed"), progress, quotedPathname) - console.log(new TextDecoder().decode(logs.bytes())) - } else { - console.log(green("Passed"), progress, quotedPathname) - } - }), - concurrency, -) - -if (failed.length) { - console.log(`${red("Erroring examples")}: \n - "${failed.join(`"\n - "`)}"`) - Deno.exit(1) -} else { - if (passed) console.log(blue(`${passed} examples passed`)) - if (skipped) console.log(gray(`${skipped} examples skipped`)) - Deno.exit() -} - -async function runDeno(pathname: string, logs: Buffer): Promise { - const flags = reload ? [`-r${reload === "" ? "" : `=${reload}`}`] : [] - const process = new Deno.Command(Deno.execPath(), { - args: ["run", "-A", ...flags, pathname], - stdout: "piped", - stderr: "piped", - }).spawn() - const [{ code }] = await Promise.all([ - process.status, - ...[process.stdout, process.stderr].map(async (stream) => { - const lineIter = readLines(readerFromStreamReader(stream.getReader())) - const encoder = new TextEncoder() - for await (const line of lineIter) await writeAll(logs, encoder.encode(`${line}\n`)) - }), - ]) - return code -} - -async function runBrowser(_pathname: string, _logs: Buffer) { - return unimplemented() -} - -function runWithConcurrency(fns: ReadonlyArray<() => Promise>, concurrency: number) { - const queue = [...fns] - let running = 0 - const results: Promise[] = [] - const final = deferred() - flushQueue() - return final - - function flushQueue() { - for (; running < concurrency; running++) { - if (!queue.length) { - final.resolve(Promise.all(results)) - return - } - const promise = queue.shift()!() - results.push(promise) - promise.finally(() => { - running-- - flushQueue() - }) - } - } -} diff --git a/util.ts b/util.ts new file mode 100644 index 0000000..452de38 --- /dev/null +++ b/util.ts @@ -0,0 +1,25 @@ +import { deferred } from "./deps/std/async.ts" + +export function runWithConcurrency(fns: ReadonlyArray<() => Promise>, concurrency: number) { + const queue = [...fns] + let running = 0 + const results: Promise[] = [] + const final = deferred() + flushQueue() + return final + + function flushQueue() { + for (; running < concurrency; running++) { + if (!queue.length) { + final.resolve(Promise.all(results)) + return + } + const promise = queue.shift()!() + results.push(promise) + promise.finally(() => { + running-- + flushQueue() + }) + } + } +}