Ctrl K
ComunidadGuías…

scripts/autostart.test.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { spawnSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import assert from "node:assert/strict";
import test from "node:test";
import {
  buildSystemdUserUnit,
  buildWindowsHostCompilePowerShellCommand,
  buildWindowsInstallationManifest,
  buildWindowsTaskStatusPowerShellCommand,
  buildWindowsTaskXml,
  defaultConfigPath,
  defaultWindowsPowerShellPath,
  encodePowerShellCommand,
  parseAutostartArgs,
  parseWindowsInstallationManifest,
  parseWindowsTaskStatus,
  quoteWindowsArg,
  windowsAutostartPaths
} from "./autostart-core.mjs";

const windowsHostSourcePath = fileURLToPath(new URL("./windows-autostart-host.cs", import.meta.url));

test("parseAutostartArgs accepts install options and resolves paths", () => {
  const result = parseAutostartArgs(["install", "--config", "./config.yaml", "--env-file", "./secrets.env"]);
  assert.equal(result.command, "install");
  assert.equal(result.configPath.endsWith("config.yaml"), true);
  assert.equal(result.envFile.endsWith("secrets.env"), true);
});

test("parseAutostartArgs rejects duplicate and unknown options", () => {
  assert.throws(() => parseAutostartArgs(["install", "--config", "a", "--config", "b"]), /only once/u);
  assert.throws(() => parseAutostartArgs(["install", "--wat"]), /Unknown/u);
  assert.throws(() => parseAutostartArgs(["status", "--config", "a"]), /does not accept/u);
  assert.throws(() => parseAutostartArgs(["restart", "--env-file", "a"]), /does not accept/u);
});

test("default paths follow Windows and XDG conventions", () => {
  assert.equal(
    defaultConfigPath("win32", { APPDATA: "C:\\Users\\u\\AppData\\Roaming" }, "C:\\Users\\u"),
    "C:\\Users\\u\\AppData\\Roaming\\PalmTTY\\config.yaml"
  );
  assert.equal(defaultConfigPath("linux", { XDG_CONFIG_HOME: "/tmp/config" }, "/home/u"), "/tmp/config/palmtty/config.yaml");

  assert.deepEqual(
    windowsAutostartPaths({ LOCALAPPDATA: "C:\\Users\\u\\AppData\\Local" }, "C:\\Users\\u"),
    {
      directory: "C:\\Users\\u\\AppData\\Local\\PalmTTY\\autostart",
      host: "C:\\Users\\u\\AppData\\Local\\PalmTTY\\autostart\\palmtty-autostart-host.exe",
      installation: "C:\\Users\\u\\AppData\\Local\\PalmTTY\\autostart\\installation.json",
      runtime: "C:\\Users\\u\\AppData\\Local\\PalmTTY\\autostart\\runtime.json",
      lastError: "C:\\Users\\u\\AppData\\Local\\PalmTTY\\autostart\\last-error.txt"
    }
  );
});

test("defaultWindowsPowerShellPath resolves the system Windows PowerShell", () => {
  assert.equal(
    defaultWindowsPowerShellPath({ SystemRoot: "C:\\Windows" }),
    "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"
  );
  assert.throws(() => defaultWindowsPowerShellPath({}), /SystemRoot/u);
});

test("quoteWindowsArg preserves spaces and trailing backslashes", () => {
  assert.equal(quoteWindowsArg("plain"), "plain");
  assert.equal(quoteWindowsArg("C:\\Program Files\\node.exe"), '"C:\\Program Files\\node.exe"');
  assert.equal(quoteWindowsArg("C:\\path with space\\"), '"C:\\path with space\\\\"');
});

test("Windows installation manifest contains paths but never secret values", () => {
  const manifest = buildWindowsInstallationManifest({
    nodePath: "C:\\Program Files\\nodejs\\node.exe",
    agentPath: "C:\\PalmTTY\\apps\\agent\\dist\\index.js",
    repoRoot: "C:\\PalmTTY",
    configPath: "C:\\Users\\me\\PalmTTY config.yaml",
    envFile: "C:\\Users\\me\\PalmTTY\\autostart.env"
  });
  assert.equal(manifest.version, 1);
  assert.equal(manifest.envFile, "C:\\Users\\me\\PalmTTY\\autostart.env");
  assert.doesNotMatch(JSON.stringify(manifest), /PALMTTY_ACCESS_TOKEN=/u);
  assert.deepEqual(
    parseWindowsInstallationManifest(JSON.stringify(manifest)),
    manifest
  );
  assert.throws(
    () => parseWindowsInstallationManifest('{"version":2}'),
    /unsupported version/u
  );
});

test("Windows Task Scheduler launches only the native GUI host", () => {
  const xml = buildWindowsTaskXml({
    userSid: "S-1-5-21-123",
    hostPath: "C:\\Users\\me\\AppData\\Local\\PalmTTY\\autostart\\palmtty-autostart-host.exe",
    installationPath: "C:\\Users\\me\\AppData\\Local\\PalmTTY\\autostart\\installation.json",
    workingDirectory: "C:\\Users\\me\\AppData\\Local\\PalmTTY\\autostart"
  });
  assert.match(xml, /<LogonType>InteractiveToken<\/LogonType>/u);
  assert.match(xml, /<RunLevel>LeastPrivilege<\/RunLevel>/u);
  assert.match(xml, /<MultipleInstancesPolicy>IgnoreNew<\/MultipleInstancesPolicy>/u);
  assert.match(xml, /<Command>C:\\Users\\me\\AppData\\Local\\PalmTTY\\autostart\\palmtty-autostart-host\.exe<\/Command>/u);
  assert.match(xml, /--installation/u);
  assert.match(xml, /installation\.json/u);
  assert.doesNotMatch(xml, /powershell/i);
  assert.doesNotMatch(xml, /node\.exe/i);
  assert.doesNotMatch(xml, /apps\\agent/i);
});

test("Windows host source owns Agent lifecycle without a console", () => {
  const source = readFileSync(windowsHostSourcePath, "utf8");
  assert.match(source, /JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE/u);
  assert.match(source, /JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK/u);
  assert.match(source, /CREATE_SUSPENDED \| CREATE_NO_WINDOW/u);
  assert.match(source, /AssignProcessToJobObject/u);
  assert.match(source, /WaitForSingleObject/u);
  assert.match(source, /DataContractJsonSerializer/u);
  assert.doesNotMatch(source, /System\.Windows\.Forms/u);
});

test("Windows host compilation targets the GUI subsystem", () => {
  const command = buildWindowsHostCompilePowerShellCommand({
    sourcePath: "C:\\PalmTTY\\scripts\\windows-autostart-host.cs",
    outputPath: "C:\\Users\\me\\AppData\\Local\\PalmTTY\\autostart\\host.tmp.exe"
  });
  assert.match(command, /Add-Type/u);
  assert.match(command, /-OutputType WindowsApplication/u);
  assert.match(command, /System\.Runtime\.Serialization\.dll/u);
});

function peSubsystem(executablePath) {
  const data = readFileSync(executablePath);
  const peOffset = data.readUInt32LE(0x3c);
  assert.equal(data.toString("ascii", peOffset, peOffset + 4), "PE\0\0");
  const optionalHeader = peOffset + 4 + 20;
  const magic = data.readUInt16LE(optionalHeader);
  assert.ok(magic === 0x10b || magic === 0x20b, "PE optional-header magic must be PE32/PE32+");
  return data.readUInt16LE(optionalHeader + 68);
}

test("Windows native host compiles as GUI, propagates Agent exit, and preserves detached Worker lifetime", { skip: process.platform !== "win32" }, async () => {
  const directory = mkdtempSync(path.join(os.tmpdir(), "palmtty autostart-"));
  const hostPath = path.join(directory, "palmtty-autostart-host.exe");
  const installationPath = path.join(directory, "installation.json");
  const markerPath = path.join(directory, "worker-survived.txt");
  const workerPath = path.join(directory, "worker.cjs");
  const agentPath = path.join(directory, "agent.cjs");
  const configPath = path.join(directory, "config.yaml");

  try {
    writeFileSync(configPath, "server:\n  port: 17688\n  exposure:\n    mode: local\n", "utf8");
    writeFileSync(
      workerPath,
      `const { writeFileSync } = require("node:fs");\nsetTimeout(() => { writeFileSync(${JSON.stringify(markerPath)}, "ok"); process.exit(0); }, 300);\n`,
      "utf8"
    );
    writeFileSync(
      agentPath,
      `const { spawn } = require("node:child_process");\nconst child = spawn(process.execPath, [${JSON.stringify(workerPath)}], { detached: true, windowsHide: true, stdio: "ignore" });\nchild.unref();\nprocess.exit(7);\n`,
      "utf8"
    );

    const compileCommand = buildWindowsHostCompilePowerShellCommand({
      sourcePath: windowsHostSourcePath,
      outputPath: hostPath
    });
    const compile = spawnSync(
      defaultWindowsPowerShellPath(),
      [
        "-NoLogo",
        "-NoProfile",
        "-NonInteractive",
        "-EncodedCommand",
        encodePowerShellCommand(compileCommand)
      ],
      // Add-Type can spend well over 30s starting the Windows compiler on
      // a cold/shared GitHub runner. Keep the smoke test bounded, but do not
      // mistake runner startup latency for a product failure.
      { encoding: "utf8", windowsHide: true, timeout: 90_000 }
    );
    assert.equal(compile.status, 0, compile.stderr || compile.stdout);
    assert.equal(peSubsystem(hostPath), 2, "PE subsystem must be Windows GUI");

    writeFileSync(
      installationPath,
      JSON.stringify(buildWindowsInstallationManifest({
        nodePath: process.execPath,
        agentPath,
        repoRoot: directory,
        configPath
      })),
      "utf8"
    );

    const result = spawnSync(
      hostPath,
      ["--installation", installationPath],
      { encoding: "utf8", windowsHide: false, timeout: 15_000 }
    );
    assert.equal(result.status, 7, result.stderr || result.stdout);

    const deadline = Date.now() + 5_000;
    while (Date.now() < deadline) {
      try {
        assert.equal(readFileSync(markerPath, "utf8"), "ok");
        // Marker write precedes detached worker shutdown. Windows must not
        // remove the executable/cwd while that survivor is still exiting.
        await new Promise((resolve) => setTimeout(resolve, 500));
        return;
      } catch {
        await new Promise((resolve) => setTimeout(resolve, 100));
      }
    }
    assert.fail("detached Worker did not survive native host Job close");
  } finally {
    try {
      rmSync(directory, { recursive: true, force: true, maxRetries: 60, retryDelay: 250 });
    } catch (error) {
      // Windows AV/indexing can transiently retain handles after a detached
      // survivor has finished; cleanup must not mask an otherwise passing
      // lifecycle assertion. Unexpected filesystem errors still fail.
      if (process.platform !== "win32" ||
          !["EBUSY", "EPERM", "ENOTEMPTY"].includes(error?.code)) throw error;
      console.warn(`Windows deferred temp cleanup: ${error.code}`);
    }
  }
});

test("Windows status query executes through the system PowerShell", { skip: process.platform !== "win32" }, () => {
  const command = buildWindowsTaskStatusPowerShellCommand("PalmTTY status test 9f0dd16d");
  const result = spawnSync(
    defaultWindowsPowerShellPath(),
    [
      "-NoLogo",
      "-NoProfile",
      "-NonInteractive",
      "-EncodedCommand",
      encodePowerShellCommand(command)
    ],
    { encoding: "utf8", windowsHide: true }
  );
  assert.equal(result.status, 0, result.stderr || result.stdout);
  assert.deepEqual(parseWindowsTaskStatus(result.stdout), { installed: false });
});

test("Windows status query is locale-independent UTF-8 JSON", () => {
  const command = buildWindowsTaskStatusPowerShellCommand();
  assert.match(command, /OutputEncoding/u);
  assert.match(command, /Get-ScheduledTask/u);
  assert.match(command, /ConvertTo-Json -Compress/u);
  assert.doesNotMatch(command, /schtasks/u);

  assert.deepEqual(parseWindowsTaskStatus('{"installed":false}'), { installed: false });
  assert.deepEqual(
    parseWindowsTaskStatus('{"installed":true,"state":"Running","lastRunTime":"2026-09-22T12:00:00.0000000Z","nextRunTime":null,"lastTaskResult":0}'),
    {
      installed: true,
      state: "running",
      lastRunTime: "2026-09-22T12:00:00.0000000Z",
      nextRunTime: null,
      lastTaskResult: 0
    }
  );
  assert.throws(() => parseWindowsTaskStatus('{"installed":true,"state":"Running","lastRunTime":null,"nextRunTime":null}'), /lastTaskResult/u);
  assert.throws(() => parseWindowsTaskStatus("garbage"), /invalid status payload/u);
});

test("systemd unit preserves independent Worker lifetime", () => {
  const unit = buildSystemdUserUnit({
    nodePath: "/usr/bin/node",
    agentPath: "/opt/PalmTTY/apps/agent/dist/index.js",
    repoRoot: "/opt/PalmTTY",
    configPath: "/home/me/.config/palmtty/config.yaml",
    envFile: "/home/me/.config/palmtty/autostart.env"
  });
  assert.match(unit, /^KillMode=process$/mu);
  assert.match(unit, /^Restart=on-failure$/mu);
  assert.doesNotMatch(unit, /network-online\.target/u);
  assert.match(unit, /--env-file/u);
  assert.doesNotMatch(unit, /PALMTTY_ACCESS_TOKEN=/u);
});