Skip to content

Commit 02d570b

Browse files
samchonclaude
andauthored
fix(ttsc): make TS config loaders and the virtual layout Windows-safe (#308)
* fix(lint,banner,strip): make TS config loaders volume-aware on Windows The lint fix from #300 left two byte-identical siblings hardcoding the loader tsconfig's rootDir to "/", which is never an ancestor of drive-letter paths (TS6059, #304). Extract the volume-root resolution into a named loaderRootDir helper (also restoring gofmt-stable formatting in linthost) and apply it to the banner and strip drivers. Rooting at the volume is still not enough when the system temp dir and the config file live on different drives: no single rootDir spans two volumes and relative import specifiers cannot cross them (#305). Create the ephemeral loader tree under the config's nearest node_modules/.cache in that case — on all three Go drivers and the lint JS factory — keeping the system temp dir whenever the volumes already match. Also normalize separators in the runtime hooks' isWithin so the slash-form manifest rootDir the loader tsconfig now emits (C:/) matches native real paths, and align the ttsx e2e fixture that mirrored the loader's rootDir synthesis. Resolves #304, resolves #305. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ttsc): copy file-symlink virtual entries when symlinks need privilege linkVirtualEntry handled directories, plain files, and directory-target symlinks in a Windows-safe way, but its final branch re-symlinked file symlinks with a bare fs.symlinkSync — which needs SeCreateSymbolicLinkPrivilege on Windows and crashed the run with EPERM without admin rights or Developer Mode (#306, same class as #218). Mirror the plain-file branch's hard-link/copy fallback, and skip a link whose target no longer exists: it can serve no module and none of the fallbacks can materialize it. Resolves #306. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(lint,banner,strip,ttsc): close the loader-volume and symlink edges Driving the whole loader chain end-to-end on a two-volume Windows machine surfaced three gaps in the previous commit: - A linked node_modules (junction/symlink) broke the loader: the ESM runtime realpaths the loader module at import time, so a relative config specifier computed from the link-form .cache path resolved against the wrong directory. The temp base is realpathed now; NTFS junctions defeat filepath.EvalSymlinks, so the Go drivers chase the link component with os.Readlink first. - Cross-volume configs with no usable node_modules/.cache fell back to the system temp dir — guaranteed to fail. They now fall back to the config's own directory, which is always on the right volume and keeps the relative import a plain "./". - isWithin now compares case-insensitively on Windows (a lowercase TEMP yields a c:-rooted manifest rootDir for the volume real paths spell C:), and is exported alongside linkVirtualEntry so the e2e suite can pin the Windows normalization and EPERM-fallback branches that a spawned run cannot reach on CI. New coverage: the temp-base tests pin the blocked-.cache, missing node_modules, and junction realpath branches; api e2e tests drive linkVirtualEntry's copy fallback and dangling-entry skip (the dangling fixture is a junction, so it exercises the real privilege-less EPERM path on Windows) and isWithin's root/slash/case matching. Verified on Windows 11 with the project on D: and TEMP on C:, including live cross-drive config evaluation through real ttsx + tsgo on both the JS factory and the Go driver paths. Refs #304, #305, #306. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci(test): add a Windows Go-suite lane and make it green Add a windows-latest matrix entry running `pnpm run test:go` as the minimal Windows safety net — the Go unit suites cover the platform-sensitive layers (config loaders, path handling) without the POSIX-only e2e runner, and only need the ttsx launcher build. Everything that kept that lane red gets fixed with it: - scripts/test-go-utility-plugins.cjs wrote forward-slash `use` paths into its go.work; Go's workspace-module matching on Windows rejects them ("directory ... is not one of the workspace modules"), so every suite failed at setup. Write native separators. - packages/ttsc's build script copied README with unix `cp`, which cmd has no notion of; use a node one-liner. - The banner suites faked launchers as extensionless `#!/bin/sh` scripts, which Windows cannot spawn. A new writeDirectLauncher helper emits the sh script on POSIX and an equivalent `.cmd` on Windows, keeping both off the node-routed extension list. - The tempdir-failure case pointed TMPDIR at a file; os.TempDir reads TMP/TEMP on Windows, so override those there. - Two assertions were unhermetic on machines with a stray drive-root node_modules; they now tolerate discoveries outside the fixture. The relative-config-path case surfaced a real regression in the new loaderTempBase helpers: a volume-less location compared unequal to the system temp's volume and was misrouted to the cross-volume branch, breaking the Rel-failure contract (and creating loader dirs relative to the cwd). All four helpers now keep the historical default for volume-less locations, with test scenarios pinning it. Refs #304, #305. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: drop a leaked paths test fixture from the tree A locally-run paths Go test materializes its external-file fixture inside the package directory and leaves it behind; the previous commit swept it up unintentionally. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(paths,ci): unblock the Windows Go lane on split-volume runners Two problems surfaced running the paths suite on a machine whose repo and temp dir live on different drives (the windows-latest layout): - TestCommandRewritesAllModuleSpecifierForms materialized its external files-list fixture inside the package directory. With the project in the system temp dir that makes the tsconfig `files` list span two volumes, and project loading hangs until the go test timeout (#310 tracks the sidecar behavior; tsc errors fast on the same input). The fixture moves to a sibling temp dir — still outside the tsconfig directory, same volume, and it can no longer leak into the tree when a test dies mid-run. - The utility-plugin runner now prewarms the plugin/driver build before `go test`: the command tests `go run ./plugin` during test execution, so on a cold CI cache the full typescript-go compile counted against the 10-minute test timeout. With these, all three utility suites pass on Windows for the first time (banner 16s, paths 20s, strip 27s locally). Refs #308, #310. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: drop leaked paths fixtures again Residue from the two timed-out suite runs predating the fixture relocation; the previous commit swept them up. The relocation itself prevents any further leaks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: normalize the cross-volume temp-base expectation like the helper CI Windows runners hand out an 8.3 short-name TEMP (RUNNER~1), so the helper's EvalSymlinks-normalized result never compared equal to a raw Join of the fixture root even though both name the same directory. Normalize the expectation the same way. Refs #308. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: narrow the Dirent fixture lookup before use assert.ok on `entry?.isSymbolicLink()` narrows that expression, not `entry`, so the `Dirent | undefined` from Array.find reached linkVirtualEntry and failed typecheck. Assert existence first. Refs #308. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: front the stderr redirect in the Windows fake launcher A trailing `>&2` glued to a payload ending in a digit would turn that digit into a file-descriptor redirect; putting the redirect first sidesteps both that and the trailing-space form. Also document the cmd-metacharacter constraint on payloads. Refs #308. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 1ab7956 commit 02d570b

28 files changed

Lines changed: 1131 additions & 55 deletions

.github/workflows/test.yml

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,18 @@ concurrency:
2020
jobs:
2121
test:
2222
name: ${{ matrix.name }}
23-
runs-on: ubuntu-latest
23+
runs-on: ${{ matrix.os || 'ubuntu-latest' }}
2424
strategy:
2525
fail-fast: false
2626
matrix:
2727
include:
2828
- { name: go, run: "pnpm run test:go && pnpm --filter ttsc go:vet" }
29+
# Minimal Windows safety net: the Go unit suites cover the
30+
# platform-sensitive layers (config loaders, path handling) without
31+
# the e2e runner, which is POSIX-only. Only the ttsx launcher build
32+
# is needed (test-go-lint drives real ttsx), so the full
33+
# build:current is skipped.
34+
- { name: windows-go, os: windows-latest, build: "pnpm --filter ttsc build", run: "pnpm run test:go" }
2935
- { name: shim-audit, run: "pnpm --filter ttsc shim:audit:test && pnpm --filter ttsc shim:audit" }
3036
- { name: typecheck, run: "pnpm run check:flags && pnpm run test:typecheck" }
3137
- { name: banner, run: "pnpm --filter @ttsc/test-banner start" }
@@ -61,10 +67,11 @@ jobs:
6167
run: pnpm install --frozen-lockfile
6268

6369
- name: Use System Go For Source Plugin Tests
70+
shell: bash
6471
run: echo "TTSC_GO_BINARY=$(go env GOROOT)/bin/go" >> "$GITHUB_ENV"
6572

6673
- name: Build Current Platform
67-
run: pnpm run build:current
74+
run: ${{ matrix.build || 'pnpm run build:current' }}
6875

6976
- name: Run ${{ matrix.name }}
7077
run: ${{ matrix.run }}

packages/banner/driver/banner.go

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,7 @@ function toSerializableBanner(value) {
364364
// build runs with `--no-plugins` so evaluating the config never triggers the
365365
// host project's transform/check plugins against the loader tsconfig.
366366
func loadBannerTypeScriptConfigFile(location string) (any, error) {
367-
tempDir, err := os.MkdirTemp("", "ttsc-banner-config-")
367+
tempDir, err := os.MkdirTemp(loaderTempBase(location, os.TempDir()), "ttsc-banner-config-")
368368
if err != nil {
369369
return nil, fmt.Errorf("@ttsc/banner: create config loader tempdir: %w", err)
370370
}
@@ -507,7 +507,7 @@ func typeScriptConfigLoaderTsconfig(loader, location, outDir string) string {
507507
"moduleResolution": "bundler",
508508
"outDir": filepath.ToSlash(filepath.Join(outDir, "out")),
509509
"rewriteRelativeImportExtensions": true,
510-
"rootDir": "/",
510+
"rootDir": loaderRootDir(outDir),
511511
"skipLibCheck": true,
512512
"strict": true,
513513
"target": "ES2022",
@@ -521,6 +521,78 @@ func typeScriptConfigLoaderTsconfig(loader, location, outDir string) string {
521521
return string(body)
522522
}
523523

524+
// loaderRootDir returns the widest rootDir that still contains the loader
525+
// tsconfig's inputs: the volume root of the loader temp dir (`C:/` on
526+
// Windows, `/` elsewhere). A literal "/" is not an ancestor of drive-letter
527+
// paths, so tsgo rejects every input with TS6059 (#299, #304). The temp dir
528+
// is created on the same volume as the config file (see loaderTempBase), so
529+
// its volume root spans both `files` entries.
530+
func loaderRootDir(outDir string) string {
531+
vol := filepath.VolumeName(outDir)
532+
if vol == "" {
533+
return "/"
534+
}
535+
return filepath.ToSlash(vol + `\`)
536+
}
537+
538+
// loaderTempBase picks the parent directory for the ephemeral config-loader
539+
// tree. The system temp dir is the default, but when it sits on a different
540+
// volume than the config file (Windows: TEMP on `C:`, project on `D:`) the
541+
// loader cannot work from there — no single tsconfig rootDir spans two
542+
// volumes and filepath.Rel cannot produce a relative import across drives
543+
// (#305) — so the tree is created under the config's nearest
544+
// node_modules/.cache instead, falling back to the config's own directory
545+
// when no node_modules exists (or its .cache cannot be created): any location
546+
// on the config's volume beats the system temp dir, which is guaranteed to
547+
// fail. Returns "" (the os.MkdirTemp default) when the volumes already match.
548+
func loaderTempBase(location, systemTemp string) string {
549+
// A relative location has no volume; "" must not be read as "a volume
550+
// other than the system temp's" — it keeps the historical default (and
551+
// the Rel-failure contract for relative config paths).
552+
vol := filepath.VolumeName(location)
553+
if vol == "" || strings.EqualFold(filepath.VolumeName(systemTemp), vol) {
554+
return ""
555+
}
556+
nodeModules := findNearestNodeModules(filepath.Dir(location))
557+
if nodeModules == "" {
558+
return filepath.Dir(location)
559+
}
560+
// Resolve a linked node_modules (junction/symlink — common in managed
561+
// setups) before descending into it: the ESM runtime realpaths the loader
562+
// module at import time, and a relative config specifier computed from the
563+
// link-form path would resolve against the wrong directory. NTFS junctions
564+
// defeat filepath.EvalSymlinks, so the link component is chased by hand
565+
// first. Realpathing may also land on another volume, which defeats the
566+
// whole point — fall back to the config's directory then.
567+
base := filepath.Join(resolveDirLink(nodeModules), ".cache")
568+
if err := os.MkdirAll(base, 0o755); err != nil {
569+
return filepath.Dir(location)
570+
}
571+
real, err := filepath.EvalSymlinks(base)
572+
if err != nil || !strings.EqualFold(filepath.VolumeName(real), filepath.VolumeName(location)) {
573+
return filepath.Dir(location)
574+
}
575+
return real
576+
}
577+
578+
// resolveDirLink chases a directory that is itself a symlink or NTFS junction
579+
// to its target (bounded against link cycles). os.Readlink is the probe:
580+
// it resolves junctions, which report neither ModeSymlink nor an
581+
// EvalSymlinks-traversable path.
582+
func resolveDirLink(dir string) string {
583+
for i := 0; i < 8; i++ {
584+
target, err := os.Readlink(dir)
585+
if err != nil {
586+
return dir
587+
}
588+
if !filepath.IsAbs(target) {
589+
target = filepath.Join(filepath.Dir(dir), target)
590+
}
591+
dir = target
592+
}
593+
return dir
594+
}
595+
524596
// ttsxCommand builds an exec.Cmd that runs ttsx with the given args.
525597
// When TTSC_TTSX_BINARY has a script extension (.js, .ts, …) the binary is
526598
// invoked via the Node runtime so it is executed correctly on all platforms.
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package banner_test
2+
3+
import (
4+
"os"
5+
"os/exec"
6+
"path/filepath"
7+
"strings"
8+
"testing"
9+
)
10+
11+
// TestConfigLoaderTempBaseStaysOnConfigVolume verifies the config-loader
12+
// temp-dir base follows the config file's volume.
13+
//
14+
// When the system temp dir shares the config's volume, the historical default
15+
// ("" — os.MkdirTemp's system temp) must be kept. When it does not
16+
// (drive-letter platforms: TEMP on C:, project on D:), the loader must move
17+
// under the config's nearest node_modules/.cache — a cross-volume loader can
18+
// neither express a tsconfig rootDir spanning both inputs nor a relative
19+
// import of the config (#305).
20+
//
21+
// 1. Materialize a config next to a node_modules directory.
22+
// 2. Same-volume systemTemp → expect the "" default.
23+
// 3. Fake a systemTemp on another volume (drive-letter platforms only) →
24+
// expect node_modules/.cache on the config's volume, created on disk.
25+
// 4. Block node_modules/.cache with a file → expect the config's directory.
26+
// 5. No node_modules at all → expect the config's directory.
27+
// 6. A junction node_modules → expect the realpath of its .cache (the ESM
28+
// runtime realpaths the loader at import time, so a link-form base breaks
29+
// the relative config import).
30+
func TestConfigLoaderTempBaseStaysOnConfigVolume(t *testing.T) {
31+
root := t.TempDir()
32+
if err := os.MkdirAll(filepath.Join(root, "node_modules"), 0o755); err != nil {
33+
t.Fatal(err)
34+
}
35+
config := filepath.Join(root, "banner.config.ts")
36+
if base := bannerLoaderTempBase(config, root); base != "" {
37+
t.Fatalf("same-volume base mismatch: %q", base)
38+
}
39+
// A relative location has no volume and must keep the historical default,
40+
// not be mistaken for a cross-volume config.
41+
if base := bannerLoaderTempBase("banner.config.ts", root); base != "" {
42+
t.Fatalf("relative-location base mismatch: %q", base)
43+
}
44+
fake := `Z:\ttsc-fake-temp`
45+
if strings.EqualFold(filepath.VolumeName(root), "Z:") {
46+
fake = `Y:\ttsc-fake-temp`
47+
}
48+
if filepath.VolumeName(fake) == "" {
49+
// No volume concept on this platform; the cross-volume branch is
50+
// unreachable by construction.
51+
return
52+
}
53+
base := bannerLoaderTempBase(config, fake)
54+
// Normalize the expectation the same way the helper normalizes its result:
55+
// EvalSymlinks also expands 8.3 short names (CI runners hand out a
56+
// RUNNER~1-style TEMP), so a raw Join of the fixture root won't compare
57+
// equal even though both name the same directory.
58+
crossWant, crossErr := filepath.EvalSymlinks(filepath.Join(root, "node_modules", ".cache"))
59+
if crossErr != nil {
60+
t.Fatal(crossErr)
61+
}
62+
if base != crossWant {
63+
t.Fatalf("cross-volume base mismatch: %q != %q", base, crossWant)
64+
}
65+
if stat, err := os.Stat(base); err != nil || !stat.IsDir() {
66+
t.Fatalf("cross-volume base was not created: %v", err)
67+
}
68+
// A file squatting on node_modules/.cache blocks the cache dir; the
69+
// config's own directory is still on the right volume, unlike the system
70+
// temp dir which is guaranteed to fail.
71+
blockedRoot := t.TempDir()
72+
if err := os.MkdirAll(filepath.Join(blockedRoot, "node_modules"), 0o755); err != nil {
73+
t.Fatal(err)
74+
}
75+
if err := os.WriteFile(filepath.Join(blockedRoot, "node_modules", ".cache"), nil, 0o644); err != nil {
76+
t.Fatal(err)
77+
}
78+
if base := bannerLoaderTempBase(filepath.Join(blockedRoot, "banner.config.ts"), fake); base != blockedRoot {
79+
t.Fatalf("blocked-cache base mismatch: %q != %q", base, blockedRoot)
80+
}
81+
// Same fallback without any node_modules. Guarded: a stray node_modules
82+
// above the test temp dir would legitimately route to its .cache.
83+
bare := t.TempDir()
84+
if bannerFindNearestNodeModules(bare) == "" {
85+
if base := bannerLoaderTempBase(filepath.Join(bare, "banner.config.ts"), fake); base != bare {
86+
t.Fatalf("no-node_modules base mismatch: %q != %q", base, bare)
87+
}
88+
}
89+
// Junction node_modules (privilege-free on Windows, and this section only
90+
// runs on drive-letter platforms).
91+
linkedRoot := t.TempDir()
92+
realModules := filepath.Join(linkedRoot, "real-modules")
93+
project := filepath.Join(linkedRoot, "project")
94+
for _, dir := range []string{realModules, project} {
95+
if err := os.MkdirAll(dir, 0o755); err != nil {
96+
t.Fatal(err)
97+
}
98+
}
99+
junction := filepath.Join(project, "node_modules")
100+
if out, err := exec.Command("cmd", "/c", "mklink", "/J", junction, realModules).CombinedOutput(); err != nil {
101+
t.Fatalf("mklink /J failed: %v: %s", err, out)
102+
}
103+
base = bannerLoaderTempBase(filepath.Join(project, "banner.config.ts"), fake)
104+
want, err := filepath.EvalSymlinks(filepath.Join(realModules, ".cache"))
105+
if err != nil {
106+
t.Fatal(err)
107+
}
108+
if base != want {
109+
t.Fatalf("junction base mismatch: %q != %q", base, want)
110+
}
111+
}

packages/banner/test/helpers_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,43 @@ func writeExecutable(t *testing.T, file string, contents string) string {
139139
return file
140140
}
141141

142+
// writeDirectLauncher writes a fake launcher that prints fixed bytes and
143+
// exits. POSIX gets a `#!/bin/sh` script; Windows cannot spawn an
144+
// extensionless shell script, so it gets the equivalent `.cmd` batch file and
145+
// the returned path carries that extension. Both stay OFF the script-extension
146+
// list, preserving the direct-exec (not node-routed) classification under
147+
// test. Payloads must avoid cmd metacharacters (%, ^, &, |, <, >) — batch has
148+
// no way to quote them that sh's single quotes would mirror.
149+
func writeDirectLauncher(t *testing.T, file, stdout, stderr string, exitCode int) string {
150+
t.Helper()
151+
var b strings.Builder
152+
if runtime.GOOS == "windows" {
153+
b.WriteString("@echo off\r\n")
154+
if stdout != "" {
155+
b.WriteString("echo " + stdout + "\r\n")
156+
}
157+
if stderr != "" {
158+
// The redirect goes up front: a trailing `1>&2` would emit "x ", and
159+
// a bare `>&2` glued to a payload ending in a digit would turn that
160+
// digit into a file-descriptor redirect.
161+
b.WriteString("1>&2 echo " + stderr + "\r\n")
162+
}
163+
b.WriteString("exit /b " + strconv.Itoa(exitCode) + "\r\n")
164+
return writeExecutable(t, file+".cmd", b.String())
165+
}
166+
b.WriteString("#!/bin/sh\n")
167+
if stdout != "" {
168+
b.WriteString("printf '" + stdout + "'\n")
169+
}
170+
if stderr != "" {
171+
b.WriteString("printf '" + stderr + "' >&2\n")
172+
}
173+
if exitCode != 0 {
174+
b.WriteString("exit " + strconv.Itoa(exitCode) + "\n")
175+
}
176+
return writeExecutable(t, file, b.String())
177+
}
178+
142179
// bannerManifest builds the plugin manifest shape that ttsc passes to native
143180
// plugins through --plugins-json. It writes a temporary banner.config.cjs file
144181
// in dir exporting an object with a "text" string and returns a manifest that

packages/banner/test/linkname_helpers_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ func bannerTypeScriptConfigLoaderSource(importLiteral string) string
5757
//go:linkname bannerTypeScriptConfigLoaderTsconfig github.com/samchon/ttsc/packages/banner/driver.typeScriptConfigLoaderTsconfig
5858
func bannerTypeScriptConfigLoaderTsconfig(loader, location, outDir string) string
5959

60+
//go:linkname bannerLoaderTempBase github.com/samchon/ttsc/packages/banner/driver.loaderTempBase
61+
func bannerLoaderTempBase(location, systemTemp string) string
62+
6063
//go:linkname bannerTtsxCommand github.com/samchon/ttsc/packages/banner/driver.ttsxCommand
6164
func bannerTtsxCommand(args ...string) *exec.Cmd
6265

packages/banner/test/node_environment_helpers_test.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,10 @@ func TestNodeEnvironmentHelpers(t *testing.T) {
2929
if got := bannerFindNearestNodeModules(nested); got != nodeModules {
3030
t.Fatalf("nearest node_modules mismatch: %q", got)
3131
}
32-
if got := bannerFindNearestNodeModules(filepath.Join(root, "without-modules")); got != "" {
32+
// Hermetic: a stray node_modules above the test temp dir (developer
33+
// machines have them at drive roots) is a legitimate discovery — only a
34+
// hit inside the fixture would be a bug.
35+
if got := bannerFindNearestNodeModules(filepath.Join(root, "without-modules")); got != "" && strings.HasPrefix(got, root) {
3336
t.Fatalf("unexpected node_modules discovery: %q", got)
3437
}
3538

@@ -59,8 +62,12 @@ func TestNodeEnvironmentHelpers(t *testing.T) {
5962
t.Fatalf("append env mismatch: %#v", appended)
6063
}
6164

62-
if err := bannerLinkNearestNodeModules(filepath.Join(root, "no-link"), filepath.Join(root, "without-modules")); err != nil {
63-
t.Fatalf("no node_modules link should be a no-op: %v", err)
65+
// Hermetic for the same reason as above: with a stray ancestor
66+
// node_modules the call is a real link attempt, not a no-op.
67+
if bannerFindNearestNodeModules(filepath.Join(root, "without-modules")) == "" {
68+
if err := bannerLinkNearestNodeModules(filepath.Join(root, "no-link"), filepath.Join(root, "without-modules")); err != nil {
69+
t.Fatalf("no node_modules link should be a no-op: %v", err)
70+
}
6471
}
6572
if runtime.GOOS == "windows" {
6673
t.Skip("symlink creation is privilege-dependent on Windows")

packages/banner/test/resolve_banner_text_branches_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ func TestResolveBannerTextBranches(t *testing.T) {
7777
t.Fatalf("expected explicit empty-text export error, got %v", err)
7878
}
7979

80-
fakeTtsx := writeExecutable(t, filepath.Join(root, "fake-ttsx"), "#!/bin/sh\nprintf '{}'\n")
80+
fakeTtsx := writeDirectLauncher(t, filepath.Join(root, "fake-ttsx"), "{}", "", 0)
8181
t.Setenv("TTSC_TTSX_BINARY", fakeTtsx)
8282
noTextTS := filepath.Join(project, "ts", "banner.config.ts")
8383
writeFile(t, noTextTS, `export default {};`)

packages/banner/test/script_config_loader_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,19 +49,19 @@ func TestScriptConfigLoader(t *testing.T) {
4949
t.Fatalf("expected invalid export error, got %v", err)
5050
}
5151

52-
invalidJSONNode := writeExecutable(t, filepath.Join(root, "fake-node-invalid-json"), "#!/bin/sh\nprintf 'not-json'\n")
52+
invalidJSONNode := writeDirectLauncher(t, filepath.Join(root, "fake-node-invalid-json"), "not-json", "", 0)
5353
t.Setenv("TTSC_NODE_BINARY", invalidJSONNode)
5454
if _, err := bannerLoadBannerScriptConfigFile(cjs); err == nil || !strings.Contains(err.Error(), "parse config file") {
5555
t.Fatalf("expected invalid stdout error, got %v", err)
5656
}
5757

58-
stderrNode := writeExecutable(t, filepath.Join(root, "fake-node-stderr"), "#!/bin/sh\nprintf 'loader failed' >&2\nexit 7\n")
58+
stderrNode := writeDirectLauncher(t, filepath.Join(root, "fake-node-stderr"), "", "loader failed", 7)
5959
t.Setenv("TTSC_NODE_BINARY", stderrNode)
6060
if _, err := bannerLoadBannerScriptConfigFile(cjs); err == nil || !strings.Contains(err.Error(), "loader failed") {
6161
t.Fatalf("expected stderr error, got %v", err)
6262
}
6363

64-
silentNode := writeExecutable(t, filepath.Join(root, "fake-node-silent"), "#!/bin/sh\nexit 7\n")
64+
silentNode := writeDirectLauncher(t, filepath.Join(root, "fake-node-silent"), "", "", 7)
6565
t.Setenv("TTSC_NODE_BINARY", silentNode)
6666
if _, err := bannerLoadBannerScriptConfigFile(cjs); err == nil || !strings.Contains(err.Error(), "exit status") {
6767
t.Fatalf("expected silent exit error, got %v", err)

0 commit comments

Comments
 (0)