import {once} from "events" import {setTimeout} from "timers/promises" import {spawn, ChildProcess} from "child_process" import * as child_process from "child_process" import {command, subcommands, run, array, multioption, option, Type} from "cmd-ts" import * as path from "path" import * as fsPromises from "fs/promises" import fetch from "node-fetch" const VERDACCIO_DB_PATH = path.normalize(`${__dirname}/verdacciodb`) const VERDACCIO_CONFIG_PATH = path.normalize(`${__dirname}/verdaccio.yaml`) const AUTOMERGE_WASM_PATH = path.normalize(`${__dirname}/../../automerge-wasm`) const AUTOMERGE_JS_PATH = path.normalize(`${__dirname}/..`) const EXAMPLES_DIR = path.normalize(path.join(__dirname, "../", "examples")) // The different example projects in "../examples" type Example = "webpack" | "vite" // Type to parse strings to `Example` so the types line up for the `buildExamples` commmand const ReadExample: Type = { async from(str) { if (str === "webpack") { return "webpack" } else if (str === "vite") { return "vite" } else { throw new Error(`Unknown example type ${str}`) } } } type Profile = "dev" | "release" const ReadProfile: Type = { async from(str) { if (str === "dev") { return "dev" } else if (str === "release") { return "release" } else { throw new Error(`Unknown profile ${str}`) } } } const buildjs = command({ name: "buildjs", args: { profile: option({ type: ReadProfile, long: "profile", short: "p", defaultValue: () => "dev" as Profile }) }, handler: ({profile}) => { console.log("building js") withPublishedWasm(profile, async (registryUrl: string) => { await buildAndPublishAutomergeJs(registryUrl) }) } }) const buildWasm = command({ name: "buildwasm", args: { profile: option({ type: ReadProfile, long: "profile", short: "p", defaultValue: () => "dev" as Profile }) }, handler: ({profile}) => { console.log("building automerge-wasm") withRegistry( publishAutomergeTypes, buildAutomergeWasm(profile), ) } }) const buildexamples = command({ name: "buildexamples", args: { examples: multioption({ long: "example", short: "e", type: array(ReadExample), }), profile: option({ type: ReadProfile, long: "profile", short: "p", defaultValue: () => "dev" as Profile }) }, handler: ({examples, profile}) => { if (examples.length === 0) { examples = ["webpack", "vite"] } buildExamples(examples, profile) } }) const runRegistry = command({ name: "run-registry", args: { profile: option({ type: ReadProfile, long: "profile", short: "p", defaultValue: () => "dev" as Profile }) }, handler: ({profile}) => { withPublishedWasm(profile, async (registryUrl: string) => { await buildAndPublishAutomergeJs(registryUrl) console.log("\n************************") console.log(` Verdaccio NPM registry is running at ${registryUrl}`) console.log(" press CTRL-C to exit ") console.log("************************") await once(process, "SIGINT") }).catch(e => { console.error(`Failed: ${e}`) }) } }) const app = subcommands({ name: "e2e", cmds: {buildjs, buildexamples, buildwasm: buildWasm, "run-registry": runRegistry} }) run(app, process.argv.slice(2)) async function buildExamples(examples: Array, profile: Profile) { withPublishedWasm(profile, async (registryUrl) => { printHeader("building and publishing automerge") await buildAndPublishAutomergeJs(registryUrl) for (const example of examples) { printHeader(`building ${example} example`) if (example === "webpack") { const projectPath = path.join(EXAMPLES_DIR, example) await removeExistingAutomerge(projectPath) await fsPromises.rm(path.join(projectPath, "yarn.lock"), {force: true}) await spawnAndWait("yarn", ["--cwd", projectPath, "install", "--registry", registryUrl, "--check-files"], {stdio: "inherit"}) await spawnAndWait("yarn", ["--cwd", projectPath, "build"], {stdio: "inherit"}) } else if (example === "vite") { const projectPath = path.join(EXAMPLES_DIR, example) await removeExistingAutomerge(projectPath) await fsPromises.rm(path.join(projectPath, "yarn.lock"), {force: true}) await spawnAndWait("yarn", ["--cwd", projectPath, "install", "--registry", registryUrl, "--check-files"], {stdio: "inherit"}) await spawnAndWait("yarn", ["--cwd", projectPath, "build"], {stdio: "inherit"}) } } }) } type WithRegistryAction = (registryUrl: string) => Promise async function withRegistry(action: WithRegistryAction, ...actions: Array) { // First, start verdaccio printHeader("Starting verdaccio NPM server") const verd = await VerdaccioProcess.start() actions.unshift(action) for (const action of actions) { try { type Step = "verd-died" | "action-completed" const verdDied: () => Promise = async () => { await verd.died() return "verd-died" } const actionComplete: () => Promise = async () => { await action("http://localhost:4873") return "action-completed" } const result = await Promise.race([verdDied(), actionComplete()]) if (result === "verd-died") { throw new Error("verdaccio unexpectedly exited") } } catch(e) { await verd.kill() throw e } } await verd.kill() } async function withPublishedWasm(profile: Profile, action: WithRegistryAction) { withRegistry( publishAutomergeTypes, buildAutomergeWasm(profile), publishAutomergeWasm, action ) } async function publishAutomergeTypes(registryUrl: string) { // Publish automerge-types printHeader("Publishing automerge-types package to verdaccio") await fsPromises.rm(path.join(VERDACCIO_DB_PATH, "automerge-types"), { recursive: true, force: true} ) await yarnPublish(registryUrl, path.join(AUTOMERGE_WASM_PATH, "types")) } function buildAutomergeWasm(profile: Profile): WithRegistryAction { return async (registryUrl: string) => { printHeader("building automerge-wasm") await spawnAndWait("yarn", ["--cwd", AUTOMERGE_WASM_PATH, "--registry", registryUrl, "install"], {stdio: "inherit"}) const cmd = profile === "release" ? "release" : "debug" await spawnAndWait("yarn", ["--cwd", AUTOMERGE_WASM_PATH, cmd], {stdio: "inherit"}) } } async function publishAutomergeWasm(registryUrl: string) { printHeader("Publishing automerge-wasm to verdaccio") await fsPromises.rm(path.join(VERDACCIO_DB_PATH, "automerge-wasm"), { recursive: true, force: true} ) await yarnPublish(registryUrl, AUTOMERGE_WASM_PATH) } async function buildAndPublishAutomergeJs(registryUrl: string) { // Build the js package printHeader("Building automerge") await removeExistingAutomerge(AUTOMERGE_JS_PATH) await removeFromVerdaccio("automerge") await fsPromises.rm(path.join(AUTOMERGE_JS_PATH, "yarn.lock"), {force: true}) await spawnAndWait("yarn", ["--cwd", AUTOMERGE_JS_PATH, "install", "--registry", registryUrl, "--check-files"], {stdio: "inherit"}) await spawnAndWait("yarn", ["--cwd", AUTOMERGE_JS_PATH, "build"], {stdio: "inherit"}) await yarnPublish(registryUrl, AUTOMERGE_JS_PATH) } /** * A running verdaccio process * */ class VerdaccioProcess { child: ChildProcess stdout: Array stderr: Array constructor(child: ChildProcess) { this.child = child // Collect stdout/stderr otherwise the subprocess gets blocked writing this.stdout = [] this.stderr = [] this.child.on("data", (data) => this.stdout.push(data)) this.child.on("data", (data) => this.stderr.push(data)) const errCallback = (e: any) => { console.error("!!!!!!!!!ERROR IN VERDACCIO PROCESS!!!!!!!!!") console.error(" ", e) if (this.stdout.length > 0) { console.log("\n**Verdaccio stdout**") const stdout = Buffer.concat(this.stdout) process.stdout.write(stdout) } if (this.stderr.length > 0) { console.log("\n**Verdaccio stderr**") const stdout = Buffer.concat(this.stderr) process.stdout.write(stdout) } process.exit(-1) } this.child.on("error", errCallback) } /** * Spawn a verdaccio process and wait for it to respond succesfully to http requests * * The returned `VerdaccioProcess` can be used to control the subprocess */ static async start() { const child = spawn("yarn", ["verdaccio", "--config", VERDACCIO_CONFIG_PATH], {env: { FORCE_COLOR: "true"}}) // Forward stdout and stderr whilst waiting for startup to complete const stdoutCallback = (data: Buffer) => process.stdout.write(data) const stderrCallback = (data: Buffer) => process.stderr.write(data) child.stdout.on("data", stdoutCallback) child.stderr.on("data", stderrCallback) const errored = once(child, "error") const healthCheck = async () => { while (true) { try { const resp = await fetch("http://localhost:4873") if (resp.status === 200) { return } else { console.log(`Healthcheck failed: bad status ${resp.status}`) } } catch (e) { console.error(`Healthcheck failed: ${e}`) } await setTimeout(500) } } await Promise.race([healthCheck(), errored]) // Stop forwarding stdout/stderr child.stdout.off("data", stdoutCallback) child.stderr.off("data", stderrCallback) return new VerdaccioProcess(child) } /** * Send a SIGKILL to the process and wait for it to stop */ async kill() { this.child.kill(); const errored = once(this.child, "error") const finished = once(this.child, "close") await Promise.race([errored, finished]) } /** * A promise which resolves if the subprocess exits for some reason */ async died(): Promise { const [exit, _signal] = await once(this.child, "exit") return exit } } function printHeader(header: string) { console.log("\n===============================") console.log(` ${header}`) console.log("===============================") } /** * Removes the automerge, automerge-wasm, and automerge-js packages from * `$packageDir/node_modules` * * This is useful to force refreshing a package by use in combination with * `yarn install --check-files`, which checks if a package is present in * `node_modules` and if it is not forces a reinstall. * * @param packageDir - The directory containing the package.json of the target project */ async function removeExistingAutomerge(packageDir: string) { await fsPromises.rm(path.join(packageDir, "node_modules", "automerge-wasm"), {recursive: true, force: true}) await fsPromises.rm(path.join(packageDir, "node_modules", "automerge-types"), {recursive: true, force: true}) await fsPromises.rm(path.join(packageDir, "node_modules", "automerge"), {recursive: true, force: true}) } type SpawnResult = { stdout?: Buffer, stderr?: Buffer, } async function spawnAndWait(cmd: string, args: Array, options: child_process.SpawnOptions): Promise { const child = spawn(cmd, args, options) let stdout = null let stderr = null if (child.stdout) { stdout = [] child.stdout.on("data", data => stdout.push(data)) } if (child.stderr) { stderr = [] child.stderr.on("data", data => stderr.push(data)) } const [exit, _signal] = await once(child, "exit") if (exit && exit !== 0) { throw new Error("nonzero exit code") } return { stderr: stderr? Buffer.concat(stderr) : null, stdout: stdout ? Buffer.concat(stdout) : null } } /** * Remove a package from the verdaccio registry. This is necessary because we * often want to _replace_ a version rather than update the version number. * Obviously this is very bad and verboten in normal circumastances, but the * whole point here is to be able to test the entire packaging story so it's * okay I Promise. */ async function removeFromVerdaccio(packageName: string) { await fsPromises.rm(path.join(VERDACCIO_DB_PATH, packageName), {force: true, recursive: true}) } async function yarnPublish(registryUrl: string, cwd: string) { await spawnAndWait( "yarn", [ "--registry", registryUrl, "--cwd", cwd, "publish", "--non-interactive", ], { stdio: "inherit", env: { FORCE_COLOR: "true", // This is a fake token, it just has to be the right format npm_config__auth: "//localhost:4873/:_authToken=Gp2Mgxm4faa/7wp0dMSuRA==" } }) }