Ctrl K
ComunidadGuías…

scripts/smoke-desktop.ts

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

Archivo original ↗Preguntar a KiBot
import {
  _electron as electron,
  expect,
  type ElectronApplication,
} from "@playwright/test";
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import crypto from "node:crypto";
import { fixtureCourse } from "../tests/v2/fixtures/course";
import type { Catalog, AppState, Command } from "../src/shared/model";
import type { AppStatus } from "../src/shared/app-update";
import { gitInfo, writeAppUpdate } from "./app-update-package";
async function main(): Promise<void> {
  const packaged = process.argv.includes("--packaged");
  const root = await fs.mkdtemp(path.join(os.tmpdir(), "vibe-smoke-"));
  const workspace = path.join(root, "my-code");
  await fs.mkdir(workspace);
  await fs.writeFile(
    path.join(workspace, "go.mod"),
    "module smoke\n\ngo 1.22\n",
  );
  const source =
    'package smoke\nimport "testing"\nfunc TestAddition(t *testing.T) { if 2+2 != 4 { t.Fatal("addition") } }\n';
  await fs.writeFile(path.join(workspace, "addition_test.go"), source);
  let executablePath = packaged
    ? process.env.VIBE_PACKAGED_EXECUTABLE
    : process.env.ELECTRON_PATH;
  if (packaged && !executablePath) {
    const parent = path.resolve(
      "out",
      `Vibe Learn-${process.platform}-${process.arch}`,
    );
    executablePath =
      process.platform === "darwin"
        ? path.join(parent, "Vibe Learn.app/Contents/MacOS/vibe-learn")
        : path.join(
            parent,
            process.platform === "win32" ? "vibe-learn.exe" : "vibe-learn",
          );
  }
  const env = {
    ...process.env,
    VIBE_USER_DATA_DIR: path.join(root, "profile"),
    ELECTRON_DISABLE_SECURITY_WARNINGS: "true",
  } as Record<string, string>;
  if (!packaged) {
    const catalog = JSON.parse(
      await fs.readFile("build/content/catalog.json", "utf8"),
    ) as Catalog;
    catalog.items.push({
      ...fixtureCourse,
      title: "Course activities · preview",
    });
    env.VIBE_DEV_CATALOG = path.join(root, "test-catalog.json");
    await fs.writeFile(env.VIBE_DEV_CATALOG, JSON.stringify(catalog));
  }
  delete env.ELECTRON_RUN_AS_NODE;
  // Packaged runs trust a throwaway key so the smoke can sign its own app update.
  const updateKeys = crypto.generateKeyPairSync("ed25519");
  if (packaged)
    env.VIBE_APP_UPDATE_PUBLIC_KEY = updateKeys.publicKey
      .export({ type: "spki", format: "der" })
      .toString("base64");
  // Empty PATH proves packaged basic Go checks use the bundled toolchain, not a host installation.
  if (packaged) {
    env.PATH = "";
    env.GOPROXY = "off";
    env.GOSUMDB = "off";
  }
  const launch = () =>
    electron.launch({
      executablePath,
      args: [
        ...(process.platform === "linux" ? ["--no-sandbox"] : []),
        ...(packaged ? [] : [path.resolve(".")]),
      ],
      env,
      timeout: 30000,
    });
  let app: ElectronApplication | undefined;
  try {
    app = await launch();
    expect(await app.evaluate(({ app }) => app.isPackaged)).toBe(packaged);
    let page = await app.firstWindow();
    await expect(
      page.getByRole("heading", { name: "Continue", exact: true }),
    ).toBeVisible();
    const call = (input: Command) =>
      page.evaluate((command) => window.learning!.invoke(command), input);
    const catalog = (await call({ type: "catalog" })) as Catalog;
    const relay = catalog.items.find((i) => i.id === "project:ingest-relay")!;
    const operator = catalog.items.find(
      (i) => i.id === "project:relay-operator",
    )!;
    expect(operator.checks).toEqual([]);
    await page.evaluate(
      ({ itemId, stageId }) => {
        location.hash = `/read/${encodeURIComponent(itemId)}/${stageId}`;
      },
      { itemId: relay.id, stageId: relay.stages[0]!.id },
    );
    await page.getByRole("button", { name: "Notes", exact: true }).click();
    await page
      .getByRole("textbox", { name: "Notes", exact: true })
      .fill("Keep my reasoning and my tests.");
    await page.getByRole("button", { name: "Save notes", exact: true }).click();
    await expect(page.getByText("Saved locally")).toBeVisible();
    await page.getByRole("button", { name: "Close panel" }).click();
    await page.getByRole("button", { name: "☆ Bookmark", exact: true }).click();
    await page.getByRole("button", { name: "Copy stage context" }).click();
    expect(
      await app.evaluate(({ clipboard }) => clipboard.readText()),
    ).toContain("Build an ingest relay");
    await app.evaluate(({ dialog }, folder) => {
      dialog.showOpenDialog = (async () => ({
        canceled: false,
        filePaths: [folder],
      })) as typeof dialog.showOpenDialog;
    }, workspace);
    await call({
      type: "workspace",
      itemId: relay.id,
      action: "attach",
      mode: "mentor",
    });
    await page.getByRole("button", { name: "Workspace", exact: true }).click();
    await page
      .getByRole("button", { name: "Run your tests", exact: true })
      .click();
    await expect
      .poll(
        async () => ((await call({ type: "state" })) as AppState).runs.length,
      )
      .toBe(1);
    const runId = ((await call({ type: "state" })) as AppState).runs[0]!.id;
    await expect
      .poll(
        async () =>
          ((await call({ type: "state" })) as AppState).runs.find(
            (r) => r.id === runId,
          )?.status,
        { timeout: 120000 },
      )
      .toBe("passed");
    await expect(
      page.locator("summary").filter({ hasText: "Your tests · passed" }),
    ).toBeVisible();
    const state = (await call({ type: "state" })) as AppState;
    expect(state.runs[0]?.sourceHash).toMatch(/^[a-f0-9]{64}$/);
    expect(
      await fs.readFile(path.join(workspace, "addition_test.go"), "utf8"),
    ).toBe(source);
    const reporter = catalog.items.find(
      (i) => i.id === "project:cloud-reporter",
    )!;
    const parent = path.join(root, "projects");
    await fs.mkdir(parent);
    await app.evaluate(({ dialog }, folder) => {
      dialog.showOpenDialog = (async () => ({
        canceled: false,
        filePaths: [folder],
      })) as typeof dialog.showOpenDialog;
    }, parent);
    const created = (await call({
      type: "workspace",
      itemId: reporter.id,
      action: "create",
      mode: "mentor",
    })) as { path: string };
    // Compare names, not full paths: Windows may report the temp directory in
    // its 8.3 short form (RUNNER~1) on one side and long form on the other.
    expect(path.basename(created.path)).toBe("cloud-reporter");
    const folder = path.join(parent, "cloud-reporter");
    expect(
      await fs.readFile(path.join(folder, "steps/02-fetch-page.md"), "utf8"),
    ).toContain("<details>");
    for (const file of [
      "README.md",
      "AGENTS.md",
      "NOTES.md",
      "MENTOR-NOTES.md",
      "go.mod",
      ".claude/settings.local.json",
    ])
      await fs.access(path.join(folder, file));
    const admission = catalog.items.find((i) => i.id === "project:kubernetes-admission-lab")!;
    const lab = (await call({
      type: "workspace", itemId: admission.id, action: "create", mode: "mentor",
    })) as { path: string };
    expect(await fs.readFile(path.join(lab.path, "Taskfile.yml"), "utf8"))
      .toContain("go run doctor.go");
    expect(await fs.readFile(path.join(lab.path, "doctor.go"), "utf8"))
      .toContain("func doctorMain(");
    await expect(fs.access(path.join(lab.path, "main.go"))).rejects.toThrow();
    await fs.writeFile(path.join(lab.path, "Taskfile.yml"), "# My tasks\n");
    await fs.unlink(path.join(lab.path, "doctor.go"));
    const restoredHelpers = (await call({
      type: "workspace", itemId: admission.id, action: "assistant-files", mode: "mentor",
    })) as { written: string[]; skipped: string[] };
    expect(restoredHelpers.written).toEqual(["doctor.go"]);
    expect(restoredHelpers.skipped).toContain("Taskfile.yml");
    expect(await fs.readFile(path.join(lab.path, "Taskfile.yml"), "utf8"))
      .toBe("# My tasks\n");
    expect(((await call({ type: "state" })) as AppState).runs).toHaveLength(1);
    await expect(
      call({ type: "run", itemId: operator.id, checkId: "go-test" }),
    ).rejects.toThrow();
    await expect(
      call({
        type: "progress",
        itemId: relay.id,
        stageId: "not-a-stage",
        scroll: 0,
      }),
    ).rejects.toThrow();
    const backup = path.join(root, "export.json");
    await app.evaluate(({ dialog }, file) => {
      dialog.showSaveDialog = (async () => ({
        canceled: false,
        filePath: file,
      })) as typeof dialog.showSaveDialog;
    }, backup);
    await call({ type: "backup" });
    expect(
      JSON.parse(await fs.readFile(backup, "utf8")).state.notes[0].text,
    ).toContain("reasoning");
    await fs.mkdir("build/screenshots", { recursive: true });
    await page.screenshot({ path: "build/screenshots/desktop.png" });
    await page.getByRole("button", { name: "Close panel" }).click();
    await page.evaluate(() => {
      location.hash = "/read/project%3Aingest-relay/session-2";
    });
    await expect(page.locator("article")).toContainText("Put it behind HTTP");
    await expect
      .poll(
        async () =>
          ((await call({ type: "state" })) as AppState).progress.find(
            (p) => p.itemId === relay.id,
          )?.stageId,
      )
      .toBe("session-2");
    await page.evaluate(async () => {
      await document.fonts.ready;
      await Promise.all(
        [...document.images].map((image) => image.decode().catch(() => {})),
      );
      await new Promise<void>((resolve) =>
        requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
      );
    });
    await page.evaluate(() =>
      window.scrollTo(
        0,
        (document.documentElement.scrollHeight - innerHeight) * 0.55,
      ),
    );
    await expect
      .poll(
        async () =>
          ((await call({ type: "state" })) as AppState).progress.find(
            (p) => p.itemId === relay.id,
          )?.scroll || 0,
      )
      .toBeGreaterThan(0.5);
    await page
      .getByRole("navigation")
      .getByRole("link", { name: "Projects" })
      .click();
    const position = (
      (await call({ type: "state" })) as AppState
    ).progress.find((p) => p.itemId === relay.id)!;
    expect(position.stageId).toBe("session-2");
    expect(position.scroll).toBeCloseTo(0.55, 1);
    if (!packaged) {
      await page.evaluate(() => {
        location.hash = "/courses/course%3Afixture/exercises";
      });
      await page
        .getByRole("button", { name: "Prepare exercise", exact: true })
        .click();
      await expect(
        page.getByRole("button", { name: "Run checks", exact: true }),
      ).toBeEnabled();
      await page
        .getByRole("button", { name: "Run checks", exact: true })
        .click();
      await expect(
        page.getByRole("heading", { name: "Last run: failed" }),
      ).toBeVisible({ timeout: 120000 });
      const folder = ((await call({ type: "state" })) as AppState)
        .exerciseWorkspaces[0]!.path;
      await fs.writeFile(
        path.join(folder, "addition.go"),
        "package addition\nfunc Add(a,b int) int {return a+b}\n",
      );
      await page
        .getByRole("button", { name: "Run checks", exact: true })
        .click();
      await expect(
        page.getByRole("heading", { name: "Last run: passed" }),
      ).toBeVisible({ timeout: 120000 });
      await page.getByText("Hint 1", { exact: true }).click();
      await page.evaluate(() => window.scrollTo(0, 0));
      await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(0);
      await page.screenshot({
        path: "build/screenshots/desktop-exercise.png",
        fullPage: true,
      });
      await page.evaluate(() => {
        location.hash = "/courses/course%3Afixture/flashcards";
      });
      await page.getByRole("button", { name: "Reveal answer" }).click();
      await page.screenshot({
        path: "build/screenshots/desktop-flashcard-browse.png",
        fullPage: true,
      });
      await page.getByRole("button", { name: /Review available/ }).click();
      await page.getByRole("button", { name: "Reveal answer" }).click();
      await page.screenshot({
        path: "build/screenshots/desktop-flashcard-review.png",
        fullPage: true,
      });
      await page.getByRole("button", { name: "Good", exact: true }).click();
      await expect(page.getByText("What is -1 + 1?")).toBeVisible();
      await page.getByRole("button", { name: "Reveal answer" }).click();
      await page.getByRole("button", { name: "Easy", exact: true }).click();
      await expect(
        page.getByRole("heading", { name: "Review complete" }),
      ).toBeVisible();
      expect(
        ((await call({ type: "state" })) as AppState).reviews,
      ).toHaveLength(2);
      await call({ type: "backup" });
      expect(
        JSON.parse(await fs.readFile(backup, "utf8")).state.reviews,
      ).toHaveLength(2);
    }
    // Serve an update through the same main-process fetch path without using the public channel.
    const updatedCatalog = structuredClone(catalog);
    updatedCatalog.items.push({
      id: "project:download-fixture",
      kind: "project",
      title: "Downloaded project",
      description: "Content update smoke fixture",
      source: "tests",
      version: "1",
      stages: [
        {
          id: "overview",
          title: "Overview",
          html: "<h1>Downloaded project</h1>",
          text: "Downloaded project",
        },
      ],
      related: [],
      checks: [],
      prerequisites: [],
    });
    const contentData = JSON.stringify(updatedCatalog);
    const contentManifest = {
      formatVersion: 1,
      minimumAppVersion: "2.0.2",
      publishedAt: new Date(Date.now() + 60000).toISOString(),
      revision: crypto.createHash("sha256").update(contentData).digest("hex"),
    };
    await app.evaluate(
      (_electron, payload) => {
        globalThis.fetch = (async (input) => {
          const url = String(input);
          if (url === "https://vibe-learn.ai/updates/latest.json")
            return new Response(JSON.stringify(payload.manifest));
          if (
            url ===
            `https://vibe-learn.ai/updates/${payload.manifest.revision}.json`
          )
            return new Response(payload.data);
          throw new Error(`Unexpected update URL: ${url}`);
        }) as typeof fetch;
      },
      { manifest: contentManifest, data: contentData },
    );
    await page.getByRole("link", { name: "Settings & backups" }).click();
    const beforeContentUpdate = (await call({ type: "state" })) as AppState;
    await page
      .getByRole("button", { name: "Update content", exact: true })
      .click();
    await expect(
      page.getByText("Content updated.", { exact: true }),
    ).toBeVisible();
    expect(await call({ type: "state" })).toEqual(beforeContentUpdate);
    await page.screenshot({ path: "build/screenshots/content-updates.png" });
    expect(
      await fs.readFile(path.join(workspace, "addition_test.go"), "utf8"),
    ).toBe(source);
    await page
      .getByRole("navigation")
      .getByRole("link", { name: "Projects", exact: true })
      .click();
    await expect(
      page.getByRole("heading", { name: "Downloaded project", exact: true }),
    ).toBeVisible();
    await app.evaluate(() => {
      globalThis.fetch = async () =>
        new Response("Unavailable", { status: 503 });
    });
    await page.getByRole("link", { name: "Settings & backups" }).click();
    await page
      .getByRole("button", { name: "Update content", exact: true })
      .click();
    await expect(
      page.getByText(/Content server returned HTTP 503/),
    ).toBeVisible();
    expect(
      ((await call({ type: "catalog" })) as Catalog).items.some(
        (i) => i.id === "project:download-fixture",
      ),
    ).toBe(true);
    if (packaged) {
      // Publish this build's own code with a marker, then update through Settings.
      const updateApp = path.join(root, "update-app");
      await fs.cp("build/app", updateApp, { recursive: true });
      const index = path.join(updateApp, "renderer/index.html");
      await fs.writeFile(
        index,
        (await fs.readFile(index, "utf8")).replace(
          "<head>",
          '<head><meta name="vibe-smoke-update" content="downloaded">',
        ),
      );
      const server = path.join(root, "update-server");
      const manifest = writeAppUpdate({
        appDir: updateApp,
        lockRoot: ".",
        destination: server,
        privateKey: updateKeys.privateKey,
        version: "9.9.9",
        commit: gitInfo(".").commit,
        publishedAt: new Date(Date.now() + 60000).toISOString(),
      });
      await app.evaluate(
        (_electron, payload) => {
          globalThis.fetch = (async (input) => {
            const url = String(input);
            if (url === "https://vibe-learn.ai/updates/app/latest.json")
              return new Response(payload.latest);
            if (
              url ===
              `https://vibe-learn.ai/updates/app/${payload.revision}.json`
            )
              return new Response(payload.bundle);
            throw new Error(`Unexpected update URL: ${url}`);
          }) as typeof fetch;
        },
        {
          revision: manifest.revision,
          latest: await fs.readFile(path.join(server, "latest.json"), "utf8"),
          bundle: await fs.readFile(
            path.join(server, `${manifest.revision}.json`),
            "utf8",
          ),
        },
      );
      await page
        .getByRole("button", { name: "Dismiss notification", exact: true })
        .click();
      const beforeAppUpdate = (await call({ type: "state" })) as AppState;
      await page
        .getByRole("button", { name: "Update app", exact: true })
        .click();
      await expect(
        page.getByText("App update downloaded. Restart to use it.", {
          exact: true,
        }),
      ).toBeVisible();
      await expect(
        page.getByRole("button", { name: "Restart now", exact: true }),
      ).toBeVisible();
      expect(await call({ type: "state" })).toEqual(beforeAppUpdate);
      await page.screenshot({ path: "build/screenshots/app-updates.png" });
    } else {
      await expect(
        page.getByRole("button", { name: "Update app", exact: true }),
      ).toBeDisabled();
    }
    await page.getByRole("button", { name: "Switch theme" }).click();
    await app.close();
    app = undefined;
    app = await launch();
    page = await app.firstWindow();
    await expect(page.getByRole("heading", { name: "Continue" })).toBeVisible();
    expect(
      ((await call({ type: "catalog" })) as Catalog).items.some(
        (i) => i.id === "project:download-fixture",
      ),
    ).toBe(true);
    const restored = (await call({ type: "state" })) as AppState;
    expect(restored.notes[0]?.text).toBe("Keep my reasoning and my tests.");
    expect(restored.bookmarks).toHaveLength(1);
    if (!packaged) {
      expect(restored.reviews).toHaveLength(2);
      expect(restored.exerciseWorkspaces).toHaveLength(1);
      expect(
        restored.runs.filter((r) => r.kind === "exercise").map((r) => r.status),
      ).toEqual(["failed", "passed"]);
    }
    expect(restored.runs[0]?.status).toBe("passed");
    expect(
      restored.progress.find((p) => p.itemId === relay.id)?.scroll,
    ).toBeCloseTo(0.55, 1);
    await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
    if (packaged) {
      // The restart ran the downloaded code and recorded it as healthy.
      await expect(page.locator('meta[name="vibe-smoke-update"]')).toHaveCount(
        1,
      );
      expect(((await call({ type: "app-status" })) as AppStatus).source).toBe(
        "downloaded",
      );
      await expect
        .poll(() =>
          fs.access(path.join(root, "profile/app-update/launching.json")).then(
            () => true,
            () => false,
          ),
        )
        .toBe(false);
      await page.getByRole("link", { name: "Settings & backups" }).click();
      await expect(page.getByText(/Version 9\.9\.9 · updated/)).toBeVisible();
      await page.screenshot({ path: "build/screenshots/app-updated.png" });
    }
    console.log(
      `${packaged ? "Packaged" : "Development"} desktop smoke passed: SQLite restart, content update/recovery, ${packaged ? "signed app update and restart, " : ""}notes, bookmarks, validated IPC, clipboard, untouched learner code, ${packaged ? "bundled offline" : "local"} Go execution.`,
    );
  } finally {
    await app?.close();
    await fs.rm(root, { recursive: true, force: true });
  }
}
void main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});