From 1714415dda8e3621f21a9cd8c890c64520f90de5 Mon Sep 17 00:00:00 2001 From: Mikael Knutsson Date: Tue, 6 Oct 2026 11:05:44 +0000 Subject: [PATCH] fix(vitest): stage a directory output's files from the test's runfiles Where runfiles are materialized as files, as under remote execution, a realpath taken inside ts_test's private tree lands in the launcher's runfiles tree. The launcher now exports that directory as TS_TEST_RUNFILES_ORIGIN; runfilesPath maps paths under it back onto the private tree, and Vite's fs.allow includes it. A materialized-runfiles test reproduces the failure without the fix. The integration harness's nested Bazel sets --remote_download_outputs=all: its --disk_cache counts as a remote cache, so once CI's shared disk cache is warm a cache hit left outputs the tests read back unmaterialized. Co-Authored-By: Claude Opus 5.5 --- tests/codegen_tree/BUILD.bazel | 25 +++- tests/codegen_tree/assets.d.ts | 4 + .../materialized_runfiles_test.go | 113 ++++++++++++++++++ tests/codegen_tree/tree_import.test.ts | 9 ++ tests/integration/harness/harness.go | 4 +- tools/launcher/vitest.go | 6 + ts/private/actions/vitest.bzl | 7 +- 7 files changed, 165 insertions(+), 3 deletions(-) create mode 100644 tests/codegen_tree/assets.d.ts create mode 100644 tests/codegen_tree/materialized_runfiles_test.go create mode 100644 tests/codegen_tree/tree_import.test.ts diff --git a/tests/codegen_tree/BUILD.bazel b/tests/codegen_tree/BUILD.bazel index ef96a56b..328c42ba 100644 --- a/tests/codegen_tree/BUILD.bazel +++ b/tests/codegen_tree/BUILD.bazel @@ -8,7 +8,7 @@ time and a directory has no file list to declare from. load("@io_bazel_rules_go//go:def.bzl", "go_test") load("@rules_shell//shell:sh_binary.bzl", "sh_binary") -load("//ts:defs.bzl", "ts_codegen", "ts_compile", "ts_dev_server") +load("//ts:defs.bzl", "ts_codegen", "ts_compile", "ts_dev_server", "ts_test") load(":tree_tests.bzl", "dev_server_declared_tree_test", "tree_reaches_compile_test") package(default_visibility = ["//:__pkg__"]) @@ -155,3 +155,26 @@ dev_server_declared_tree_test( target_under_test = ":dev", tree = ":tree", ) + +# keep +ts_test( + name = "tree_import_test", + srcs = [ + "assets.d.ts", + "tree_import.test.ts", + ], + node_modules = "//tests/npm:node_modules", + tsconfig = "tsconfig.tree.json", + deps = [ + ":tree", + "@npm//:vitest", + ], +) + +go_test( + name = "materialized_runfiles_test", + size = "medium", + srcs = ["materialized_runfiles_test.go"], + data = [":tree_import_test"], + deps = ["//tests/verify"], +) diff --git a/tests/codegen_tree/assets.d.ts b/tests/codegen_tree/assets.d.ts new file mode 100644 index 00000000..c0ce482d --- /dev/null +++ b/tests/codegen_tree/assets.d.ts @@ -0,0 +1,4 @@ +declare module "*?url" { + const url: string; + export default url; +} diff --git a/tests/codegen_tree/materialized_runfiles_test.go b/tests/codegen_tree/materialized_runfiles_test.go new file mode 100644 index 00000000..0283d85d --- /dev/null +++ b/tests/codegen_tree/materialized_runfiles_test.go @@ -0,0 +1,113 @@ +package codegen_tree_test + +import ( + "errors" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/mikn/rules_typescript/tests/verify" +) + +// Remote execution materializes runfiles as files, so a realpath taken inside the +// test's private tree lands in the launcher's runfiles tree under bazel-out//bin. +func TestTreeImportRunsFromMaterializedRunfiles(t *testing.T) { + launcher := verify.New(t).File("tests/codegen_tree/tree_import_test_test_launcher") + if !launcher.Exists() { + t.FailNow() + } + source := os.Getenv("RUNFILES_DIR") + if source == "" { + t.Skip("needs a runfiles directory to materialize") + } + runfiles := filepath.Join(t.TempDir(), "bazel-out", "k8-fastbuild", "bin", "tree_import_test.runfiles") + if err := materialize(source, runfiles); err != nil { + t.Fatal(err) + } + + cmd := exec.Command(launcher.Abs()) + cmd.Env = append(withoutRunfilesEnv(os.Environ()), + "RUNFILES_DIR="+runfiles, "TEST_SRCDIR="+runfiles, "TEST_TMPDIR="+t.TempDir()) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("tree_import_test failed from materialized runfiles: %v\n%s", err, out) + } +} + +func withoutRunfilesEnv(env []string) []string { + kept := env[:0:0] + for _, kv := range env { + name, _, _ := strings.Cut(kv, "=") + switch name { + case "RUNFILES_DIR", "RUNFILES_MANIFEST_FILE", "TEST_SRCDIR", "TEST_TMPDIR", "JAVA_RUNFILES", + "XML_OUTPUT_FILE", "TEST_SHARD_INDEX", "TEST_TOTAL_SHARDS", "TEST_SHARD_STATUS_FILE": + continue + } + kept = append(kept, kv) + } + return kept +} + +// materialize recreates src at dst as remote execution stages it: a relative symlink, such as +// pnpm's, stays a link; every other entry becomes a hard link or copy; dangling links are dropped. +func materialize(src, dst string) error { + return filepath.WalkDir(src, func(path string, entry fs.DirEntry, err error) error { + if errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil { + return err + } + relative, err := filepath.Rel(src, path) + if err != nil { + return err + } + target := filepath.Join(dst, relative) + if entry.Type()&fs.ModeSymlink != 0 { + if link, err := os.Readlink(path); err == nil && !filepath.IsAbs(link) { + return os.Symlink(link, target) + } + } + if entry.IsDir() { + return os.MkdirAll(target, 0o755) + } + real, err := filepath.EvalSymlinks(path) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil { + return err + } + info, err := os.Stat(real) + if err != nil { + return err + } + if info.IsDir() { + return materialize(real, target) + } + if err := os.Link(real, target); err == nil { + return nil + } + return copyFile(real, target, info.Mode()) + }) +} + +func copyFile(src, dst string, mode fs.FileMode) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + out.Close() + return err + } + return out.Close() +} diff --git a/tests/codegen_tree/tree_import.test.ts b/tests/codegen_tree/tree_import.test.ts new file mode 100644 index 00000000..eefca43d --- /dev/null +++ b/tests/codegen_tree/tree_import.test.ts @@ -0,0 +1,9 @@ +import vitestManifest from "vitest/package.json?url"; +import { expect, test } from "vitest"; + +import { greeting } from "#codegen/tree/messages/greeting"; + +test("a file inside a directory output loads by extensionless import, and an npm asset loads by URL", () => { + expect(greeting(2)).toBe("greeting: 2"); + expect(vitestManifest).toMatch(/package\.json$/); +}); diff --git a/tests/integration/harness/harness.go b/tests/integration/harness/harness.go index 0924d6d7..11783349 100644 --- a/tests/integration/harness/harness.go +++ b/tests/integration/harness/harness.go @@ -194,8 +194,10 @@ func (it *IT) shareRepositoryCache() error { return err } } + // A disk cache is a remote cache to Bazel, so without this a cache hit leaves outputs the tests read back unmaterialized. lines := "common --repository_cache=" + repo + "\n" + - "common --disk_cache=" + disk + "\n" + "common --disk_cache=" + disk + "\n" + + "common --remote_download_outputs=all\n" if it.staged == "" { // The checkout keeps its .bazelrc and the outer build's bazel-* links. it.bazelrc = filepath.Join(it.scratchDir, "bazelrc") diff --git a/tools/launcher/vitest.go b/tools/launcher/vitest.go index a6678fb1..a5b84148 100644 --- a/tools/launcher/vitest.go +++ b/tools/launcher/vitest.go @@ -64,6 +64,12 @@ func planVitest( if err := plan.own(tree, true); err != nil { return nil, err } + // The private tree links into these runfiles, so a realpath can land in them. + if dir := r.Dir(); dir != "" { + if origin, err := filepath.EvalSymlinks(dir); err == nil { + plan.setEnv("TS_TEST_RUNFILES_ORIGIN", origin) + } + } if r, err = r.Stage(tree, cfg.RuntimeModules); err != nil { return nil, err } diff --git a/ts/private/actions/vitest.bzl b/ts/private/actions/vitest.bzl index 57d25193..f4e648e5 100644 --- a/ts/private/actions/vitest.bzl +++ b/ts/private/actions/vitest.bzl @@ -266,9 +266,14 @@ const BIN_DIR = (() => { } catch {} return null; })(); -const FS_ALLOW = BIN_DIR ? [WORKSPACE_DIR, BIN_DIR] : [WORKSPACE_DIR]; +// The runfiles the private tree links into; where they are files, as under remote execution, a realpath lands here. +const RUNFILES_ORIGIN = process.env.TS_TEST_RUNFILES_ORIGIN || null; +const FS_ALLOW = [WORKSPACE_DIR, BIN_DIR, RUNFILES_ORIGIN].filter(Boolean); const runfilesPath = (file) => { if (isAbsolute(file) && existsSync(file)) file = realpathSync(file); + if (RUNFILES_ORIGIN && file.startsWith(RUNFILES_ORIGIN + '/')) { + return resolve(RUNFILES_ROOT, file.slice(RUNFILES_ORIGIN.length + 1)); + } const out = /^.*?\\/bazel-out\\/[^/]+\\/bin\\/(.*)$/.exec(file); if (out) { const ext = /^external\\/([^/]+)\\/(.*)$/.exec(out[1]);