Ctrl K
ComunidadGuías…

test/scripts/static-asset-build.test.ts

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

Archivo original ↗Preguntar a KiBot
import { expect } from "@std/expect";
import { join } from "@std/path";
import { describe, it as test } from "@std/testing/bdd";
import { withGeneratedOutputRollback } from "#scripts/static-assets/output-rollback.ts";
import {
  type BuiltStaticBundle,
  buildOrReuseStaticAssets,
  createStaticAssetBuild,
  deferStaticAssetBuild,
  fileExists,
  type StaticAssetBuild,
  type StaticAssetFiles,
  type StaticBundle,
} from "#scripts/static-assets/session.ts";

interface FakeContext {
  disposeCalls: number;
  rebuildCalls: number;
  value: BuiltStaticBundle["context"];
}

const fakeContext = (rebuildFails = false): FakeContext => {
  const fake: FakeContext = {
    disposeCalls: 0,
    rebuildCalls: 0,
    value: {
      dispose: () => {
        fake.disposeCalls += 1;
        return Promise.resolve();
      },
      rebuild: () => {
        fake.rebuildCalls += 1;
        return rebuildFails
          ? Promise.reject(new Error("Build failed"))
          : Promise.resolve();
      },
    },
  };
  return fake;
};

const bundle = (label: string, outfile: string): StaticBundle => ({
  label,
  options: { outfile },
});

interface Fixture {
  admin: StaticBundle;
  adminContext: FakeContext;
  scanner: StaticBundle;
  scannerContext: FakeContext;
  session: ReturnType<typeof createStaticAssetBuild>;
  stopCalls: { count: number };
  writes: Array<{ contents: Uint8Array; file: string }>;
}

const fixture = (
  scannerFails = false,
  write?: StaticAssetFiles["write"],
): Fixture => {
  const admin = bundle("Admin", "static/admin.js");
  const scanner = bundle("Scanner", "static/scanner.js");
  const adminContext = fakeContext();
  const scannerContext = fakeContext(scannerFails);
  const stopCalls = { count: 0 };
  const writes: Array<{ contents: Uint8Array; file: string }> = [];
  const built: BuiltStaticBundle[] = [
    {
      baseline: new Uint8Array([1, 2]),
      bundle: admin,
      context: adminContext.value,
      inputs: ["client/admin.ts", "client/shared.ts"],
    },
    {
      baseline: new Uint8Array([3, 4]),
      bundle: scanner,
      context: scannerContext.value,
      inputs: ["client/scanner.ts", "client/shared.ts"],
    },
  ];
  return {
    admin,
    adminContext,
    scanner,
    scannerContext,
    session: createStaticAssetBuild(built, {
      resolve: (file) => `/project/${file}`,
      stop: () => {
        stopCalls.count += 1;
      },
      write: (file, contents) => {
        writes.push({ contents, file: `/project/${file}` });
        return write?.(file, contents) ?? Promise.resolve();
      },
    }),
    stopCalls,
    writes,
  };
};

describe("static asset build session", () => {
  test("finds every bundle affected by a source file", async () => {
    const { admin, scanner, session } = fixture();

    expect(await session.affected("client/shared.ts")).toEqual([
      admin,
      scanner,
    ]);
    expect(await session.affected("client/admin.ts")).toEqual([admin]);
    expect(await session.affected("client/other.ts")).toEqual([]);
  });

  test("rebuilds only the selected bundles", async () => {
    const { adminContext, scanner, scannerContext, session } = fixture();

    expect(await session.rebuild([scanner])).toBe(true);
    expect(adminContext.rebuildCalls).toBe(0);
    expect(scannerContext.rebuildCalls).toBe(1);
  });

  test("reports an incremental rebuild failure", async () => {
    const { scanner, scannerContext, session } = fixture(true);

    expect(await session.rebuild([scanner])).toBe(false);
    expect(scannerContext.rebuildCalls).toBe(1);
  });

  test("waits for every selected rebuild after one fails", async () => {
    const { admin, adminContext, scanner, scannerContext, session } = fixture();
    const scannerBuild = Promise.withResolvers<void>();
    adminContext.value.rebuild = () => {
      adminContext.rebuildCalls += 1;
      return Promise.reject(new Error("Admin build failed"));
    };
    scannerContext.value.rebuild = () => {
      scannerContext.rebuildCalls += 1;
      return scannerBuild.promise;
    };

    let settled = false;
    const result = (async () => {
      const value = await session.rebuild([admin, scanner]);
      settled = true;
      return value;
    })();
    await Promise.resolve();
    expect(settled).toBe(false);

    scannerBuild.resolve();
    expect(await result).toBe(false);
    expect(adminContext.rebuildCalls).toBe(1);
    expect(scannerContext.rebuildCalls).toBe(1);
  });

  test("rejects a bundle that was not built in the session", () => {
    const { session } = fixture();

    expect(() => session.restore([bundle("Other", "static/other.js")])).toThrow(
      "Static bundle was not built: Other",
    );
  });

  test("restores the exact baseline files for selected bundles", async () => {
    const { admin, session, writes } = fixture();

    await session.restore([admin]);

    expect(writes).toEqual([
      {
        contents: new Uint8Array([1, 2]),
        file: "/project/static/admin.js",
      },
    ]);
  });

  test("waits for every selected restore write after one fails", async () => {
    const scannerWrite = Promise.withResolvers<void>();
    const { admin, scanner, session, writes } = fixture(false, (file) =>
      file === "static/admin.js"
        ? Promise.reject(new Error("Admin write failed"))
        : scannerWrite.promise,
    );

    let settled = false;
    const result = (async () => {
      try {
        await session.restore([admin, scanner]);
      } finally {
        settled = true;
      }
    })();
    await Promise.resolve();
    expect(settled).toBe(false);

    scannerWrite.resolve();
    await expect(result).rejects.toThrow("Admin write failed");
    expect(writes.map(({ file }) => file)).toEqual([
      "/project/static/admin.js",
      "/project/static/scanner.js",
    ]);
  });

  test("disposes every bundle context", async () => {
    const { adminContext, scannerContext, session, stopCalls } = fixture();

    await session.dispose();

    expect(adminContext.disposeCalls).toBe(1);
    expect(scannerContext.disposeCalls).toBe(1);
    expect(stopCalls.count).toBe(1);
  });

  test("waits for every bundle disposal when one fails", async () => {
    const { adminContext, scannerContext, session, stopCalls } = fixture();
    adminContext.value.dispose = () => {
      adminContext.disposeCalls += 1;
      return Promise.reject(new Error("Admin dispose failed"));
    };

    await expect(session.dispose()).rejects.toThrow("Admin dispose failed");

    expect(adminContext.disposeCalls).toBe(1);
    expect(scannerContext.disposeCalls).toBe(1);
    expect(stopCalls.count).toBe(1);
  });
});

describe("static asset output rollback", () => {
  test("removes partial new output when a build fails", async () => {
    const dir = await Deno.makeTempDir();
    const existing = join(dir, "existing.js");
    const generated = join(dir, "generated.js");
    await Deno.writeTextFile(existing, "keep");
    try {
      await expect(
        withGeneratedOutputRollback(
          [existing, generated],
          async () => {
            await Deno.writeTextFile(existing, "partial");
            await Deno.writeTextFile(generated, "partial");
            throw new Error("Deliberate build failure");
          },
          () => [],
        ),
      ).rejects.toThrow("Deliberate build failure");
      expect(await Deno.readTextFile(existing)).toBe("keep");
      expect(await fileExists(generated)).toBe(false);
    } finally {
      await Deno.remove(dir, { recursive: true });
    }
  });

  test("keeps generated output after a successful build", async () => {
    const dir = await Deno.makeTempDir();
    const generated = join(dir, "generated.js");
    try {
      expect(
        await withGeneratedOutputRollback(
          [generated],
          async () => {
            await Deno.writeTextFile(generated, "complete");
            return "built";
          },
          () => [],
        ),
      ).toBe("built");
      expect(await Deno.readTextFile(generated)).toBe("complete");
    } finally {
      await Deno.remove(dir, { recursive: true });
    }
  });
});

describe("buildOrReuseStaticAssets", () => {
  const session = (): StaticAssetBuild => ({
    affected: () => Promise.resolve([]),
    dispose: () => Promise.resolve(),
    rebuild: () => Promise.resolve(true),
    restore: () => Promise.resolve(),
  });

  test("builds straight away when the assets are out of date", async () => {
    const built = { count: 0 };
    const ready = session();

    const result = await buildOrReuseStaticAssets(false, () => {
      built.count += 1;
      return Promise.resolve(ready);
    });

    expect(built.count).toBe(1);
    expect(result).toBe(ready);
  });

  test("waits, and builds nothing, when the assets are current", async () => {
    const built = { count: 0 };

    const result = await buildOrReuseStaticAssets(true, () => {
      built.count += 1;
      return Promise.resolve(session());
    });

    expect(built.count).toBe(0);
    // Still a usable build — it just holds off until something asks.
    await result.dispose();
    expect(built.count).toBe(0);
    expect(await result.rebuild([])).toBe(true);
    expect(built.count).toBe(1);
  });
});

describe("deferStaticAssetBuild", () => {
  const bundle: StaticBundle = {
    label: "Admin",
    options: { outfile: "static/admin.js" },
  };

  const fakeBuild = () => {
    const calls = { affected: 0, build: 0, dispose: 0, rebuild: 0 };
    const build: StaticAssetBuild = {
      affected: () => {
        calls.affected += 1;
        return Promise.resolve([bundle]);
      },
      dispose: () => {
        calls.dispose += 1;
        return Promise.resolve();
      },
      rebuild: () => {
        calls.rebuild += 1;
        return Promise.resolve(true);
      },
      restore: () => Promise.resolve(),
    };
    return {
      calls,
      deferred: deferStaticAssetBuild(() => {
        calls.build += 1;
        return Promise.resolve(build);
      }),
    };
  };

  test("does not build when nobody asks for a rebuild", async () => {
    const { calls, deferred } = fakeBuild();

    await deferred.dispose();

    expect(calls.build).toBe(0);
    expect(calls.dispose).toBe(0);
  });

  test("builds once, on the first question about the bundles", async () => {
    const { calls, deferred } = fakeBuild();

    expect(await deferred.affected("src/ui/client/admin.ts")).toEqual([bundle]);
    expect(await deferred.rebuild([bundle])).toBe(true);

    expect(calls.build).toBe(1);
    expect(calls.affected).toBe(1);
    expect(calls.rebuild).toBe(1);
  });

  test("passes a restore through to the real build", async () => {
    const { calls, deferred } = fakeBuild();

    await deferred.restore([bundle]);

    expect(calls.build).toBe(1);
  });

  test("disposes the real build once it has happened", async () => {
    const { calls, deferred } = fakeBuild();

    await deferred.rebuild([bundle]);
    await deferred.dispose();

    expect(calls.dispose).toBe(1);
  });
});