diff --git a/docs/src/test.md b/docs/src/test.md index 4762061..5c09846 100644 --- a/docs/src/test.md +++ b/docs/src/test.md @@ -72,12 +72,11 @@ export default defineConfig({ mocha: { timeout: 10000 }, + watch: true, abortOnFail: false, - exitOnFinish: true, headless: false, startupDelay: 10000, waitForPlugin: `() => Zotero.MyPlugin.initialized`, - watch: false, hooks: {} } }); @@ -99,8 +98,6 @@ To handle such cases, use the `test.waitForPlugin` configuration option. This op ## Watch Mode -This feature is still under development. - In watch mode, Scaffold automatically: - Recompiles source code, reloads plugins, and reruns tests when the source changes. @@ -119,6 +116,7 @@ Run tests Options: --abort-on-fail Abort the test suite on first failure --exit-on-finish Exit the test suite after all tests have run + --no-watch Same with `exit-on-finish` -h, --help display help for command ``` diff --git a/packages/scaffold/package.json b/packages/scaffold/package.json index b08ec78..8bf7f1f 100644 --- a/packages/scaffold/package.json +++ b/packages/scaffold/package.json @@ -80,6 +80,7 @@ "fs-extra": "^11.3.0", "hookable": "^5.5.3", "octokit": "^4.1.2", + "pathe": "^2.0.3", "std-env": "^3.8.1", "tiny-update-notifier": "^2.0.0", "tinyglobby": "^0.2.12", diff --git a/packages/scaffold/src/cli.ts b/packages/scaffold/src/cli.ts index fc251a5..91c8a34 100644 --- a/packages/scaffold/src/cli.ts +++ b/packages/scaffold/src/cli.ts @@ -53,16 +53,16 @@ async function main() { .description("Run tests") .option("--abort-on-fail", "Abort the test suite on first failure") .option("--exit-on-finish", "Exit the test suite after all tests have run") + .option("--no-watch", "Exit the test suite after all tests have run") .action((options) => { process.env.NODE_ENV = "test"; - Config.loadConfig({}).then((ctx) => { - if (options.abortOnFail) { - ctx.test.abortOnFail = true; - } - if (options.exitOnFinish) { - ctx.test.exitOnFinish = true; - } + Config.loadConfig({ + test: { + abortOnFail: options.abortOnFail, + watch: !options.exitOnFinish && options.watch, + }, + }).then((ctx) => { new Test(ctx).run(); }); }); diff --git a/packages/scaffold/src/config.ts b/packages/scaffold/src/config.ts index 4f185fc..32c67a0 100644 --- a/packages/scaffold/src/config.ts +++ b/packages/scaffold/src/config.ts @@ -168,11 +168,10 @@ const defaultConfig = { timeout: 10000, }, abortOnFail: false, - exitOnFinish: false, headless: false, startupDelay: 1000, waitForPlugin: "() => true", - watch: false, + watch: true, hooks: {}, }, logLevel: "INFO", diff --git a/packages/scaffold/src/constant.ts b/packages/scaffold/src/constant.ts new file mode 100644 index 0000000..568393b --- /dev/null +++ b/packages/scaffold/src/constant.ts @@ -0,0 +1,13 @@ +export const BASE_DIR = ".scaffold"; +export const CACHE_DIR = `${BASE_DIR}/cache`; +export const DEFAULT_PROFILE_DIR = ""; +export const DEFAULT_DATA_DIR = ""; + +// Testser +export const TESTER_BASE_PATH = `${BASE_DIR}/test`; +export const TESTER_PROFILE_DIR = `${TESTER_BASE_PATH}/profile`; +export const TESTER_DATA_DIR = `${TESTER_BASE_PATH}/data`; +export const TESTER_PLUGIN_DIR = `${TESTER_BASE_PATH}/resource`; +export const TESTER_PLUGIN_TESTS_DIR = `${TESTER_PLUGIN_DIR}/content/units`; +export const TESTER_PLUGIN_REF = "zotero-plugin-scaffold-test-runner"; +export const TESTER_PLUGIN_ID = "scaffold-test@northword.cn"; diff --git a/packages/scaffold/src/core/builder.ts b/packages/scaffold/src/core/builder.ts index 583d2f6..42cb171 100644 --- a/packages/scaffold/src/core/builder.ts +++ b/packages/scaffold/src/core/builder.ts @@ -42,28 +42,28 @@ export default class Build extends Base { await emptyDir(dist); await this.ctx.hooks.callHook("build:mkdir", this.ctx); - this.logger.tip("Preparing static assets"); + this.logger.tip("Preparing static assets", { space: 1 }); await this.makeAssets(); await this.ctx.hooks.callHook("build:copyAssets", this.ctx); - this.logger.debug("Preparing manifest"); + this.logger.debug("Preparing manifest", { space: 2 }); await this.makeManifest(); await this.ctx.hooks.callHook("build:makeManifest", this.ctx); - this.logger.debug("Preparing locale files"); + this.logger.debug("Preparing locale files", { space: 2 }); await this.prepareLocaleFiles(); await this.ctx.hooks.callHook("build:fluent", this.ctx); await this.preparePrefs(); - this.logger.tip("Bundling scripts"); + this.logger.tip("Bundling scripts", { space: 1 }); await this.esbuild(); await this.ctx.hooks.callHook("build:bundle", this.ctx); /** ======== build resolved =========== */ if (process.env.NODE_ENV === "production") { - this.logger.tip("Packing plugin"); + this.logger.tip("Packing plugin", { space: 1 }); await this.pack(); await this.ctx.hooks.callHook("build:pack", this.ctx); diff --git a/packages/scaffold/src/core/server.ts b/packages/scaffold/src/core/server.ts index 2ead1c8..e0fd8b3 100644 --- a/packages/scaffold/src/core/server.ts +++ b/packages/scaffold/src/core/server.ts @@ -2,8 +2,7 @@ import type { Context } from "../types/index.js"; import { existsSync } from "node:fs"; import { join } from "node:path"; import process from "node:process"; -import chokidar from "chokidar"; -import { debounce } from "es-toolkit"; +import { watch } from "../utils/watcher.js"; import { ZoteroRunner } from "../utils/zotero-runner.js"; import { Base } from "./base.js"; import Build from "./builder.js"; @@ -69,47 +68,26 @@ export default class Serve extends Base { async watch() { const { source } = this.ctx; - const watcher = chokidar.watch(source, { - ignored: /(^|[/\\])\../, // ignore dotfiles - persistent: true, - }); - - const onChangeDebounced = debounce(async (path: string) => { - await this.onChange(path).catch((err) => { - // Do not abort the watcher when errors occur - // in builds triggered by the watcher. - this.logger.error(err); - }); - }, 500); - - watcher - .on("ready", async () => { - await this.ctx.hooks.callHook("serve:ready", this.ctx); - this.logger.clear(); - this.logger.ready("Server Ready!"); - }) - .on("change", async (path) => { - this.logger.clear(); - this.logger.info(`${path} changed`); - await onChangeDebounced(path); - }) - .on("error", (err) => { - this.logger.fail("Server start failed!"); - this.logger.error(err); - }); - } - - async onChange(path: string) { - await this.ctx.hooks.callHook("serve:onChanged", this.ctx, path); - - if (path.endsWith(".ts") || path.endsWith(".tsx")) { - await this.builder.esbuild(); - } - else { - await this.builder.run(); - } - - await this.reload(); + watch( + source, + { + onReady: async () => { + await this.ctx.hooks.callHook("serve:ready", this.ctx); + }, + onChange: async (path) => { + await this.ctx.hooks.callHook("serve:onChanged", this.ctx, path); + + if (path.endsWith(".ts") || path.endsWith(".tsx")) { + await this.builder.esbuild(); + } + else { + await this.builder.run(); + } + + await this.reload(); + }, + }, + ); } async reload() { diff --git a/packages/scaffold/src/core/tester.ts b/packages/scaffold/src/core/tester.ts deleted file mode 100644 index bc98ce9..0000000 --- a/packages/scaffold/src/core/tester.ts +++ /dev/null @@ -1,670 +0,0 @@ -import type { Context } from "../types/index.js"; -import http from "node:http"; -import { join, resolve } from "node:path"; -import process from "node:process"; -import { build } from "esbuild"; -import { copy, emptyDir, outputFile, outputJSON, pathExists } from "fs-extra/esm"; -import { isCI } from "std-env"; -import { glob } from "tinyglobby"; -import { Xvfb } from "xvfb-ts"; -import { saveResource } from "../utils/file.js"; -import { installDepsForUbuntu24, installXvfb, installZoteroLinux } from "../utils/headless.js"; -import { toArray } from "../utils/string.js"; -import { ZoteroRunner } from "../utils/zotero-runner.js"; -import { findFreeTcpPort } from "../utils/zotero/remote-zotero.js"; -import { Base } from "./base.js"; -import Build from "./builder.js"; - -export default class Test extends Base { - private builder: Build; - private runner?: ZoteroRunner; - private communicator: { server?: http.Server; port?: number } = {}; - - constructor(ctx: Context) { - super(ctx); - process.env.NODE_ENV ??= "test"; - - this.builder = new Build(ctx); - - if (isCI) { - this.ctx.test.exitOnFinish = true; - this.ctx.test.headless = true; - } - } - - async run() { - // Handle interrupt signal (Ctrl+C) to gracefully terminate Zotero process - // Must be placed at the top to prioritize registration of events to prevent web-ext interference - process.on("SIGINT", this.exit); - - // Empty dirs - await emptyDir(this.profilePath); - await emptyDir(this.dataDir); - await emptyDir(this.testPluginDir); - - await this.ctx.hooks.callHook("test:init", this.ctx); - - // prebuild - await this.builder.run(); - await this.ctx.hooks.callHook("test:prebuild", this.ctx); - - this.logger.clear(); - - await this.startHttpServer(); - await this.ctx.hooks.callHook("test:listen", this.ctx); - - await this.createTestPlugin(); - await this.copyTestLibraries(); - await this.ctx.hooks.callHook("test:copyAssets", this.ctx); - - await this.bundleTests(); - await this.ctx.hooks.callHook("test:bundleTests", this.ctx); - - if (this.ctx.test.watch) { - // - } - - if ((isCI || this.ctx.test.headless)) { - await this.startZoteroHeadless(); - } - else { - await this.startZotero(); - } - - await this.ctx.hooks.callHook("test:run", this.ctx); - } - - async createTestPlugin() { - const manifest = { - manifest_version: 2, - name: this.testPluginRef, - version: "0.0.1", - description: "Test suite for the Zotero plugin. This is a runtime-generated plugin only for testing purposes.", - applications: { - zotero: { - id: this.testPluginID, - update_url: "https://invalid.com", - // strict_min_version: "*.*.*", - strict_max_version: "999.*.*", - }, - }, - }; - await outputJSON(`${this.testPluginDir}/manifest.json`, manifest, { spaces: 2 }); - - const bootstrap = ` - /** - * Code generated by the zotero-plugin-scaffold tester - */ - - var chromeHandle; - - function install(data, reason) {} - - async function startup({ id, version, resourceURI, rootURI }, reason) { - await Zotero.initializationPromise; - const aomStartup = Components.classes[ - "@mozilla.org/addons/addon-manager-startup;1" - ].getService(Components.interfaces.amIAddonManagerStartup); - const manifestURI = Services.io.newURI(rootURI + "manifest.json"); - chromeHandle = aomStartup.registerChrome(manifestURI, [ - ["content", "${this.testPluginRef}", rootURI + "content/"], - ]); - - launchTests().catch((error) => { - Zotero.debug(error); - Zotero.HTTP.request( - "POST", - "http://localhost:${this.communicator.port}/update", - { - body: JSON.stringify({ - type: "fail", - data: { - title: "Internal: Plugin awaiting timeout", - stack: "", - str: "Plugin awaiting timeout", - }, - }), - } - ); - }); - } - - function onMainWindowLoad({ window: win }) {} - - function onMainWindowUnload({ window: win }) {} - - function shutdown({ id, version, resourceURI, rootURI }, reason) { - if (reason === APP_SHUTDOWN) { - return; - } - - if (chromeHandle) { - chromeHandle.destruct(); - chromeHandle = null; - } - } - - function uninstall(data, reason) {} - - async function launchTests() { - // Delay to allow plugin to fully load before opening the test page - await Zotero.Promise.delay(${this.ctx.test.startupDelay || 1000}); - - const waitForPlugin = "${this.ctx.test.waitForPlugin}"; - - if (waitForPlugin) { - // Wait for a plugin to be installed - await waitUtilAsync(() => { - try { - return !!eval(waitForPlugin)(); - } catch (error) { - return false; - } - }).catch(() => { - throw new Error("Plugin awaiting timeout"); - }); - } - - Services.ww.openWindow( - null, - "chrome://${this.testPluginRef}/content/index.xhtml", - "${this.ctx.namespace}-test", - "chrome,centerscreen,resizable=yes", - {} - ); - } - - function waitUtilAsync(condition, interval = 100, timeout = 1e4) { - return new Promise((resolve, reject) => { - const start = Date.now(); - const intervalId = setInterval(() => { - if (condition()) { - clearInterval(intervalId); - resolve(); - } else if (Date.now() - start > timeout) { - clearInterval(intervalId); - reject(); - } - }, interval); - }); - } - `.replaceAll(/^ {6}/g, ""); - await outputFile(`${this.testPluginDir}/bootstrap.js`, bootstrap); - } - - async copyTestLibraries() { - // Save mocha and chai packages - const pkgs: { - name: string; - remote: string; - local: string; - }[] = [ - { - name: "mocha.js", - local: "node_modules/mocha/mocha.js", - remote: "https://cdn.jsdelivr.net/npm/mocha/mocha.js", - }, - { - name: "chai.js", - // local: "node_modules/chai/chai.js", - local: "", // chai packages install from npm do not support browser - remote: "https://www.chaijs.com/chai.js", - }, - ]; - - await Promise.all(pkgs.map(async (pkg) => { - const targetPath = `${this.testPluginDir}/content/${pkg.name}`; - - if (pkg.local && await pathExists(pkg.local)) { - this.logger.debug(`Local ${pkg.name} package found`); - await copy(pkg.local, targetPath); - return; - } - - const cachePath = `${this.cacheDir}/${pkg.name}`; - if (await pathExists(`${cachePath}`)) { - this.logger.debug(`Cache ${pkg.name} package found`); - await copy(cachePath, targetPath); - return; - } - - this.logger.info(`No local ${pkg.name} found, we recommend you install ${pkg.name} package locally.`); - await saveResource(pkg.remote, `${this.cacheDir}/${pkg.name}`); - await copy(cachePath, targetPath); - })); - } - - async bundleTests() { - const testDirs = toArray(this.ctx.test.entries); - - // Bundle all test files, including both JavaScript and TypeScript - for (const dir of testDirs) { - let tsconfigPath: string | undefined = resolve(`${dir}/tsconfig.json`); - if (!await pathExists(tsconfigPath)) { - tsconfigPath = undefined; - } - - await build({ - entryPoints: await glob(`${dir}/**/*.spec.{js,ts}`), - outdir: `${this.testPluginDir}/content/units`, - bundle: true, - target: "firefox115", - tsconfig: tsconfigPath || undefined, - }); - } - - const testFiles = (await glob(`**/*.spec.js`, { cwd: `${this.testPluginDir}/content` })).sort(); - - // Generate test farmwork code - const setupCode = ` - mocha.setup({ ui: "bdd", reporter: Reporter, timeout: ${this.ctx.test.mocha.timeout} || 10000, }); - - window.expect = chai.expect; - window.assert = chai.assert; - - async function send(data) { - console.log("Sending data to server", data); - const req = await Zotero.HTTP.request( - "POST", - "http://localhost:${this.communicator.port}/update", - { - body: JSON.stringify(data), - } - ); - - if (req.status !== 200) { - dump("Error sending data to server" + req.responseText); - return null; - } else { - const result = JSON.parse(req.responseText); - return result; - } - } - - window.debug = function (...data) { - const str = data.join("\\n"); - Zotero.debug(str); - send({ type: "debug", data: { str } }); - }; - - // Inherit the default test settings from Zotero - function Reporter(runner) { - var indents = 0, - passed = 0, - failed = 0, - aborted = false; - - function indent() { - return Array(indents).join(" "); - } - - function dump(str) { - console.log(str); - document.querySelector("#mocha").innerText += str; - } - - runner.on("start", async function () { - await send({ type: "start" }); - }); - - runner.on("suite", async function (suite) { - ++indents; - const str = indent() + suite.title + "\\n"; - dump(str); - await send({ type: "suite", data: { title: suite.title, str } }); - }); - - runner.on("suite end", async function (suite) { - --indents; - const str = indents === 1 ? "\\n" : ""; - dump(str); - await send({ type: "suite end", data: { title: suite.title, str } }); - }); - - runner.on("pending", async function (test) { - const str = indent() + "pending -" + test.title + "\\n"; - dump(str); - await send({ type: "pending", data: { title: test.title, str } }); - }); - - runner.on("pass", async function (test) { - passed++; - let str = indent() + Mocha.reporters.Base.symbols.ok + " " + test.title; - if ("fast" != test.speed) { - str += " (" + Math.round(test.duration) + " ms)"; - } - str += "\\n"; - dump(str); - await send({ - type: "pass", - data: { title: test.title, duration: test.duration, str }, - }); - }); - - runner.on("fail", async function (test, err) { - // Make sure there's a blank line after all stack traces - err.stack = err.stack.replace(/\\s*$/, "\\n\\n"); - - failed++; - let indentStr = indent(); - const str = - indentStr + - // Dark red X for errors - "\\x1B[31;40m" + - Mocha.reporters.Base.symbols.err + - " [FAIL]\\x1B[0m" + - // Trigger bell if interactive - (Zotero.automatedTest ? "" : "\\x07") + - " " + - test.title + - "\\n" + - indentStr + - " " + - err.message + - " at\\n" + - err.stack.replace(/^/gm, indentStr + " ").trim() + - "\\n\\n"; - dump(str); - - if (${this.ctx.test.abortOnFail ? "true" : "false"}) { - aborted = true; - runner.abort(); - } - - await send({ - type: "fail", - data: { title: test.title, stack: err.stack, str }, - }); - }); - - runner.on("end", async function () { - const str = - passed + - "/" + - (passed + failed) + - " tests passed" + - (aborted ? " -- aborting" : "") + - "\\n"; - dump(str); - - await send({ - type: "end", - data: { passed: passed, failed: failed, aborted: aborted, str }, - }); - - // Must exit on Zotero side, otherwise the exit code will not be 0 and CI will fail - if (${this.ctx.test.exitOnFinish ? "true" : "false"}) { - Zotero.Utilities.Internal.quit(0); - } - }); - } - `.replaceAll(" ", " "); - // await outputFile(`${this.testPluginDir}/content/setup.js`, setupCode); - - const html = ` - - - - - - Zotero Plugin Test - - - -
- - - - - - - - - - - - - ${testFiles.map(f => `\n `)} - - - - - - `.replaceAll(/^ {6}/gm, ""); - await outputFile(`${this.testPluginDir}/content/index.xhtml`, html); - - // await outputFile(`${this.testPluginDir}/content/setup.js`, setupCode); - - this.logger.tip(`Injected ${testFiles.length} test files`); - } - - async startHttpServer() { - // Start a HTTP server to receive test results - // This is useful for CI/CD environments - this.communicator.server = http.createServer((req, res) => { - if (req.method === "GET" && req.url === "/") { - res.writeHead(200, { "Content-Type": "text/plain" }); - res.end("Zotero Plugin Test Server is running"); - } - else - if (req.method === "POST" && req.url === "/update") { - let body = ""; - - // Collect data chunks - req.on("data", (chunk) => { - body += chunk; - }); - - // Parse and handle the complete data - req.on("end", async () => { - try { - const jsonData = JSON.parse(body); - await this.onHttpDataUpdated(jsonData); - - // Send a response to the client - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ message: "Results received successfully" })); - } - catch (error) { - this.logger.error(`Error parsing JSON:, ${error}`); - res.writeHead(400, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Invalid JSON" })); - } - }); - } - else { - res.writeHead(404, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Not Found" })); - } - }); - - // Start the server - const PORT = this.communicator.port = await findFreeTcpPort(); - this.communicator.server.listen(PORT, () => { - this.logger.tip(`Server is listening on http://localhost:${PORT}`); - }); - } - - async onHttpDataUpdated(body: { - type: "start" | "suite" | "suite end" | "pending" | "pass" | "fail" | "end" | "debug"; - data?: { title: string; str: string; duration?: number; stack?: string }; - }) { - if (body.type === "debug" && body.data?.str) { - for (const line of body.data?.str.split("\n")) { - this.logger.log(line); - this.logger.newLine(); - } - } - const str = body.data?.str.replaceAll("\n", ""); - if (body.type === "start") { - this.logger.newLine(); - } - else if (body.type === "suite" && !!str) { - this.logger.tip(str); - } - if (body.type === "pass" && !!str) { - this.logger.log(str); - } - else if (body.type === "fail") { - this.logger.error(str); - if (this.ctx.test.abortOnFail) { - this.logger.error("Aborting test run due to failure"); - if (this.ctx.test.exitOnFinish) - this.exit(1); - } - } - else if (body.type === "suite end") { - this.logger.newLine(); - } - else if (body.type === "end") { - this.logger.success("Test run completed"); - this.communicator.server?.close(); - if (this.ctx.test.exitOnFinish) - this.exit(); - } - } - - async watch() { - // - } - - async onCodeChanged(_path: string) { - // - } - - async startZoteroHeadless() { - // Ensure xvfb installing - await installXvfb(); - await installDepsForUbuntu24(); - - // Download and Extract Zotero Beta Linux - await installZoteroLinux(); - - const xvfb = new Xvfb({ - timeout: 2000, - }); - await xvfb.start(); - await this.startZotero(); - } - - async startZotero() { - this.runner = new ZoteroRunner({ - binary: { - path: this.zoteroBinPath, - devtools: this.ctx.server.devtools, - args: this.ctx.server.startArgs, - }, - profile: { - path: this.profilePath, - dataDir: this.dataDir, - customPrefs: this.prefs, - }, - plugins: { - list: [{ - id: this.ctx.id, - sourceDir: join(this.ctx.dist, "addon"), - }, { - id: this.testPluginID, - sourceDir: this.testPluginDir, - }], - }, - }); - - await this.runner.run(); - } - - private exit = (code?: string | number) => { - this.communicator.server?.close(); - this.runner?.exit(); - - this.ctx.hooks.callHook("test:exit", this.ctx); - - if (code === 1) { - this.logger.error("Test run failed"); - process.exit(1); - } - else if (code === "SIGINT") { - this.logger.info("Tester shutdown by user request"); - process.exit(); - } - else { - this.logger.success("Test run completed successfully"); - process.exit(); - } - }; - - private get zoteroBinPath() { - if (!process.env.ZOTERO_PLUGIN_ZOTERO_BIN_PATH) - throw new Error("No Zotero Found."); - return process.env.ZOTERO_PLUGIN_ZOTERO_BIN_PATH; - } - - private get profilePath() { - return `.scaffold/test/profile`; - } - - private get dataDir() { - return `.scaffold/test/data`; - } - - private get testPluginDir() { - return `.scaffold/test/resource`; - } - - private get cacheDir() { - return `.scaffold/cache`; - } - - private get testPluginRef() { - return `${this.ctx.namespace}-test`; - } - - private get testPluginID() { - return `${this.testPluginRef}@only-for-testing.com`; - } - - private get prefs() { - const defaultPref = { - "extensions.experiments.enabled": true, - "extensions.autoDisableScopes": 0, - // Enable remote-debugging - "devtools.debugger.remote-enabled": true, - "devtools.debugger.remote-websocket": true, - "devtools.debugger.prompt-connection": false, - // Inherit the default test settings from Zotero - "app.update.enabled": false, - "extensions.zotero.sync.server.compressData": false, - "extensions.zotero.automaticScraperUpdates": false, - "extensions.zotero.debug.log": 5, - "extensions.zotero.debug.level": 5, - "extensions.zotero.debug.time": 5, - "extensions.zotero.firstRun.skipFirefoxProfileAccessCheck": true, - "extensions.zotero.firstRunGuidance": false, - "extensions.zotero.firstRun2": false, - "extensions.zotero.reportTranslationFailure": false, - "extensions.zotero.httpServer.enabled": true, - "extensions.zotero.httpServer.port": 23124, - "extensions.zotero.httpServer.localAPI.enabled": true, - "extensions.zotero.backup.numBackups": 0, - "extensions.zotero.sync.autoSync": false, - "extensions.zoteroMacWordIntegration.installed": true, - "extensions.zoteroMacWordIntegration.skipInstallation": true, - "extensions.zoteroWinWordIntegration.skipInstallation": true, - "extensions.zoteroOpenOfficeIntegration.skipInstallation": true, - }; - - return Object.assign(defaultPref, this.ctx.test.prefs || {}); - } -} diff --git a/packages/scaffold/src/utils/headless.ts b/packages/scaffold/src/core/tester/headless.ts similarity index 83% rename from packages/scaffold/src/utils/headless.ts rename to packages/scaffold/src/core/tester/headless.ts index a3c76d2..1985da0 100644 --- a/packages/scaffold/src/utils/headless.ts +++ b/packages/scaffold/src/core/tester/headless.ts @@ -1,7 +1,23 @@ import { execSync } from "node:child_process"; import process from "node:process"; import { isDebug, isLinux } from "std-env"; -import { LOG_LEVEL, logger } from "./logger.js"; +import { Xvfb } from "xvfb-ts"; +import { CACHE_DIR } from "../../constant.js"; +import { LOG_LEVEL, logger } from "../../utils/logger.js"; + +export async function prepareHeadless() { + // Ensure xvfb installing + await installXvfb(); + await installDepsForUbuntu24(); + + // Download and Extract Zotero Beta Linux + await installZoteroLinux(); + + const xvfb = new Xvfb({ + timeout: 2000, + }); + await xvfb.start(); +} function isPackageInstalled(packageName: string): boolean { try { @@ -69,9 +85,11 @@ export async function installZoteroLinux() { } logger.debug("Installing Zotero..."); + execSync(`cd ${CACHE_DIR}`); execSync("wget -O zotero.tar.bz2 'https://www.zotero.org/download/client/dl?platform=linux-x86_64&channel=beta'", { stdio: "pipe" }); execSync("tar -xvf zotero.tar.bz2", { stdio: "pipe" }); // Set Environment Variable for Zotero Bin Path process.env.ZOTERO_PLUGIN_ZOTERO_BIN_PATH = `${process.cwd()}/Zotero_linux-x86_64/zotero`; + execSync("cd -"); } diff --git a/packages/scaffold/src/core/tester/http-reporter.ts b/packages/scaffold/src/core/tester/http-reporter.ts new file mode 100644 index 0000000..bbffad5 --- /dev/null +++ b/packages/scaffold/src/core/tester/http-reporter.ts @@ -0,0 +1,159 @@ +import http from "node:http"; +import styleText from "node-style-text"; +import { logger } from "../../utils/logger.js"; +import { findFreeTcpPort } from "../../utils/zotero/remote-zotero.js"; + +interface ResultDataBase { + title: string; + indents: number; +} + +interface ResultS { + type: "start" | "pending" | "end" | "debug"; + data?: ResultDataBase | any; +} + +interface ResultSuite { + type: "suite" | "suite end"; + data: ResultDataBase & { + root: boolean; + }; +} + +interface ResultTestPass { + type: "pass"; + data: ResultDataBase & { + fullTitle: string; + duration: number; + }; +} + +interface ResultTestFail { + type: "fail"; + data: ResultTestPass["data"] & { + error: { + message: string; + actual: unknown; + exprct: unknown; + operator: string; + stack: string; + }; + }; +} + +type Result = ResultS | ResultSuite | ResultTestPass | ResultTestFail; + +export class TestHttpReporter { + private _server?: http.Server; + private _port?: number; + public passed: number = 0; + public failed: number = 0; + + constructor() { } + + async getPort() { + this._port = await findFreeTcpPort(); + } + + get port() { + return this._port!; + } + + async start() { + if (!this._port) + await this.getPort(); + + this._server = http.createServer(this.handleRequest.bind(this)); + this._server.listen(this._port, () => { + logger.debug(`Server is listening on http://localhost:${this.port}`); + }); + return this; + } + + private handleRequest(req: http.IncomingMessage, res: http.ServerResponse) { + if (req.method === "GET" && req.url === "/") { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end("Zotero Plugin Test Server is running"); + } + else + if (req.method === "POST" && req.url === "/update") { + let body = ""; + + // Collect data chunks + req.on("data", (chunk) => { + body += chunk; + }); + + // Parse and handle the complete data + req.on("end", async () => { + try { + const jsonData = JSON.parse(body); + await this.onData(jsonData); + + // Send a response to the client + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ message: "Results received successfully" })); + } + catch (error) { + logger.error(`Error parsing JSON:, ${error}`); + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Invalid JSON" })); + } + }); + } + else { + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Not Found" })); + } + } + + async onData(body: Result) { + const { type, data } = body; + const logger_option = { space: data.indents - 1 }; + + switch (type) { + case "debug": + logger.log(data); + break; + case "start": + logger.newLine(); + break; + case "suite": + if (data.title) + logger.tip(data.title, logger_option); + break; + case "pass": + this.passed++; + logger.success(`${data.title} ${styleText.gray(`${data.duration}ms`)}`, logger_option); + break; + case "fail": + this.failed++; + logger.fail(styleText.red(`${data.title}, ${body.data?.error?.message}`), logger_option); + // if (this.onFailed) + // this.onFailed(); + break; + case "pending": + logger.info(`${data.title} pending`, logger_option); + break; + case "suite end": + break; + case "end": + logger.newLine(); + if (this.failed === 0) + logger.success(`Test run completed - ${this.passed} passed`); + else + logger.fail(`Test run completed - ${this.passed} passed, ${this.failed} failed`); + + // if (this.onEnd) + // this.onEnd(); + break; + default: + logger.log(data); + break; + } + } + + stop() { + this._server?.close(); + } +} diff --git a/packages/scaffold/src/core/tester/index.ts b/packages/scaffold/src/core/tester/index.ts new file mode 100644 index 0000000..f49e180 --- /dev/null +++ b/packages/scaffold/src/core/tester/index.ts @@ -0,0 +1,191 @@ +import type { Context } from "../../types/index.js"; +import { join } from "node:path"; +import process from "node:process"; +import { emptyDir } from "fs-extra/esm"; +import { resolve } from "pathe"; +import { isCI } from "std-env"; +import { TESTER_DATA_DIR, TESTER_PLUGIN_DIR, TESTER_PLUGIN_ID, TESTER_PROFILE_DIR } from "../../constant.js"; +import { toArray } from "../../utils/string.js"; +import { watch } from "../../utils/watcher.js"; +import { ZoteroRunner } from "../../utils/zotero-runner.js"; +import { Base } from "../base.js"; +import Build from "../builder.js"; +import { prepareHeadless } from "./headless.js"; +import { TestHttpReporter } from "./http-reporter.js"; +import { TestBundler } from "./test-bundler.js"; + +export default class Test extends Base { + private builder: Build; + private zotero?: ZoteroRunner; + private reporter: TestHttpReporter = new TestHttpReporter(); + private testBundler?: TestBundler; + + constructor(ctx: Context) { + super(ctx); + process.env.NODE_ENV ??= "test"; + + this.builder = new Build(ctx); + + if (isCI) { + this.ctx.test.headless = true; + this.ctx.test.watch = false; + } + } + + async run() { + // Handle interrupt signal (Ctrl+C) to gracefully terminate Zotero process + // Must be placed at the top to prioritize registration of events to prevent web-ext interference + process.on("SIGINT", this.exit); + + // Empty dirs + await emptyDir(TESTER_PROFILE_DIR); + await emptyDir(TESTER_DATA_DIR); + await emptyDir(TESTER_PLUGIN_DIR); + await this.ctx.hooks.callHook("test:init", this.ctx); + + // prebuild + await this.builder.run(); + await this.ctx.hooks.callHook("test:prebuild", this.ctx); + + // Start a HTTP server to receive test results + // This is useful for CI/CD environments + await this.reporter.start(); + + // Create proxy plugin to run tests + this.testBundler = new TestBundler( + this.ctx, + this.reporter.port, + ); + await this.testBundler.generate(); + await this.ctx.hooks.callHook("test:bundleTests", this.ctx); + + // Start Zotero + await this.startZotero(); + await this.ctx.hooks.callHook("test:run", this.ctx); + + // Watch mode + if (this.ctx.test.watch) { + this.watch(); + } + } + + async watch() { + const source = toArray(this.ctx.source).map(p => resolve(p)); + const tests = toArray(this.ctx.test.entries).map(p => resolve(p)); + function isSource(_path: string) { + const path = resolve(_path); + const isSource = source.find(s => path.match(s)) || false; + const _isTests = tests.find(t => path.match(t)) || false; + return isSource; + } + + watch( + [this.ctx.source, this.ctx.test.entries].flat(), + { + onChange: async (path) => { + if (isSource(path)) { + await this.builder.run(); + await this.testBundler?.regenerate(path); + await this.zotero?.reloadAllPlugins(); + } + else { + await this.testBundler?.regenerate(path); + await this.zotero?.reloadTemporaryPluginBySourceDir(TESTER_PLUGIN_DIR); + } + }, + }, + ); + } + + async startZotero() { + if (this.ctx.test.headless) { + await prepareHeadless(); + } + + this.zotero = new ZoteroRunner({ + binary: { + path: this.zoteroBinPath, + devtools: this.ctx.server.devtools, + args: this.ctx.server.startArgs, + }, + profile: { + path: TESTER_PROFILE_DIR, + dataDir: TESTER_DATA_DIR, + customPrefs: this.prefs, + }, + plugins: { + list: [{ + id: this.ctx.id, + sourceDir: join(this.ctx.dist, "addon"), + }, { + id: TESTER_PLUGIN_ID, + sourceDir: TESTER_PLUGIN_DIR, + }], + }, + }); + + await this.zotero.run(); + + this.zotero.zotero?.on("close", () => this.onZoteroExit()); + } + + private onZoteroExit = () => { + this.reporter.stop(); + this.ctx.hooks.callHook("test:exit", this.ctx); + + if (this.reporter.failed) + process.exit(1); + else + process.exit(0); + }; + + private exit = (code?: string | number) => { + if (code === "SIGINT") { + this.logger.info("Tester shutdown by user request"); + } + + this.reporter.stop(); + this.zotero?.exit(); + this.ctx.hooks.callHook("test:exit", this.ctx); + process.exit(); + }; + + private get zoteroBinPath() { + if (!process.env.ZOTERO_PLUGIN_ZOTERO_BIN_PATH) + throw new Error("No Zotero Found."); + return process.env.ZOTERO_PLUGIN_ZOTERO_BIN_PATH; + } + + private get prefs() { + const defaultPref = { + "extensions.experiments.enabled": true, + "extensions.autoDisableScopes": 0, + // Enable remote-debugging + "devtools.debugger.remote-enabled": true, + "devtools.debugger.remote-websocket": true, + "devtools.debugger.prompt-connection": false, + // Inherit the default test settings from Zotero + "app.update.enabled": false, + "extensions.zotero.sync.server.compressData": false, + "extensions.zotero.automaticScraperUpdates": false, + "extensions.zotero.debug.log": 5, + "extensions.zotero.debug.level": 5, + "extensions.zotero.debug.time": 5, + "extensions.zotero.firstRun.skipFirefoxProfileAccessCheck": true, + "extensions.zotero.firstRunGuidance": false, + "extensions.zotero.firstRun2": false, + "extensions.zotero.reportTranslationFailure": false, + "extensions.zotero.httpServer.enabled": true, + "extensions.zotero.httpServer.port": 23124, + "extensions.zotero.httpServer.localAPI.enabled": true, + "extensions.zotero.backup.numBackups": 0, + "extensions.zotero.sync.autoSync": false, + "extensions.zoteroMacWordIntegration.installed": true, + "extensions.zoteroMacWordIntegration.skipInstallation": true, + "extensions.zoteroWinWordIntegration.skipInstallation": true, + "extensions.zoteroOpenOfficeIntegration.skipInstallation": true, + }; + + return Object.assign(defaultPref, this.ctx.test.prefs || {}); + } +} diff --git a/packages/scaffold/src/core/tester/test-bundler-template/bootsrtap.ts b/packages/scaffold/src/core/tester/test-bundler-template/bootsrtap.ts new file mode 100644 index 0000000..e954149 --- /dev/null +++ b/packages/scaffold/src/core/tester/test-bundler-template/bootsrtap.ts @@ -0,0 +1,102 @@ +import { TESTER_PLUGIN_REF } from "../../../constant.js"; + +export function generateBootstrap(options: { + port: number; + startupDelay: number; + waitForPlugin: string; +}) { + return `//Code generated by the zotero-plugin-scaffold tester +var chromeHandle; + +function install(data, reason) {} + +async function startup({ id, version, resourceURI, rootURI }, reason) { + await Zotero.initializationPromise; + const aomStartup = Components.classes[ + "@mozilla.org/addons/addon-manager-startup;1" + ].getService(Components.interfaces.amIAddonManagerStartup); + const manifestURI = Services.io.newURI(rootURI + "manifest.json"); + chromeHandle = aomStartup.registerChrome(manifestURI, [ + ["content", "${TESTER_PLUGIN_REF}", rootURI + "content/"], + ]); + + launchTests().catch((error) => { + Zotero.debug(error); + Zotero.HTTP.request( + "POST", + "http://localhost:${options.port}/update", + { + body: JSON.stringify({ + type: "fail", + data: { + title: "Internal: Plugin awaiting timeout", + stack: "", + str: "Plugin awaiting timeout", + }, + }), + } + ); + }); +} + +function onMainWindowLoad({ window: win }) {} + +function onMainWindowUnload({ window: win }) {} + +function shutdown({ id, version, resourceURI, rootURI }, reason) { + if (reason === APP_SHUTDOWN) { + return; + } + + if (chromeHandle) { + chromeHandle.destruct(); + chromeHandle = null; + } +} + +function uninstall(data, reason) {} + +async function launchTests() { + // Delay to allow plugin to fully load before opening the test page + await Zotero.Promise.delay(${options.startupDelay || 1000}); + + const waitForPlugin = "${options.waitForPlugin}"; + + if (waitForPlugin) { + // Wait for a plugin to be installed + await waitUtilAsync(() => { + try { + return !!eval(waitForPlugin)(); + } catch (error) { + return false; + } + }).catch(() => { + throw new Error("Plugin awaiting timeout"); + }); + } + + Services.ww.openWindow( + null, + "chrome://${TESTER_PLUGIN_REF}/content/index.xhtml", + "Zotero Plugin Scaffold Test Runnner", + "chrome,centerscreen,resizable=yes", + {} + ); +} + +function waitUtilAsync(condition, interval = 100, timeout = 1e4) { + return new Promise((resolve, reject) => { + const start = Date.now(); + const intervalId = setInterval(() => { + if (condition()) { + clearInterval(intervalId); + resolve(); + } else if (Date.now() - start > timeout) { + clearInterval(intervalId); + reject(); + } + }, interval); + }); +} +`; +} diff --git a/packages/scaffold/src/core/tester/test-bundler-template/html.ts b/packages/scaffold/src/core/tester/test-bundler-template/html.ts new file mode 100644 index 0000000..84d9fa3 --- /dev/null +++ b/packages/scaffold/src/core/tester/test-bundler-template/html.ts @@ -0,0 +1,49 @@ +export function generateHtml( + setupCode: string, + testFiles: string[], +) { + const tests = testFiles.map(f => ``).join("\n "); + + return ` + + + + + + Zotero Plugin Test + + + +
+ + + + + + + + + + + + + ${tests} + + + + + +`; +} diff --git a/packages/scaffold/src/core/tester/test-bundler-template/index.ts b/packages/scaffold/src/core/tester/test-bundler-template/index.ts new file mode 100644 index 0000000..3ae5cfa --- /dev/null +++ b/packages/scaffold/src/core/tester/test-bundler-template/index.ts @@ -0,0 +1,4 @@ +export * from "./bootsrtap.js"; +export * from "./html.js"; +export * from "./manifest.js"; +export * from "./mocha-setup.js"; diff --git a/packages/scaffold/src/core/tester/test-bundler-template/manifest.ts b/packages/scaffold/src/core/tester/test-bundler-template/manifest.ts new file mode 100644 index 0000000..348f32e --- /dev/null +++ b/packages/scaffold/src/core/tester/test-bundler-template/manifest.ts @@ -0,0 +1,18 @@ +import { TESTER_PLUGIN_ID } from "../../../constant.js"; + +export function generateManifest() { + return { + manifest_version: 2, + name: "Zotero Plugin Scaffold Test Runner", + version: "0.0.1", + description: "Test suite for the Zotero plugin. This is a runtime-generated plugin only for testing purposes.", + applications: { + zotero: { + id: TESTER_PLUGIN_ID, + update_url: "https://example.com", + // strict_min_version: "*.*.*", + strict_max_version: "999.*.*", + }, + }, + }; +} diff --git a/packages/scaffold/src/core/tester/test-bundler-template/mocha-setup.ts b/packages/scaffold/src/core/tester/test-bundler-template/mocha-setup.ts new file mode 100644 index 0000000..0868ccf --- /dev/null +++ b/packages/scaffold/src/core/tester/test-bundler-template/mocha-setup.ts @@ -0,0 +1,152 @@ +export function generateMochaSetup(options: { + port: number; + timeout: number; + abortOnFail: boolean; + exitOnFinish: boolean; +}) { + return `// Generated by zotero-plugin-scaffold +mocha.setup({ + ui: "bdd", + reporter: Reporter, + timeout: ${options.timeout} || 10000, + bail: ${options.abortOnFail} +}); + +window.expect = chai.expect; +window.assert = chai.assert; + +async function send(data) { + const req = await Zotero.HTTP.request( + "POST", + "http://localhost:${options.port}/update", + { + body: JSON.stringify(data), + } + ); + + if (req.status !== 200) { + dump("Error sending data to server" + req.responseText); + return null; + } else { + const result = JSON.parse(req.responseText); + return result; + } +} + +window.debug = function (data) { + send({ type: "debug", data }); +}; + +let indents = 0, + passed = 0, + failed = 0, + aborted = false; + +// Inherit the default test settings from Zotero +function Reporter(runner) { + function indent() { + return " ".repeat(indents); + } + + function dump(str) { + // console.log(str) + // const p = document.createElement("p") + // p.textContent = str + // document.querySelector("#mocha").append(p) + document.querySelector("#mocha").innerText += str; + } + + runner.on("start", async function () { + console.log("start") + await send({ type: "start", data: { indents } }); + }); + + runner.on("suite", async function (suite) { + console.log("suite", suite) + indents++; + const str = indent() + suite.title + "\\n"; + dump(str); + await send({ type: "suite", data: { title: suite.title, root: suite.root, indents } }); + }); + + runner.on("suite end", async function (suite) { + console.log("suite end", suite) + indents--; + const str = indents === 1 ? "\\n" : ""; + dump(str); + await send({ type: "suite end", data: { title: suite.title, root: suite.root, indents } }); + }); + + runner.on("pending", async function (test) { + console.log("pending", test) + const str = indent() + "pending -" + test.title + "\\n"; + dump(str); + await send({ type: "pending", data: { title: test.title, fulltest: test.fullTitle(), duration: test.duration, indents: indents + 1 } }); + }); + + runner.on("pass", async function (test) { + console.log("pass", test) + passed++; + let str = indent() + Mocha.reporters.Base.symbols.ok + " " + test.title; + if ("fast" != test.speed) { + str += " (" + Math.round(test.duration) + " ms)"; + } + str += "\\n"; + dump(str); + await send({ type: "pass", data: { title: test.title, fulltest: test.fullTitle(), duration: test.duration, indents: indents + 1 } }); + + }); + + runner.on("fail", async function (test, error) { + console.log("fail", test, error) + + // Make sure there's a blank line after all stack traces + // err.stack = err.stack.replace(/\\s*$/, "\\n\\n"); + + failed++; + let indentStr = indent(); + const str = + indentStr + + // Dark red X for errors + Mocha.reporters.Base.symbols.err + + // Trigger bell if interactive + (Zotero.automatedTest ? "" : "\\x07") + + " " + + test.title + + "\\n" + + indentStr + + " " + + error.message + + // " at\\n" + + // err.stack.replace(/^/gm, indentStr + " ").trim() + + "\\n\\n"; + dump(str); + + await send({ type: "fail", data: { title: test.title, fulltest: test.fullTitle(), duration: test.duration, error, indents: indents + 1 } }); + + }); + + runner.on("end", async function () { + console.log("end") + const str = + passed + + "/" + + (passed + failed) + + " tests passed" + + (aborted ? " -- aborting" : "") + + "\\n"; + dump(str); + + await send({ + type: "end", + data: { passed: passed, failed: failed, aborted: aborted, str, indents }, + }); + + // Must exit on Zotero side, otherwise the exit code will not be 0 and CI will fail + if (${options.exitOnFinish ? "true" : "false"}) { + Zotero.Utilities.Internal.quit(0); + } + }); +} +`; +} diff --git a/packages/scaffold/src/core/tester/test-bundler.ts b/packages/scaffold/src/core/tester/test-bundler.ts new file mode 100644 index 0000000..60a71a6 --- /dev/null +++ b/packages/scaffold/src/core/tester/test-bundler.ts @@ -0,0 +1,176 @@ +import type { BuildContext, BuildResult } from "esbuild"; +import type { Context } from "../../types/index.js"; +import { context } from "esbuild"; +import { copy, outputFile, outputJSON, pathExists } from "fs-extra/esm"; +import { resolve } from "pathe"; +import { glob } from "tinyglobby"; +import { CACHE_DIR, TESTER_PLUGIN_DIR } from "../../constant.js"; +import { saveResource } from "../../utils/file.js"; +import { logger } from "../../utils/logger.js"; +import { toArray } from "../../utils/string.js"; +import { generateBootstrap, generateHtml, generateManifest, generateMochaSetup } from "./test-bundler-template/index.js"; + +export class TestBundler { + private esbuildContext?: BuildContext; + constructor( + private ctx: Context, + private port: number, + ) { + // + } + + async generate() { + // this.generatePluginRes + // bootstrape + // manifest + // copy lib + // bundle tests + await this.generateTestResources(); + + // this.generateTestPage + // mocha setup + // html + await this.createTestHtml(); + } + + async regenerate(changedFile: string) { + // re-bundle tests + const esbuildResult = await this.esbuildContext?.rebuild(); + + // get affected tests + const tests = findImpactedTests(changedFile, esbuildResult?.metafile); + + // this.generateTestPage + // mocha setup + // html + await this.createTestHtml(tests); + } + + private async generateTestResources() { + // bootstrape + const manifest = generateManifest(); + await outputJSON(`${TESTER_PLUGIN_DIR}/manifest.json`, manifest, { spaces: 2 }); + + // manifest + const bootstrap = generateBootstrap({ + port: this.port, + startupDelay: this.ctx.test.startupDelay, + waitForPlugin: this.ctx.test.waitForPlugin, + }); + await outputFile(`${TESTER_PLUGIN_DIR}/bootstrap.js`, bootstrap); + + // copy lib + await this.copyTestLibraries(); + + // bundle tests + await this.bundleTests(); + } + + private async copyTestLibraries() { + // Save mocha and chai packages + const pkgs: { + name: string; + remote: string; + local: string; + }[] = [ + { + name: "mocha.js", + local: "node_modules/mocha/mocha.js", + remote: "https://cdn.jsdelivr.net/npm/mocha/mocha.js", + }, + { + name: "chai.js", + // local: "node_modules/chai/chai.js", + local: "", // chai packages install from npm do not support browser + remote: "https://www.chaijs.com/chai.js", + }, + ]; + + await Promise.all(pkgs.map(async (pkg) => { + const targetPath = `${TESTER_PLUGIN_DIR}/content/${pkg.name}`; + + if (pkg.local && await pathExists(pkg.local)) { + logger.debug(`Local ${pkg.name} package found`); + await copy(pkg.local, targetPath); + return; + } + + const cachePath = `${CACHE_DIR}/${pkg.name}`; + if (await pathExists(`${cachePath}`)) { + logger.debug(`Cache ${pkg.name} package found`); + await copy(cachePath, targetPath); + return; + } + + logger.info(`No local ${pkg.name} found, we recommend you install ${pkg.name} package locally.`); + await saveResource(pkg.remote, `${CACHE_DIR}/${pkg.name}`); + await copy(cachePath, targetPath); + })); + } + + private async bundleTests() { + const testDirs = toArray(this.ctx.test.entries); + // Because esbuild only support `*` and `**`, + // so we need glob ourselves. + // https://esbuild.github.io/api/#glob-style-entry-points + const entryPoints = (await Promise.all(testDirs.map(dir => glob(`${dir}/**/*.spec.{js,ts}`)))) + .flat(); + + // Bundle all test files, including both JavaScript and TypeScript + this.esbuildContext = await context({ + entryPoints, + outdir: `${TESTER_PLUGIN_DIR}/content/units`, + bundle: true, + target: "firefox115", + metafile: true, + }); + await this.esbuildContext.rebuild(); + } + + private async createTestHtml(tests: string[] = []) { + // mocha setup + const setupCode = generateMochaSetup({ + timeout: this.ctx.test.mocha.timeout, + port: this.port, + abortOnFail: this.ctx.test.abortOnFail, + exitOnFinish: !this.ctx.test.watch, + }); + + // html + let testFiles = tests; + if (testFiles.length === 0) { + testFiles = (await glob(`**/*.spec.js`, { cwd: `${TESTER_PLUGIN_DIR}/content` })).sort(); + } + const html = generateHtml(setupCode, testFiles); + await outputFile(`${TESTER_PLUGIN_DIR}/content/index.xhtml`, html); + } +} + +/** + * Determines which test files are impacted by a given changed file based on the esbuild metafile. + * + * This function analyzes the build metadata to find test files that depend on the changed file + * either directly as an entry point or indirectly as an input. It is useful in a watch mode setup + * to selectively rerun only the affected tests. + * + * @param {string} changedFilePath - The file path of the changed source file. + * @param {BuildResult["metafile"]} buildMetadata - The esbuild metafile containing dependency information. + * @returns {string[]} An array of impacted test file paths that need to be re-executed. + */ +export function findImpactedTests(changedFilePath: string, buildMetadata: BuildResult["metafile"]): string[] { + if (!buildMetadata) + return []; + + const resolvedChangedFile = resolve(changedFilePath); + const impactedTestFiles = new Set(); + + for (const [outputFilePath, outputInfo] of Object.entries(buildMetadata.outputs)) { + const testFilePath = outputFilePath.replace(`${TESTER_PLUGIN_DIR}/content/`, ""); + // const resolvedEntryPoint = outputInfo.entryPoint ? resolve(outputInfo.entryPoint) : null; + + if (Object.keys(outputInfo.inputs).some(inputPath => resolve(inputPath) === resolvedChangedFile)) { + impactedTestFiles.add(testFilePath); + } + } + return Array.from(impactedTestFiles); +} diff --git a/packages/scaffold/src/index.ts b/packages/scaffold/src/index.ts index f88e031..ce6dbc0 100644 --- a/packages/scaffold/src/index.ts +++ b/packages/scaffold/src/index.ts @@ -2,7 +2,7 @@ import { defineConfig, loadConfig } from "./config.js"; import Build from "./core/builder.js"; import Release from "./core/releaser/index.js"; import Serve from "./core/server.js"; -import Test from "./core/tester.js"; +import Test from "./core/tester/index.js"; const Config = { defineConfig, diff --git a/packages/scaffold/src/types/config.ts b/packages/scaffold/src/types/config.ts index 51e92cd..d56fe86 100644 --- a/packages/scaffold/src/types/config.ts +++ b/packages/scaffold/src/types/config.ts @@ -643,13 +643,19 @@ export interface TestConfig { abortOnFail: boolean; /** - * Exit Zotero when the test is finished. + * Watch source and test file changes, + * and re-run tests when files change. * - * 测试完成后退出 Zotero。 + * If this set to false, Zotero will exit + * when test complated. * - * @default false + * 当源码或测试代码变更时自动重新运行测试。 + * + * 若此选项设置为 false,Zotero 将在测试结束后立即退出。 + * + * @default true (false in ci) */ - exitOnFinish: boolean; + watch: boolean; /** * Run Zotero in deadless mode. @@ -662,7 +668,7 @@ export interface TestConfig { * - 仅支持 Linux * - 在 CI 模式下,默认为 true * - * @default false + * @default false (true in ci) */ headless: boolean; @@ -693,19 +699,12 @@ export interface TestConfig { */ waitForPlugin: string; - /** - * @todo not - */ - watch: boolean; - hooks: Partial; } interface TestHooks { "test:init": (ctx: Context) => void | Promise; "test:prebuild": (ctx: Context) => void | Promise; - "test:listen": (ctx: Context) => void | Promise; - "test:copyAssets": (ctx: Context) => void | Promise; "test:bundleTests": (ctx: Context) => void | Promise; "test:run": (ctx: Context) => void | Promise; "test:exit": (ctx: Context) => void; diff --git a/packages/scaffold/src/utils/logger.ts b/packages/scaffold/src/utils/logger.ts index 6981f65..e3701d9 100644 --- a/packages/scaffold/src/utils/logger.ts +++ b/packages/scaffold/src/utils/logger.ts @@ -153,7 +153,7 @@ export class Logger { const { space = DEFAULT_OPTIONS.SPACE, newLine = DEFAULT_OPTIONS.NEW_LINE } = options; const formattedContent = this.formatContent(content); const output = [ - " ".repeat(space), + " ".repeat(space), config.symbol, formattedContent, ].join(" "); diff --git a/packages/scaffold/src/utils/watcher.ts b/packages/scaffold/src/utils/watcher.ts new file mode 100644 index 0000000..599b801 --- /dev/null +++ b/packages/scaffold/src/utils/watcher.ts @@ -0,0 +1,56 @@ +import chokidar from "chokidar"; +import { debounce } from "es-toolkit"; +import { logger } from "./logger.js"; + +export function watch( + source: string | string[], + event: { + onReady?: () => any; + onChange: (path: string) => any | Promise; + onError?: (err: unknown) => any; + }, +) { + const watcher = chokidar.watch(source, { + ignored: /(^|[/\\])\../, // ignore dotfiles + persistent: true, + }); + + const onChangeDebounced = safeDebounce(event.onChange); + + watcher + .on("ready", async () => { + if (event.onReady) + await event.onReady(); + logger.clear(); + logger.ready("Server Ready!"); + }) + .on("change", async (path) => { + logger.clear(); + logger.info(`${path} changed`); + // Do not abort the watcher when errors occur + // in builds triggered by the watcher. + await onChangeDebounced(path); + }) + .on("error", async (err) => { + if (event.onError) + await event.onError(err); + logger.fail("Server start failed!"); + logger.error(err); + }); + + // return watcher; +} + +function safeDebounce(fn?: (...args: any[]) => void) { + if (!fn) + return () => {}; + + return debounce(async (...args) => { + try { + await fn(...args); + } + catch (error) { + logger.error(error); + } + }, 500); +} diff --git a/packages/scaffold/src/utils/zotero/remote-zotero.ts b/packages/scaffold/src/utils/zotero/remote-zotero.ts index 0e84062..7a0d0e2 100644 --- a/packages/scaffold/src/utils/zotero/remote-zotero.ts +++ b/packages/scaffold/src/utils/zotero/remote-zotero.ts @@ -249,9 +249,7 @@ export class RemoteFirefox { // Reload addon: {"actor":"server1.conn0.webExtensionDescriptor8","debuggable":true,"hidden":false,"iconURL":"file:///D:/Code/Zotero/zotero-format-metadata/build/addon/content/icons/favicon@0.5x.png","id":"zotero-format-metadata@northword.cn","isSystem":false,"isWebExtension":true,"manifestURL":"moz-extension://d6d93075-0004-4850-b421-30347a44928c/manifest.json","name":"Linter for Zotero","temporarilyInstalled":true,"traits":{"supportsReloadDescriptor":true,"watcher":true},"url":"file:///D:/Code/Zotero/zotero-format-metadata/build/addon/","warnings":[]} await this.checkForAddonReloading(addon); await this.addonRequest(addon, "reload"); - logger.success( - `\rLast extension reload: ${new Date().toTimeString()}`, - ); + logger.success(`Last extension reload: ${new Date().toTimeString()}`); } } diff --git a/packages/scaffold/test/unit/test-bundler.test.ts b/packages/scaffold/test/unit/test-bundler.test.ts new file mode 100644 index 0000000..3d3cf24 --- /dev/null +++ b/packages/scaffold/test/unit/test-bundler.test.ts @@ -0,0 +1,52 @@ +import type { Metafile } from "esbuild"; +import { describe, expect, it } from "vitest"; +import { findImpactedTests } from "../../src/core/tester/test-bundler.js"; + +describe("findImpactedTests", () => { + const mockMetafileOutputs = { + outputs: { + ".scaffold/test/resource/content/units/test1.spec.js": { + entryPoint: "test/test1.spec.ts", + inputs: { + "test/test1.spec.ts": {}, + "src/moduleA.ts": {}, + "src/moduleB.ts": {}, + }, + }, + ".scaffold/test/resource/content/units/test2.spec.js": { + entryPoint: "test/test2.spec.ts", + inputs: { + "test/test2.spec.ts": {}, + "src/moduleC.ts": {}, + }, + }, + ".scaffold/test/resource/content/units/test3.spec.js": { + entryPoint: "test/test3.spec.ts", + inputs: { + "test/test3.spec.ts": {}, + "src/moduleC.ts": {}, + }, + }, + }, + } as unknown as Metafile; + + it("returns affected test file when a test file itself is changed", () => { + const result = findImpactedTests("test/test1.spec.ts", mockMetafileOutputs); + expect(result).toEqual(["units/test1.spec.js"]); + }); + + it("returns affected test files when a source file is changed", () => { + const result = findImpactedTests("src/moduleA.ts", mockMetafileOutputs); + expect(result).toEqual(["units/test1.spec.js"]); + }); + + it("returns multiple affected test files when multiple tests depend on the changed file", () => { + const result = findImpactedTests("src/moduleC.ts", mockMetafileOutputs); + expect(result).toEqual(["units/test2.spec.js", "units/test3.spec.js"]); + }); + + it("returns an empty array if no test file is affected", () => { + const result = findImpactedTests("src/unrelated.ts", mockMetafileOutputs); + expect(result).toEqual([]); + }); +}); diff --git a/packages/scaffold/tsconfig.json b/packages/scaffold/tsconfig.json index 345ab08..397eb40 100644 --- a/packages/scaffold/tsconfig.json +++ b/packages/scaffold/tsconfig.json @@ -1,7 +1,6 @@ { "compilerOptions": { "target": "ESNext", - "baseUrl": ".", "module": "NodeNext", "moduleResolution": "nodenext", "resolveJsonModule": true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 768c616..292d9f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -128,6 +128,9 @@ importers: octokit: specifier: ^4.1.2 version: 4.1.2 + pathe: + specifier: ^2.0.3 + version: 2.0.3 std-env: specifier: ^3.8.1 version: 3.8.1