diff --git a/README.md b/README.md index 94d7c30..b92a9c2 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ IntelliJ IDEA 一样按项目列出任务分组和依赖配置,运行任务、 | 运行 | `JavaExec` 类型的任务(如 `application` 插件的 `run`)作为可运行的应用 | | 自定义任务 | “Run Gradle Tasks…” 输入任意任务和参数,选中子项目时在该项目目录下运行 | | 选项 | 工具栏开关 Offline(`--offline`);Gradle User Home(`--gradle-user-home`) | -| 测试 | 运行项目、测试类或单个方法(`--tests`),读取 `build/test-results/test` 的 JUnit XML 报告 | +| 测试 | 运行项目、测试类或单个方法(`--tests`),读取 `build/test-results/test` 的 JUnit XML 报告;`--rerun` 仅重新执行所选测试任务,编译等依赖仍使用增量缓存,避免旧报告被宿主过滤后显示为零 | | 诊断 | 把 javac 的 `文件:行: error|warning:` 和 Kotlin 的 `e:|w: file://…` 输出显示为问题 | | 依赖 | 每个项目按 source set 的 `compileClasspath`、`runtimeClasspath`、`annotationProcessor` 等配置显示依赖树;已出现过的组件标记为省略,不重复展开(与 `gradle dependencies` 的 `(*)` 相同) | | 内容根 | source set 的 Java/Kotlin/Groovy/Scala 源码、资源和注解处理器生成目录;`build` 与 `.gradle` 标为排除 | @@ -84,6 +84,8 @@ node scripts/package.mjs GRADLE=/path/to/gradle node scripts/test.mjs ``` +只验证测试报告回归时可运行 `GRADLE=/path/to/gradle node scripts/test.mjs --test-results-only`;它连续运行两次相同测试,检查第二次报告刷新且测试类没有重新编译。 + 测试使用临时工作区和独立的 Gradle User Home,每个 Gradle 命令最多运行 120 秒,结束时有界停止该测试的守护进程并清理临时目录。 每次运行都使用 `--no-daemon`;初始化脚本自己的运行还带 `--warning-mode=fail`,出现弃用警告即失败。 输出记录命令和每项断言的耗时,并检查运行前后插件包的文件清单与 SHA-256 保持不变。 diff --git a/io.github.lithe-idea.gradle-build/plugin.json b/io.github.lithe-idea.gradle-build/plugin.json index 7bce13b..7d068da 100644 --- a/io.github.lithe-idea.gradle-build/plugin.json +++ b/io.github.lithe-idea.gradle-build/plugin.json @@ -113,6 +113,7 @@ "arguments": [ { "value": "${project.property:taskPrefix}test", "when": "nestedProjectSelected" }, { "value": "test", "when": "noNestedProjectSelected" }, + "--rerun", { "value": "--tests", "when": "suiteSelected" }, { "value": "${test.suite}", "when": ["suiteSelected", "noCaseSelected"] }, { "value": "${test.suite}.${test.case}", "when": ["suiteSelected", "caseSelected"] } diff --git a/scripts/test.mjs b/scripts/test.mjs index 40ea74c..61ddc9c 100644 --- a/scripts/test.mjs +++ b/scripts/test.mjs @@ -14,7 +14,7 @@ import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import { createHash } from "node:crypto"; -import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; @@ -173,99 +173,119 @@ try { const byName = (name) => model.projects.find((project) => project.name === name); const task = (project, name) => model.tasks.find((entry) => entry.project === byName(project).path && entry.name === name); - await check("the model lists every project with its Gradle path", () => { - assert.equal(model.format, "litheBuildModel"); - assert.deepEqual(model.projects.map((project) => project.name), ["multi", "app", "core"]); - assert.equal(byName("core").parent, byName("multi").path); - assert.deepEqual(byName("multi").properties, { path: ":", taskPrefix: ":", group: "demo", version: "1.0.0" }); - assert.deepEqual(byName("core").properties, { path: ":core", taskPrefix: ":core:", group: "demo", version: "1.0.0" }); - }); + if (!process.argv.includes("--test-results-only")) { + await check("the model lists every project with its Gradle path", () => { + assert.equal(model.format, "litheBuildModel"); + assert.deepEqual(model.projects.map((project) => project.name), ["multi", "app", "core"]); + assert.equal(byName("core").parent, byName("multi").path); + assert.deepEqual(byName("multi").properties, { path: ":", taskPrefix: ":", group: "demo", version: "1.0.0" }); + assert.deepEqual(byName("core").properties, { path: ":core", taskPrefix: ":core:", group: "demo", version: "1.0.0" }); + }); - await check("tasks are filed under their Gradle group and run by path", () => { - assert.deepEqual(task("core", "test").category, ["Tasks", "verification"]); - assert.deepEqual(task("core", "compileJava").category, ["Tasks", "other"]); - assert.equal(task("core", "build").properties.path, ":core:build"); - assert.equal(task("multi", "help").properties.path, "help", "a root task runs as a selector"); - const templates = new Set(buildSystem.tasks.filter((entry) => entry.fromModel).map((entry) => entry.id)); - assert.ok(model.tasks.every((entry) => templates.has(entry.template)), "every task names a fromModel template"); - assert.ok(!model.tasks.some((entry) => entry.name.startsWith("lithe")), "Lithe's own tasks are hidden"); - }); + await check("tasks are filed under their Gradle group and run by path", () => { + assert.deepEqual(task("core", "test").category, ["Tasks", "verification"]); + assert.deepEqual(task("core", "compileJava").category, ["Tasks", "other"]); + assert.equal(task("core", "build").properties.path, ":core:build"); + assert.equal(task("multi", "help").properties.path, "help", "a root task runs as a selector"); + const templates = new Set(buildSystem.tasks.filter((entry) => entry.fromModel).map((entry) => entry.id)); + assert.ok(model.tasks.every((entry) => templates.has(entry.template)), "every task names a fromModel template"); + assert.ok(!model.tasks.some((entry) => entry.name.startsWith("lithe")), "Lithe's own tasks are hidden"); + }); - await check("JavaExec tasks are applications the run entries pick up", () => { - assert.equal(task("app", "run").execution, "application"); - assert.equal(task("app", "run").properties.path, ":app:run"); - assert.equal(task("core", "test").execution, undefined); - }); + await check("JavaExec tasks are applications the run entries pick up", () => { + assert.equal(task("app", "run").execution, "application"); + assert.equal(task("app", "run").properties.path, ":app:run"); + assert.equal(task("core", "test").execution, undefined); + }); - await check("source sets become content roots", () => { - const core = byName("core").path; - const kinds = model.contentRoots.filter((entry) => entry.project === core).map((entry) => [entry.kind, entry.path]); - for (const expected of [ - ["source", join(core, "src", "main", "java")], - ["resource", join(core, "src", "main", "resources")], - ["testSource", join(core, "src", "test", "java")], - ["testResource", join(core, "src", "test", "resources")], - ["generatedSource", join(core, "build", "generated", "sources", "annotationProcessor", "java", "main")], - ]) { - assert.ok(kinds.some(([kind, path]) => kind === expected[0] && path === expected[1]), `${expected.join(" ")}`); - } - assert.ok(!model.contentRoots.some((entry) => entry.project === byName("multi").path)); - }); + await check("source sets become content roots", () => { + const core = byName("core").path; + const kinds = model.contentRoots.filter((entry) => entry.project === core).map((entry) => [entry.kind, entry.path]); + for (const expected of [ + ["source", join(core, "src", "main", "java")], + ["resource", join(core, "src", "main", "resources")], + ["testSource", join(core, "src", "test", "java")], + ["testResource", join(core, "src", "test", "resources")], + ["generatedSource", join(core, "build", "generated", "sources", "annotationProcessor", "java", "main")], + ]) { + assert.ok(kinds.some(([kind, path]) => kind === expected[0] && path === expected[1]), `${expected.join(" ")}`); + } + assert.ok(!model.contentRoots.some((entry) => entry.project === byName("multi").path)); + }); - const projectDependencies = join(scratch, "app-dependencies.json"); - await run(root, buildSystem.dependencies.arguments, { outputFile: projectDependencies, project: byName("app") }, STRICT); - capture("app-dependencies.json", projectDependencies); - await check("a project's tree lists its classpaths with the sibling project", () => { - const tree = JSON.parse(readFileSync(projectDependencies, "utf8")); - assert.equal(tree.format, "litheDependencies"); - assert.deepEqual(tree.dependencies.map((node) => node.name), [ - "compileClasspath", - "runtimeClasspath", - "testCompileClasspath", - "testRuntimeClasspath", - ]); - assert.deepEqual(tree.dependencies[0].children.map((node) => [node.name, node.properties]), [ - ["core", { kind: "project", path: ":core" }], - ]); - }); + const projectDependencies = join(scratch, "app-dependencies.json"); + await run(root, buildSystem.dependencies.arguments, { outputFile: projectDependencies, project: byName("app") }, STRICT); + capture("app-dependencies.json", projectDependencies); + await check("a project's tree lists its classpaths with the sibling project", () => { + const tree = JSON.parse(readFileSync(projectDependencies, "utf8")); + assert.equal(tree.format, "litheDependencies"); + assert.deepEqual(tree.dependencies.map((node) => node.name), [ + "compileClasspath", + "runtimeClasspath", + "testCompileClasspath", + "testRuntimeClasspath", + ]); + assert.deepEqual(tree.dependencies[0].children.map((node) => [node.name, node.properties]), [ + ["core", { kind: "project", path: ":core" }], + ]); + }); - const workspaceDependencies = join(scratch, "dependencies.json"); - await run(root, buildSystem.dependencies.arguments, { outputFile: workspaceDependencies }, STRICT); - capture("dependencies.json", workspaceDependencies); - await check("the workspace tree has one node per project with transitive children", () => { - const tree = JSON.parse(readFileSync(workspaceDependencies, "utf8")); - assert.deepEqual(tree.dependencies.map((node) => node.name), ["multi", "app", "core"]); - const core = tree.dependencies[2]; - const testClasspath = core.children.find((node) => node.name === "testCompileClasspath"); - const junit = testClasspath.children[0]; - assert.deepEqual([junit.group, junit.name, junit.version], ["junit", "junit", "4.13.2"]); - assert.equal(junit.children[0].name, "hamcrest-core"); - }); + const workspaceDependencies = join(scratch, "dependencies.json"); + await run(root, buildSystem.dependencies.arguments, { outputFile: workspaceDependencies }, STRICT); + capture("dependencies.json", workspaceDependencies); + await check("the workspace tree has one node per project with transitive children", () => { + const tree = JSON.parse(readFileSync(workspaceDependencies, "utf8")); + assert.deepEqual(tree.dependencies.map((node) => node.name), ["multi", "app", "core"]); + const core = tree.dependencies[2]; + const testClasspath = core.children.find((node) => node.name === "testCompileClasspath"); + const junit = testClasspath.children[0]; + assert.deepEqual([junit.group, junit.name, junit.version], ["junit", "junit", "4.13.2"]); + assert.equal(junit.children[0].name, "hamcrest-core"); + }); - await check("an unknown project fails instead of writing an empty tree", async () => { - const missing = { properties: { path: ":missing" } }; - await assert.rejects( - execute(root, [...SINGLE_USE, ...expand(buildSystem.dependencies.arguments, { outputFile: join(scratch, "x.json"), project: missing })], { quiet: true }), - /Gradle exited 1:/, - ); - assert.ok(!existsSync(join(scratch, "x.json")), "the failed command wrote no document"); - }); + await check("an unknown project fails instead of writing an empty tree", async () => { + const missing = { properties: { path: ":missing" } }; + await assert.rejects( + execute(root, [...SINGLE_USE, ...expand(buildSystem.dependencies.arguments, { outputFile: join(scratch, "x.json"), project: missing })], { quiet: true }), + /Gradle exited 1:/, + ); + assert.ok(!existsSync(join(scratch, "x.json")), "the failed command wrote no document"); + }); - await check("reading the model and dependencies builds nothing", () => { - for (const directory of ["build", "core/build", "app/build"]) { - assert.ok(!existsSync(join(root, directory)), `${directory} was not created`); - } - }); + await check("reading the model and dependencies builds nothing", () => { + for (const directory of ["build", "core/build", "app/build"]) { + assert.ok(!existsSync(join(root, directory)), `${directory} was not created`); + } + }); + + } - await run(root, buildSystem.test.arguments, { project: byName("core"), suite: "demo.core.GreeterTest" }); + const testSelection = { project: byName("core"), suite: "demo.core.GreeterTest" }; + await run(root, buildSystem.test.arguments, testSelection); await check("a filtered test run writes the report the results glob reads", () => { const report = readFileSync(join(root, "core", "build", "test-results", "test", "TEST-demo.core.GreeterTest.xml"), "utf8"); assert.match(report, /]*name="demo\.core\.GreeterTest"[^>]*tests="2"/); }); - await check("without Lithe's properties the init script does nothing", async () => { - await execute(root, [...STRICT, "-q", "--no-configuration-cache", "--init-script", join(pluginDir, "lithe.init.gradle"), "help"]); + await check("repeated tests refresh reports while compiled dependencies stay incremental", async () => { + const report = join(root, "core", "build", "test-results", "test", "TEST-demo.core.GreeterTest.xml"); + const classFile = join(root, "core", "build", "classes", "java", "test", "demo", "core", "GreeterTest.class"); + const compiledBefore = statSync(classFile).mtimeMs; + // A fixed old timestamp reproduces the host's stale-report rejection without + // sleeps or relying on how quickly the two Gradle invocations finish. + utimesSync(report, new Date(0), new Date(0)); + const started = Date.now(); + await run(root, buildSystem.test.arguments, testSelection); + assert.ok(statSync(report).mtimeMs >= started, "the repeated run must produce a current report"); + assert.match(readFileSync(report, "utf8"), /]*name="demo\.core\.GreeterTest"[^>]*tests="2"/); + assert.equal(statSync(classFile).mtimeMs, compiledBefore, "test compilation must remain incremental"); }); + + if (!process.argv.includes("--test-results-only")) { + await check("without Lithe's properties the init script does nothing", async () => { + await execute(root, [...STRICT, "-q", "--no-configuration-cache", "--init-script", join(pluginDir, "lithe.init.gradle"), "help"]); + }); + } await check("the packaged plugin stays byte-identical after the workflow", () => { assert.deepEqual(packageSnapshot(), packageBefore); });