diff --git a/packages/cli/test/commands/open.integration.test.ts b/packages/cli/test/commands/open.integration.test.ts new file mode 100644 index 00000000000..6cd7720aeed --- /dev/null +++ b/packages/cli/test/commands/open.integration.test.ts @@ -0,0 +1,153 @@ +import { spawn } from "node:child_process"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +import { epic, feature, label, story } from "allure-js-commons"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +const commandsDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(commandsDir, "../../../.."); +const cliPath = join(repoRoot, "packages", "cli", "cli.js"); +const yarnRcPath = join(repoRoot, ".yarnrc.yml"); + +const workspaces = new Set(); + +const resolveYarnInvocation = async () => { + const { readFile, stat } = await import("node:fs/promises"); + const yarnRc = await readFile(yarnRcPath, "utf-8"); + const configuredYarnPath = /^yarnPath:\s+(.+)$/m.exec(yarnRc)?.[1]?.trim(); + + if (configuredYarnPath) { + const resolvedYarnPath = resolve(repoRoot, configuredYarnPath); + + try { + await stat(resolvedYarnPath); + + return { + command: process.execPath, + args: [resolvedYarnPath], + }; + } catch { + // fall through + } + } + + return { + command: process.platform === "win32" ? "yarn.cmd" : "yarn", + args: [], + }; +}; + +const waitForOutput = (pattern: RegExp, stdout: NodeJS.ReadableStream | null, timeoutMs = 30_000) => + new Promise((resolve, reject) => { + let output = ""; + const timeout = setTimeout(() => { + cleanup(); + reject(new Error(`Timed out waiting for ${pattern}`)); + }, timeoutMs); + + const onData = (chunk: Buffer | string) => { + output += chunk.toString(); + if (pattern.test(output)) { + cleanup(); + resolve(output); + } + }; + + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + + const cleanup = () => { + clearTimeout(timeout); + stdout?.off("data", onData); + stdout?.off("error", onError); + }; + + stdout?.on("data", onData); + stdout?.on("error", onError); + }); + +afterEach(async () => { + await Promise.all( + [...workspaces].map(async (workspace) => { + await rm(workspace, { recursive: true, force: true }); + }), + ); + workspaces.clear(); +}); + +describe("open command integration", () => { + beforeEach(async () => { + await epic("coverage"); + await feature("cli-commands"); + await story("open.integration"); + await label("coverage", "cli-commands"); + }); + + it("serve with historyPath keeps the HTTP server running", async () => { + const workspace = await mkdtemp(join(tmpdir(), "allure-serve-history-")); + workspaces.add(workspace); + + await mkdir(join(workspace, "allure-results"), { recursive: true }); + await writeFile( + join(workspace, "allure-results", "x-result.json"), + `${JSON.stringify({ + uuid: "11111111-1111-1111-1111-111111111111", + historyId: "abc", + name: "dummy test", + status: "passed", + stage: "finished", + start: 1_784_900_000_000, + stop: 1_784_900_001_000, + labels: [], + steps: [], + parameters: [], + links: [], + })}\n`, + "utf-8", + ); + await writeFile( + join(workspace, "allurerc.mjs"), + `export default { + name: "repro", + historyPath: "./allure-history.jsonl", +}; +`, + "utf-8", + ); + + const yarnInvocation = await resolveYarnInvocation(); + const child = spawn( + yarnInvocation.command, + [...yarnInvocation.args, "node", cliPath, "serve", "allure-results", "--cwd", workspace], + { + cwd: repoRoot, + env: { + ...process.env, + NODE_NO_WARNINGS: "1", + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + + try { + const output = await waitForOutput(/Allure is running on http:\/\/localhost:\d+/, child.stdout); + expect(output).toMatch(/Allure is running on http:\/\/localhost:\d+/); + expect(child.killed).toBe(false); + } finally { + child.kill("SIGTERM"); + await new Promise((resolve) => { + child.once("exit", () => resolve()); + setTimeout(() => { + child.kill("SIGKILL"); + resolve(); + }, 5_000).unref(); + }); + } + }, 60_000); +}); diff --git a/packages/core/src/history.ts b/packages/core/src/history.ts index d4f8099526b..8e26b3dcc7c 100644 --- a/packages/core/src/history.ts +++ b/packages/core/src/history.ts @@ -2,7 +2,7 @@ import { once } from "node:events"; import { type FileHandle, mkdir, open } from "node:fs/promises"; import path from "node:path"; import readline from "node:readline/promises"; -import { pipeline } from "node:stream/promises"; +import { finished, pipeline } from "node:stream/promises"; import { type AllureHistory, @@ -137,8 +137,6 @@ export class AllureLocalHistory implements AllureHistory { const { file: historyFile, exists: historyExists } = await this.#ensureFileOpenedToAppend(fullPath); try { - const dst = historyFile.createWriteStream({ encoding: "utf-8", start: 0, autoClose: false }); - if (limit === 0 && historyExists) { await historyFile.truncate(0); return; @@ -148,6 +146,8 @@ export class AllureLocalHistory implements AllureHistory { return; } + const dst = historyFile.createWriteStream({ encoding: "utf-8", start: 0, autoClose: false }); + if (historyExists) { // move up to `limit-1` most recent entries to the beginning of the file const start = await this.#findFirstEntryAddress(historyFile, limit ? limit - 1 : undefined); @@ -164,6 +164,11 @@ export class AllureLocalHistory implements AllureHistory { if (historyExists) { await historyFile.truncate(dst.bytesWritten); } + + // FileHandle-backed write streams keep the handle ref'd until destroyed. + // Closing the handle before that makes Node exit with a pending appendHistory promise (see nodejs/node#48466). + await finished(dst); + dst.destroy(); } finally { await historyFile.close(); diff --git a/packages/core/test/history.test.ts b/packages/core/test/history.test.ts index a1af99ffe16..c2fcd43dc8d 100644 --- a/packages/core/test/history.test.ts +++ b/packages/core/test/history.test.ts @@ -1,8 +1,10 @@ import { constants } from "node:buffer"; +import { execFile } from "node:child_process"; import { randomUUID } from "node:crypto"; -import { appendFile, open, readFile, rm, writeFile } from "node:fs/promises"; +import { appendFile, mkdir, open, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path/posix"; +import { promisify } from "node:util"; import type { HistoryDataPoint, TestCase, TestResult } from "@allurereport/core-api"; import { epic, feature, label, story } from "allure-js-commons"; @@ -11,6 +13,8 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from import { AllureLocalHistory, createHistory } from "../src/history.js"; import { getDataPath } from "./utils.js"; +const execFileAsync = promisify(execFile); + beforeEach(async () => { await epic("coverage"); await feature("history"); @@ -283,7 +287,7 @@ describe("AllureLocalHistory", () => { }); afterEach(async () => { - await rm(historyPath); + await rm(historyPath, { force: true }); }); it("should create empty file if limit is zero", async () => { @@ -302,6 +306,52 @@ describe("AllureLocalHistory", () => { await checkHistoryFile(["New entry"]); }); + it("should resolve appendHistory in a standalone node process", async () => { + const workDir = join(tmpdir(), randomUUID()); + const historyFilePath = join(workDir, "history.jsonl"); + const scriptPath = join(workDir, "append-history.mjs"); + const line = `${JSON.stringify({ ...entry, name: "Standalone entry" })}\n`; + + await mkdir(workDir, { recursive: true }); + await writeFile( + scriptPath, + ` +import { finished, pipeline } from "node:stream/promises"; +import { mkdir, open } from "node:fs/promises"; +import { dirname } from "node:path"; + +const historyPath = ${JSON.stringify(historyFilePath)}; +await mkdir(dirname(historyPath), { recursive: true }); +const historyFile = await open(historyPath, "w"); + +try { + const dst = historyFile.createWriteStream({ encoding: "utf-8", start: 0, autoClose: false }); + await pipeline([${JSON.stringify(line)}], dst); + await finished(dst); + dst.destroy(); +} finally { + await historyFile.close(); +} + +console.log("append-complete"); + `.trim(), + "utf-8", + ); + + try { + const { stdout } = await execFileAsync(process.execPath, [scriptPath], { + cwd: workDir, + timeout: 5000, + env: { ...process.env, NODE_NO_WARNINGS: "1" }, + }); + + expect(stdout.trim()).toBe("append-complete"); + await expect(readFile(historyFilePath, "utf-8")).resolves.toBe(line); + } finally { + await rm(workDir, { recursive: true, force: true }); + } + }); + it("should write entry to new file if limit is positive", async () => { const history = new AllureLocalHistory({ historyPath, limit: 1 });