diff --git a/desktop/package.json b/desktop/package.json index e810d2bc28..14db248a13 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -16,13 +16,13 @@ "format": "biome format --write .", "test": "node --import ./test-loader.mjs --experimental-strip-types --test \"src/**/*.test.mjs\"", "preview": "vite preview", - "tauri": "tauri", + "tauri": "node ./scripts/tauri-command.mjs", "test:e2e": "pnpm build:e2e && playwright test", "test:e2e:smoke": "pnpm build:e2e && playwright test --project=smoke", "test:e2e:integration": "pnpm build:e2e && playwright test --project=integration", "test:e2e:release-smoke": "pnpm build:e2e && playwright test --config=playwright.release-smoke.config.ts", "test:e2e:report": "playwright show-report", - "tauri:build": "tauri build" + "tauri:build": "node ./scripts/tauri-command.mjs build" }, "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/desktop/scripts/build-protected-feature-artifacts.mjs b/desktop/scripts/build-protected-feature-artifacts.mjs index 879cea4767..3de4830cee 100644 --- a/desktop/scripts/build-protected-feature-artifacts.mjs +++ b/desktop/scripts/build-protected-feature-artifacts.mjs @@ -123,7 +123,9 @@ function main() { const scratchRoot = mkdtempSync( path.join(tmpdir(), "buzz-protected-feature-artifacts-"), ); - const selectedOutput = path.join(desktopRoot, "dist"); + const selectedOutput = process.env.BUZZ_PROTECTED_BUILD_OUTPUT + ? path.resolve(process.env.BUZZ_PROTECTED_BUILD_OUTPUT) + : path.join(desktopRoot, "dist"); const alternateOutput = path.join(scratchRoot, "alternate"); try { diff --git a/desktop/scripts/tauri-command.mjs b/desktop/scripts/tauri-command.mjs new file mode 100644 index 0000000000..d60ac00749 --- /dev/null +++ b/desktop/scripts/tauri-command.mjs @@ -0,0 +1,59 @@ +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const desktopRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); +const tauriPackageJsonPath = fileURLToPath( + import.meta.resolve("@tauri-apps/cli/package.json"), +); +const tauriPackage = JSON.parse(readFileSync(tauriPackageJsonPath, "utf8")); +const defaultTauriEntrypoint = path.resolve( + path.dirname(tauriPackageJsonPath), + tauriPackage.bin.tauri, +); + +function runTauri(args, options = {}) { + const entrypoint = + process.env.BUZZ_TAURI_CLI_ENTRYPOINT ?? defaultTauriEntrypoint; + const result = spawnSync(process.execPath, [entrypoint, ...args], { + cwd: desktopRoot, + env: { ...process.env, ...options.env }, + stdio: "inherit", + }); + if (result.error) throw result.error; + return result.status ?? 1; +} + +export function runTauriCommand(args) { + if (args[0] !== "build") return runTauri(args); + + // Tauri runs beforeBuildCommand and then consumes frontendDist. Give the + // entire invocation a private directory so concurrent OSS/internal packages + // cannot replace one another's assets between those two operations. + const invocationRoot = mkdtempSync( + path.join(tmpdir(), "buzz-tauri-package-assets-"), + ); + const frontendDist = path.join(invocationRoot, "dist"); + const outputOverride = JSON.stringify({ build: { frontendDist } }); + + try { + return runTauri([...args, "--config", outputOverride], { + env: { BUZZ_PROTECTED_BUILD_OUTPUT: frontendDist }, + }); + } finally { + rmSync(invocationRoot, { recursive: true, force: true }); + } +} + +if ( + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + process.exitCode = runTauriCommand(process.argv.slice(2)); +} diff --git a/desktop/src/protectedFeatures/tauriCommand.test.mjs b/desktop/src/protectedFeatures/tauriCommand.test.mjs new file mode 100644 index 0000000000..c002787d69 --- /dev/null +++ b/desktop/src/protectedFeatures/tauriCommand.test.mjs @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { spawn } from "node:child_process"; + +const desktopRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../..", +); +const wrapper = path.join(desktopRoot, "scripts/tauri-command.mjs"); +const fakeCli = path.join(tmpdir(), `buzz-fake-tauri-${process.pid}.mjs`); + +writeFileSync( + fakeCli, + `import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +const args = process.argv.slice(2); +const configIndex = args.lastIndexOf("--config"); +const override = JSON.parse(args[configIndex + 1]); +const output = override.build.frontendDist; +mkdirSync(output, { recursive: true }); +writeFileSync(path.join(output, "variant.txt"), process.env.VITE_BUZZ_BESTIE); +await new Promise((resolve) => setTimeout(resolve, 100)); +const observed = readFileSync(path.join(output, "variant.txt"), "utf8"); +writeFileSync(process.env.BUZZ_TEST_RESULT, JSON.stringify({ output, observed })); +`, +); + +function packageVariant(variant, result) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [wrapper, "build"], { + cwd: desktopRoot, + env: { + ...process.env, + BUZZ_TAURI_CLI_ENTRYPOINT: fakeCli, + BUZZ_TEST_RESULT: result, + VITE_BUZZ_BESTIE: variant, + }, + stdio: "inherit", + }); + child.once("error", reject); + child.once("exit", (code) => + code === 0 ? resolve() : reject(new Error(`wrapper exited ${code}`)), + ); + }); +} + +test("opposite Tauri package variants own private frontend artifacts", async () => { + const resultRoot = path.join(tmpdir(), `buzz-tauri-results-${process.pid}`); + mkdirSync(resultRoot, { recursive: true }); + const ossResult = path.join(resultRoot, "oss.json"); + const internalResult = path.join(resultRoot, "internal.json"); + + await Promise.all([ + packageVariant("0", ossResult), + packageVariant("1", internalResult), + ]); + + const oss = JSON.parse(readFileSync(ossResult, "utf8")); + const internal = JSON.parse(readFileSync(internalResult, "utf8")); + assert.equal(oss.observed, "0"); + assert.equal(internal.observed, "1"); + assert.notEqual(oss.output, internal.output); +});