Repository: denoland/deno
Pull request: ci: skip wpt and autobahn tests when able

Similar to https://github.com/denoland/deno/pull/32214

Changed files:
filename: ext/websocket/autobahn/fuzzingclient.js
patch: @@ -3,14 +3,23 @@
 // deno-lint-ignore-file
 
 import { $ } from "https://deno.land/x/dax@0.31.0/mod.ts";
+import { dirname, shouldSkipOnCi } from "../../../tools/util.js";
 
 $.setPrintCommand(true);
 const pwd = new URL(".", import.meta.url).pathname;
 
+const self = Deno.execPath();
+if (
+  await shouldSkipOnCi("autobahn", dirname(self), async (hasher) => {
+    await hasher.hashFile(self);
+    await hasher.hashDir(pwd);
+  })
+) {
+  Deno.exit(0);
+}
+
 const AUTOBAHN_TESTSUITE_DOCKER =
   "crossbario/autobahn-testsuite:25.10.1@sha256:519915fb568b04c9383f70a1c405ae3ff44ab9e35835b085239c258b6fac3074";
-
-const self = Deno.execPath();
 $`${self} run -A --config ${pwd}/../../../tests/config/deno.json ${pwd}/autobahn_server.js`
   .spawn();
 

filename: tests/wpt/runner/utils.ts
patch: @@ -2,7 +2,12 @@
 /// FLAGS
 
 import { parseArgs } from "@std/cli/parse-args";
-import { join, resolve, ROOT_PATH } from "../../../tools/util.js";
+import {
+  join,
+  resolve,
+  ROOT_PATH,
+  shouldSkipOnCi as shouldSkipOnCiGeneric,
+} from "../../../tools/util.js";
 
 export const {
   json,
@@ -25,7 +30,33 @@ export function denoBinary() {
   if (binary) {
     return resolve(binary);
   }
-  return join(ROOT_PATH, `./target/${release ? "release" : "debug"}/deno`);
+  const ext = Deno.build.os === "windows" ? ".exe" : "";
+  return join(
+    ROOT_PATH,
+    `./target/${release ? "release" : "debug"}/deno${ext}`,
+  );
+}
+
+/// CI SKIP CACHE
+
+async function getWptSubmoduleHash(): Promise<string> {
+  const gitResult = await new Deno.Command("git", {
+    args: ["rev-parse", "HEAD"],
+    cwd: join(ROOT_PATH, "tests", "wpt", "suite"),
+    stdout: "piped",
+    stderr: "piped",
+  }).output();
+  return new TextDecoder().decode(gitResult.stdout).trim();
+}
+
+export function shouldSkipOnCi(): Promise<boolean> {
+  const targetDir = join(ROOT_PATH, "target", release ? "release" : "debug");
+  return shouldSkipOnCiGeneric("wpt", targetDir, async (hasher) => {
+    const hashDenoBinaryTask = hasher.hashFile(denoBinary());
+    const submoduleHash = await getWptSubmoduleHash();
+    await hashDenoBinaryTask;
+    hasher.writeSync(submoduleHash);
+  });
 }
 
 /// WPT TEST MANIFEST

filename: tests/wpt/wpt.ts
patch: @@ -33,6 +33,7 @@ import {
   rest,
   runGitDiff,
   runPy,
+  shouldSkipOnCi,
   updateManifest,
   wptreport,
 } from "./runner/utils.ts";
@@ -96,6 +97,7 @@ switch (command) {
   case "run":
     await checkPy3Available();
     await cargoBuild();
+    if (await shouldSkipOnCi()) break;
     await run();
     break;
 

filename: tools/util.js
patch: @@ -16,6 +16,7 @@ export { existsSync, expandGlobSync, walk } from "@std/fs";
 export { TextLineStream } from "@std/streams/text-line-stream";
 export { delay } from "@std/async/delay";
 export { parse as parseJSONC } from "@std/jsonc/parse";
+import { createHash } from "node:crypto";
 
 // [toolName] --version output
 const versions = {
@@ -275,3 +276,136 @@ export async function verifyVersion(toolName, toolPath) {
     return false;
   }
 }
+
+/// INPUT HASHING
+
+/** A streaming hasher for computing a combined hash of multiple inputs.
+ * Mirrors the Rust InputHasher API in tests/util/lib/hash.rs. */
+export class InputHasher {
+  #hash;
+
+  constructor() {
+    this.#hash = createHash("sha256");
+  }
+
+  /** Create a hasher pre-seeded with the current CLI args. */
+  static newWithCliArgs() {
+    const hasher = new InputHasher();
+    for (const arg of Deno.args) {
+      hasher.writeSync(arg);
+    }
+    return hasher;
+  }
+
+  /** Write raw string data into the hash. */
+  writeSync(data) {
+    this.#hash.update(data);
+  }
+
+  /** Hash a single file's contents (streamed). Skips if file doesn't exist. */
+  async hashFile(path) {
+    try {
+      const file = await Deno.open(path);
+      for await (const chunk of file.readable) {
+        this.#hash.update(chunk);
+      }
+    } catch {
+      // skip if file doesn't exist
+    }
+    return this;
+  }
+
+  /** Recursively hash all file contents in a directory (sorted for
+   * determinism). Skips if directory doesn't exist. */
+  async hashDir(path) {
+    const entries = [];
+    collectEntriesRecursive(path, entries);
+    entries.sort();
+    for (const entryPath of entries) {
+      // hash the relative path for determinism
+      if (entryPath.startsWith(path)) {
+        this.writeSync(entryPath.slice(path.length));
+      }
+      try {
+        const file = await Deno.open(entryPath);
+        for await (const chunk of file.readable) {
+          this.#hash.update(chunk);
+        }
+      } catch {
+        // skip unreadable files
+      }
+    }
+    return this;
+  }
+
+  /** Finalize the hash and return a hex string. */
+  finish() {
+    return this.#hash.digest("hex");
+  }
+}
+
+function collectEntriesRecursive(dir, out) {
+  let entries;
+  try {
+    entries = Deno.readDirSync(dir);
+  } catch {
+    return;
+  }
+  for (const entry of entries) {
+    const fullPath = join(dir, entry.name);
+    if (entry.isDirectory) {
+      collectEntriesRecursive(fullPath, out);
+    } else {
+      out.push(fullPath);
+    }
+  }
+}
+
+/**
+ * Check if tests can be skipped on CI by comparing input hashes.
+ *
+ * `name` is used for the hash file name and log messages (e.g. "wpt").
+ * `targetDir` is where the hash file is stored (e.g. target/debug).
+ * `configure` receives an InputHasher to add whatever files/dirs are relevant.
+ *
+ * Returns true if the hash is unchanged and tests should be skipped.
+ */
+export async function shouldSkipOnCi(name, targetDir, configure) {
+  if (!Deno.env.get("CI")) {
+    return false;
+  }
+
+  const start = performance.now();
+  const hashPath = join(targetDir, `${name}_input_hash`);
+
+  const hasher = InputHasher.newWithCliArgs();
+  await configure(hasher);
+  const newHash = await hasher.finish();
+
+  const elapsed = Math.round(performance.now() - start);
+  console.log(`ci hash took ${elapsed}ms`);
+
+  let oldHash;
+  try {
+    oldHash = (await Deno.readTextFile(hashPath)).trim();
+  } catch {
+    // file doesn't exist yet
+  }
+
+  if (oldHash === newHash) {
+    console.log(`${name} input hash unchanged (${newHash}), skipping`);
+    return true;
+  }
+
+  console.log(
+    `${name} input hash changed from ${
+      oldHash ?? "none"
+    }, writing new hash (${newHash})`,
+  );
+  try {
+    await Deno.writeTextFile(hashPath, newHash);
+  } catch {
+    // ignore write errors
+  }
+  return false;
+}