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