|
| 1 | +import { assert, assertEquals } from "jsr:@std/assert"; |
| 2 | +import { Path } from "libpkgx"; |
| 3 | +import shellcode, { datadir } from "./shellcode().ts"; |
| 4 | + |
| 5 | +// Exercises `_pkgx_chpwd_hook` end-to-end via a zsh subprocess with a fake |
| 6 | +// `dev` binary on PATH. Reproduces the bug from issue #51 where cd-ing |
| 7 | +// directly into a subdir of an activated devenv fails to activate and emits |
| 8 | +// `permission denied`. |
| 9 | + |
| 10 | +async function run_hook_in_subdir(): Promise< |
| 11 | + { stdout: string; stderr: string; recorded_args: string[] } |
| 12 | +> { |
| 13 | + const tmp = Path.mktemp(); |
| 14 | + const proj = tmp.join("proj").mkdir(); |
| 15 | + const sub = proj.join("sub").mkdir(); |
| 16 | + |
| 17 | + // Override XDG_DATA_HOME so datadir() lives in the temp tree. |
| 18 | + const xdg = tmp.join("xdg").mkdir(); |
| 19 | + |
| 20 | + // Pre-create the activation marker for `proj`. |
| 21 | + const marker_dir = xdg.join( |
| 22 | + "pkgx", |
| 23 | + "dev", |
| 24 | + proj.string.slice(1), |
| 25 | + ).mkdir("p"); |
| 26 | + marker_dir.join("dev.pkgx.activated").touch(); |
| 27 | + |
| 28 | + // Fake `dev` that records its argv and emits a sentinel shell command we |
| 29 | + // can detect in stdout. Crucially: the real `dev` (with no args) would |
| 30 | + // sniff $PWD, find nothing, and exit 1 — the bug we're testing. |
| 31 | + const fake_bin = tmp.join("bin").mkdir(); |
| 32 | + const fake_dev = fake_bin.join("dev"); |
| 33 | + const log = tmp.join("dev-args.log"); |
| 34 | + Deno.writeTextFileSync( |
| 35 | + fake_dev.string, |
| 36 | + `#!/bin/sh\nprintf '%s\\n' "$@" >> "${log.string}"\necho "echo HOOK_OK"\n`, |
| 37 | + ); |
| 38 | + Deno.chmodSync(fake_dev.string, 0o755); |
| 39 | + |
| 40 | + const env = { |
| 41 | + ...Deno.env.toObject(), |
| 42 | + XDG_DATA_HOME: xdg.string, |
| 43 | + PATH: `${fake_bin.string}:${Deno.env.get("PATH") ?? ""}`, |
| 44 | + }; |
| 45 | + |
| 46 | + // Generate shellcode using a PATH that resolves `dev` to our fake and an |
| 47 | + // XDG_DATA_HOME that points at our temp datadir (both are baked in at |
| 48 | + // codegen time). |
| 49 | + const original_path = Deno.env.get("PATH"); |
| 50 | + const original_xdg = Deno.env.get("XDG_DATA_HOME"); |
| 51 | + Deno.env.set("PATH", env.PATH); |
| 52 | + Deno.env.set("XDG_DATA_HOME", xdg.string); |
| 53 | + let code: string; |
| 54 | + try { |
| 55 | + code = shellcode(); |
| 56 | + } finally { |
| 57 | + if (original_path !== undefined) Deno.env.set("PATH", original_path); |
| 58 | + if (original_xdg === undefined) Deno.env.delete("XDG_DATA_HOME"); |
| 59 | + else Deno.env.set("XDG_DATA_HOME", original_xdg); |
| 60 | + } |
| 61 | + |
| 62 | + // Simulate the user: shell starts in HOME, then cd's directly into the |
| 63 | + // subdirectory of the already-activated project. |
| 64 | + const script = ` |
| 65 | +${code} |
| 66 | +cd "${sub.string}" |
| 67 | +`; |
| 68 | + |
| 69 | + const script_path = tmp.join("script.zsh"); |
| 70 | + Deno.writeTextFileSync(script_path.string, script); |
| 71 | + |
| 72 | + const proc = await new Deno.Command("zsh", { |
| 73 | + args: [script_path.string], |
| 74 | + env, |
| 75 | + stdout: "piped", |
| 76 | + stderr: "piped", |
| 77 | + }).output(); |
| 78 | + |
| 79 | + const recorded_args = log.isFile() |
| 80 | + ? Deno.readTextFileSync(log.string).split("\n").filter((x) => x.length > 0) |
| 81 | + : []; |
| 82 | + |
| 83 | + return { |
| 84 | + stdout: new TextDecoder().decode(proc.stdout), |
| 85 | + stderr: new TextDecoder().decode(proc.stderr), |
| 86 | + recorded_args, |
| 87 | + }; |
| 88 | +} |
| 89 | + |
| 90 | +Deno.test("chpwd hook activates when cd-ing directly into subdir of devenv", async () => { |
| 91 | + const { stdout, stderr, recorded_args } = await run_hook_in_subdir(); |
| 92 | + |
| 93 | + // The hook must invoke our fake dev and run its emitted shellcode. |
| 94 | + assert( |
| 95 | + stdout.includes("HOOK_OK"), |
| 96 | + `expected hook to eval dev's stdout (HOOK_OK), got stdout=${ |
| 97 | + JSON.stringify(stdout) |
| 98 | + } stderr=${JSON.stringify(stderr)}`, |
| 99 | + ); |
| 100 | + |
| 101 | + // Crucially: no "permission denied" from the shell trying to execute a |
| 102 | + // directory path as a command (the bug from issue #51). |
| 103 | + assert( |
| 104 | + !/permission denied/i.test(stderr), |
| 105 | + `unexpected 'permission denied' in stderr: ${stderr}`, |
| 106 | + ); |
| 107 | + |
| 108 | + // dev must have been invoked with the activated dir so it sniffs the |
| 109 | + // right place — not invoked bare while $PWD points at the subdir. |
| 110 | + assertEquals( |
| 111 | + recorded_args.length, |
| 112 | + 1, |
| 113 | + `expected dev to be called with exactly one argument, got: ${ |
| 114 | + JSON.stringify(recorded_args) |
| 115 | + }`, |
| 116 | + ); |
| 117 | + assert( |
| 118 | + recorded_args[0].endsWith("/proj"), |
| 119 | + `expected dev to be called with the activated dir, got: ${ |
| 120 | + recorded_args[0] |
| 121 | + }`, |
| 122 | + ); |
| 123 | +}); |
| 124 | + |
| 125 | +Deno.test("datadir respects XDG_DATA_HOME", () => { |
| 126 | + const original = Deno.env.get("XDG_DATA_HOME"); |
| 127 | + try { |
| 128 | + Deno.env.set("XDG_DATA_HOME", "/tmp/xdg-test"); |
| 129 | + assertEquals(datadir().string, "/tmp/xdg-test/pkgx/dev"); |
| 130 | + } finally { |
| 131 | + if (original === undefined) Deno.env.delete("XDG_DATA_HOME"); |
| 132 | + else Deno.env.set("XDG_DATA_HOME", original); |
| 133 | + } |
| 134 | +}); |
0 commit comments