Ctrl K
ComunidadGuías…

test/scripts/static-asset-cache.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 {
  type CompletedStaticAssetBuild,
  lookUpTrackedFiles,
  manifestStillMatches,
  readStaticAssetManifest,
  type StaticAssetManifest,
  staticAssetsAreUpToDate,
  trackFile,
  writeStaticAssetManifest,
} from "#scripts/static-assets/cache.ts";

const recorded = (path: string, hash: string, mtime: number) => ({
  hash,
  mtime,
  path,
});

const manifest = (
  files: StaticAssetManifest["files"],
  outputs: string[],
): StaticAssetManifest => ({ files, outputs });

/** Look-up table standing in for the disk in the pure freshness check. */
const disk =
  (entries: StaticAssetManifest["files"]) =>
  (path: string): StaticAssetManifest["files"][number] | null =>
    entries.find((entry) => entry.path === path) ?? null;

/**
 * Fixed times, stamped onto the fixture files, so nothing here depends on when
 * the tests happen to run. A build that began a second after the source was
 * saved is one whose input had settled first; the asset carries a later time
 * because a build always writes its own output after it starts.
 */
const SOURCE_SAVED_AT = 1_700_000_000_000;
const BUILD_BEGAN_AT = SOURCE_SAVED_AT + 1_000;
const ASSET_WRITTEN_AT = BUILD_BEGAN_AT + 1_000;

/**
 * A throwaway directory holding a record, one source file and one built asset,
 * cleaned up afterwards. Both files are written before the body runs, unless
 * `leaveAssetMissing` asks for the build-produced-nothing case, and both are
 * given the fixed times above. `write` records a build over them, defaulting to
 * the ordinary one: a build that began after the source settled.
 */
const withBuildDir = async (
  body: (files: {
    asset: string;
    dir: string;
    record: string;
    source: string;
    write: (over?: Partial<CompletedStaticAssetBuild>) => Promise<void>;
  }) => Promise<void>,
  leaveAssetMissing = false,
): Promise<void> => {
  const dir = await Deno.makeTempDir();
  const files = {
    asset: join(dir, "built.js"),
    dir,
    record: join(dir, "record.json"),
    source: join(dir, "source.ts"),
  };
  try {
    await Deno.writeTextFile(files.source, "a");
    await Deno.utime(
      files.source,
      new Date(SOURCE_SAVED_AT),
      new Date(SOURCE_SAVED_AT),
    );
    if (!leaveAssetMissing) {
      await Deno.writeTextFile(files.asset, "bb");
      await Deno.utime(
        files.asset,
        new Date(ASSET_WRITTEN_AT),
        new Date(ASSET_WRITTEN_AT),
      );
    }
    await body({
      ...files,
      write: (over = {}) =>
        writeStaticAssetManifest(
          {
            inputs: [files.source],
            outputs: [files.asset],
            startedAt: BUILD_BEGAN_AT,
            ...over,
          },
          files.record,
        ),
    });
  } finally {
    await Deno.remove(dir, { recursive: true });
  }
};

describe("static asset cache", () => {
  describe("manifestStillMatches", () => {
    const input = recorded("/p/src/admin.ts", "aaa", 500);
    const output = recorded("/p/static/admin.js", "bbb", 600);
    const record = manifest([input, output], [output.path]);

    test("accepts a build whose every file is untouched", () => {
      expect(
        manifestStillMatches(record, [output.path], disk([input, output])),
      ).toBe(true);
    });

    test("rejects a build when a source file's contents changed", () => {
      expect(
        manifestStillMatches(
          record,
          [output.path],
          disk([recorded(input.path, "changed", 500), output]),
        ),
      ).toBe(false);
    });

    test("accepts a build when only a file's modified time moved", () => {
      // Re-saving the same bytes, or a checkout that rewrites timestamps, is
      // not a reason to rebuild — the assets would come out identical.
      expect(
        manifestStillMatches(
          record,
          [output.path],
          disk([recorded(input.path, input.hash, 999), output]),
        ),
      ).toBe(true);
    });

    test("rejects a build when an output file is gone", () => {
      expect(manifestStillMatches(record, [output.path], disk([input]))).toBe(
        false,
      );
    });

    test("rejects a build that produced a different set of assets", () => {
      expect(
        manifestStillMatches(
          record,
          [output.path, "/p/static/extra.js"],
          disk([input, output]),
        ),
      ).toBe(false);
    });

    test("rejects a build whose assets were renamed", () => {
      expect(
        manifestStillMatches(
          record,
          ["/p/static/other.js"],
          disk([input, output]),
        ),
      ).toBe(false);
    });
  });

  describe("trackFile", () => {
    test("reports the content hash and modified time of a real file", async () => {
      const dir = await Deno.makeTempDir();
      try {
        const path = join(dir, "asset.js");
        await Deno.writeTextFile(path, "hello");
        const info = await Deno.stat(path);

        // SHA-256 of "hello".
        expect(await trackFile(path)).toEqual({
          hash: "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
          mtime: info.mtime?.getTime() ?? 0,
          path,
        });
      } finally {
        await Deno.remove(dir, { recursive: true });
      }
    });

    test("reports null for a file that is not there", async () => {
      const dir = await Deno.makeTempDir();
      try {
        expect(await trackFile(join(dir, "missing.js"))).toBe(null);
      } finally {
        await Deno.remove(dir, { recursive: true });
      }
    });
  });

  describe("lookUpTrackedFiles", () => {
    test("pairs every recorded path with how it looks now", async () => {
      const dir = await Deno.makeTempDir();
      try {
        const present = join(dir, "present.js");
        const gone = join(dir, "gone.js");
        await Deno.writeTextFile(present, "abc");

        const look = await lookUpTrackedFiles(
          manifest(
            [recorded(present, "abc", 1), recorded(gone, "gone", 1)],
            [present],
          ),
        );

        expect(look(present)?.hash).toMatch(/^[0-9a-f]{64}$/);
        expect(look(gone)).toBe(null);
        expect(look(join(dir, "never-recorded.js"))).toBe(null);
      } finally {
        await Deno.remove(dir, { recursive: true });
      }
    });
  });

  describe("reading and writing the record", () => {
    test("writes each file once, in a settled order", async () => {
      await withBuildDir(async ({ asset, record, source, write }) => {
        await write({ inputs: [source, source] });
        const written = await readStaticAssetManifest(record);

        expect(written?.files.map((file) => file.path)).toEqual(
          [asset, source].sort(),
        );
        expect(
          written?.files.every((file) => /^[0-9a-f]{64}$/.test(file.hash)),
        ).toBe(true);
        expect(written?.outputs).toEqual([asset]);
      });
    });

    test("reports no record when the file is not there", async () => {
      await withBuildDir(async ({ record }) => {
        expect(await readStaticAssetManifest(record)).toBe(null);
      });
    });

    test("refuses to record a build that left an asset missing", async () => {
      await withBuildDir(async ({ asset, record, write }) => {
        await expect(write()).rejects.toThrow(
          `did not leave every asset on disk: ${asset}`,
        );
        expect(await readStaticAssetManifest(record)).toBe(null);
      }, true);
    });

    test("keeps the previous record when a write is interrupted", async () => {
      await withBuildDir(async ({ asset, dir, record, write }) => {
        await write();

        await expect(
          write({ outputs: [join(dir, "gone.js")] }),
        ).rejects.toThrow("did not leave every asset on disk");

        expect((await readStaticAssetManifest(record))?.outputs).toEqual([
          asset,
        ]);
      });
    });

    // One rule at the two timings that matter. A build only owns assets it
    // wrote itself — the fixture's asset is stamped later than the build began,
    // and is still fine — while a source that moved after it started reading is
    // one it cannot vouch for, so nothing is recorded and the next run rebuilds.
    const timings = [
      {
        name: "records nothing when a source was saved while the build ran",
        recorded: false,
        startedAt: SOURCE_SAVED_AT - 60_000,
      },
      {
        name: "still records when only the assets were written during the build",
        recorded: true,
        startedAt: BUILD_BEGAN_AT,
      },
    ];

    for (const timing of timings) {
      test(timing.name, async () => {
        await withBuildDir(async ({ asset, record, write }) => {
          await write({ startedAt: timing.startedAt });

          expect(
            (await readStaticAssetManifest(record))?.outputs ?? null,
          ).toEqual(timing.recorded ? [asset] : null);
        });
      });
    }

    test("records nothing when a source vanished under the build", async () => {
      await withBuildDir(async ({ dir, record, write }) => {
        // The build read it, then it was renamed or deleted before we looked.
        await write({ inputs: [join(dir, "deleted-since.ts")] });

        expect(await readStaticAssetManifest(record)).toBe(null);
      });
    });

    test("leaves no working file behind when two runs record at once", async () => {
      await withBuildDir(async ({ asset, dir, record, write }) => {
        await Promise.all([write(), write(), write()]);

        expect((await readStaticAssetManifest(record))?.outputs).toEqual([
          asset,
        ]);
        const left = [];
        for await (const entry of Deno.readDir(dir)) left.push(entry.name);
        expect(left.filter((name) => name.endsWith(".pending"))).toEqual([]);
      });
    });

    test("ignores a record left half-written", async () => {
      await withBuildDir(async ({ record }) => {
        await Deno.writeTextFile(record, '{"files":[{"path":"a.js"');
        expect(await readStaticAssetManifest(record)).toBe(null);
      });
    });

    test("ignores a record whose shape it does not recognise", async () => {
      await withBuildDir(async ({ record }) => {
        await Deno.writeTextFile(
          record,
          JSON.stringify({ files: [{ path: "a.js" }], outputs: [] }),
        );
        expect(await readStaticAssetManifest(record)).toBe(null);
      });
    });
  });

  describe("staticAssetsAreUpToDate", () => {
    test("says no when this working copy has no record of a build", async () => {
      await withBuildDir(async ({ record }) => {
        expect(await staticAssetsAreUpToDate(record)).toBe(false);
      });
    });

    test("says no when the record is for a different set of assets", async () => {
      await withBuildDir(async ({ record, write }) => {
        await write();

        expect(await staticAssetsAreUpToDate(record)).toBe(false);
      });
    });
  });
});