Skip to content

Commit ac6feb6

Browse files
authored
Decouple Shadowenv test from specific dev setup (#4087)
1 parent 32b5d7a commit ac6feb6

2 files changed

Lines changed: 118 additions & 153 deletions

File tree

.github/workflows/ci.yml

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -99,21 +99,6 @@ jobs:
9999
bundler-cache: true
100100
cache-version: 3
101101

102-
- name: Download shadowenv
103-
if: matrix.os == 'ubuntu-latest' || matrix.os == 'macos-latest'
104-
env:
105-
GH_TOKEN: ${{ github.token }}
106-
run: |
107-
if [ "${{ matrix.os }}" == "ubuntu-latest" ]; then
108-
pattern="shadowenv-x86_64-unknown-linux-gnu"
109-
else
110-
pattern="shadowenv-x86_64-apple-darwin"
111-
fi
112-
113-
gh release download --pattern $pattern --repo=Shopify/shadowenv --output shadowenv
114-
chmod +x shadowenv
115-
sudo mv shadowenv /usr/local/bin/shadowenv
116-
117102
- name: Install gems for node tests
118103
shell: bash
119104
run: |

vscode/src/test/suite/ruby/shadowenv.test.ts

Lines changed: 118 additions & 138 deletions
Original file line numberDiff line numberDiff line change
@@ -2,195 +2,175 @@ import fs from "fs";
22
import assert from "assert";
33
import path from "path";
44
import os from "os";
5-
import { execSync } from "child_process";
65

76
import { beforeEach, afterEach } from "mocha";
87
import * as vscode from "vscode";
98
import sinon from "sinon";
109

11-
import { Shadowenv } from "../../../ruby/shadowenv";
10+
import { Shadowenv, UntrustedWorkspaceError } from "../../../ruby/shadowenv";
1211
import { WorkspaceChannel } from "../../../workspaceChannel";
13-
import { LOG_CHANNEL, asyncExec } from "../../../common";
14-
import { RUBY_VERSION } from "../../rubyVersion";
12+
import { LOG_CHANNEL } from "../../../common";
1513
import * as common from "../../../common";
14+
import { ActivationResult, NonReportableError } from "../../../ruby/versionManager";
1615
import { createContext, FakeContext } from "../helpers";
1716

18-
suite("Shadowenv", () => {
19-
if (os.platform() === "win32") {
20-
// eslint-disable-next-line no-console
21-
console.log("Skipping Shadowenv tests on Windows");
22-
return;
23-
}
24-
25-
try {
26-
execSync("shadowenv --version >/dev/null 2>&1");
27-
} catch {
28-
// eslint-disable-next-line no-console
29-
console.log("Skipping Shadowenv tests because no `shadowenv` found");
30-
return;
31-
}
32-
33-
let context: FakeContext;
34-
beforeEach(() => {
35-
context = createContext();
36-
});
37-
afterEach(() => {
38-
context.dispose();
39-
});
17+
// Typed view over the private method we need to stub. Kept in one place so the cast doesn't leak into each test.
18+
type ShadowenvStub = { runEnvActivationScript: (command: string) => Promise<ActivationResult> };
19+
type ActivationBehavior = ActivationResult | Error;
4020

21+
suite("Shadowenv", () => {
4122
let rootPath: string;
4223
let workspacePath: string;
4324
let workspaceFolder: vscode.WorkspaceFolder;
4425
let outputChannel: WorkspaceChannel;
45-
let rubyBinPath: string;
46-
const [major, minor, patch] = RUBY_VERSION.split(".");
47-
48-
if (process.env.CI && os.platform() === "linux") {
49-
rubyBinPath = path.join("/", "opt", "hostedtoolcache", "Ruby", RUBY_VERSION, "x64", "bin");
50-
} else if (process.env.CI) {
51-
rubyBinPath = path.join("/", "Users", "runner", "hostedtoolcache", "Ruby", RUBY_VERSION, "arm64", "bin");
52-
} else {
53-
rubyBinPath = path.join("/", "opt", "rubies", RUBY_VERSION, "bin");
26+
let context: FakeContext;
27+
let sandbox: sinon.SinonSandbox;
28+
const FAKE_ACTIVATION: ActivationResult = {
29+
env: { PATH: "/fake/ruby/bin:/usr/bin", GEM_ROOT: "/fake/gem/root" },
30+
yjit: true,
31+
version: "3.3.5",
32+
gemPath: ["/fake/gem/path"],
33+
};
34+
35+
function stubActivation(behaviors: ActivationBehavior[]): sinon.SinonStub {
36+
const stub = sandbox.stub(Shadowenv.prototype as unknown as ShadowenvStub, "runEnvActivationScript");
37+
38+
behaviors.forEach((behavior, i) => {
39+
if (behavior instanceof Error) {
40+
stub.onCall(i).rejects(behavior);
41+
} else {
42+
stub.onCall(i).resolves(behavior);
43+
}
44+
});
45+
46+
return stub;
5447
}
5548

56-
assert.ok(fs.existsSync(rubyBinPath), `Ruby bin path does not exist ${rubyBinPath}`);
57-
58-
const shadowLispFile = `
59-
(provide "ruby" "${RUBY_VERSION}")
60-
61-
(when-let ((ruby-root (env/get "RUBY_ROOT")))
62-
(env/remove-from-pathlist "PATH" (path-concat ruby-root "bin"))
63-
(when-let ((gem-root (env/get "GEM_ROOT")))
64-
(env/remove-from-pathlist "PATH" (path-concat gem-root "bin")))
65-
(when-let ((gem-home (env/get "GEM_HOME")))
66-
(env/remove-from-pathlist "PATH" (path-concat gem-home "bin"))))
67-
68-
(env/set "BUNDLE_PATH" ())
69-
(env/set "GEM_PATH" ())
70-
(env/set "GEM_HOME" ())
71-
(env/set "RUBYOPT" ())
72-
(env/set "RUBYLIB" ())
73-
74-
(env/set "RUBY_ROOT" "${path.dirname(rubyBinPath)}")
75-
(env/prepend-to-pathlist "PATH" "${rubyBinPath}")
76-
(env/set "RUBY_ENGINE" "ruby")
77-
(env/set "RUBY_VERSION" "${RUBY_VERSION}")
78-
(env/set "GEM_ROOT" "${path.dirname(rubyBinPath)}/lib/ruby/gems/${major}.${minor}.0")
79-
80-
(when-let ((gem-root (env/get "GEM_ROOT")))
81-
(env/prepend-to-pathlist "GEM_PATH" gem-root)
82-
(env/prepend-to-pathlist "PATH" (path-concat gem-root "bin")))
83-
84-
(let ((gem-home
85-
(path-concat (env/get "HOME") ".gem" (env/get "RUBY_ENGINE") "${RUBY_VERSION}")))
86-
(do
87-
(env/set "GEM_HOME" gem-home)
88-
(env/prepend-to-pathlist "GEM_PATH" gem-home)
89-
(env/prepend-to-pathlist "PATH" (path-concat gem-home "bin"))))
90-
`;
49+
function expectNonReportable(error: Error, messagePattern: RegExp): boolean {
50+
assert.ok(error instanceof NonReportableError);
51+
assert.match(error.message, messagePattern);
52+
return true;
53+
}
9154

9255
beforeEach(() => {
56+
sandbox = sinon.createSandbox();
57+
context = createContext();
58+
9359
rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "ruby-lsp-test-shadowenv-"));
9460
workspacePath = path.join(rootPath, "workspace");
95-
9661
fs.mkdirSync(workspacePath);
9762
fs.mkdirSync(path.join(workspacePath, ".shadowenv.d"));
9863

9964
workspaceFolder = {
100-
uri: vscode.Uri.from({ scheme: "file", path: workspacePath }),
65+
uri: vscode.Uri.file(workspacePath),
10166
name: path.basename(workspacePath),
10267
index: 0,
10368
};
10469
outputChannel = new WorkspaceChannel("fake", LOG_CHANNEL);
10570
});
10671

10772
afterEach(() => {
73+
sandbox.restore();
74+
context.dispose();
10875
fs.rmSync(rootPath, { recursive: true, force: true });
10976
});
11077

111-
test("Finds Ruby only binary path is appended to PATH", async () => {
112-
await asyncExec("shadowenv trust", { cwd: workspacePath });
78+
test("Throws when .shadowenv.d is missing from the workspace", async () => {
79+
fs.rmSync(path.join(workspacePath, ".shadowenv.d"), { recursive: true, force: true });
11380

114-
fs.writeFileSync(
115-
path.join(workspacePath, ".shadowenv.d", "500_ruby.lisp"),
116-
`(env/prepend-to-pathlist "PATH" "${rubyBinPath}")`,
81+
await assert.rejects(
82+
() => new Shadowenv(workspaceFolder, outputChannel, context, async () => {}).activate(),
83+
(error: Error) => expectNonReportable(error, /no \.shadowenv\.d directory was found/),
11784
);
118-
119-
const shadowenv = new Shadowenv(workspaceFolder, outputChannel, context, async () => {});
120-
const { env, version, yjit } = await shadowenv.activate();
121-
122-
assert.match(env.PATH!, new RegExp(rubyBinPath));
123-
assert.strictEqual(version, RUBY_VERSION);
124-
assert.notStrictEqual(yjit, undefined);
12585
});
12686

127-
test("Finds Ruby on a complete shadowenv configuration", async () => {
128-
await asyncExec("shadowenv trust", { cwd: workspacePath });
129-
130-
fs.writeFileSync(path.join(workspacePath, ".shadowenv.d", "500_ruby.lisp"), shadowLispFile);
131-
132-
const shadowenv = new Shadowenv(workspaceFolder, outputChannel, context, async () => {});
133-
const { env, version, yjit } = await shadowenv.activate();
134-
135-
assert.match(env.PATH!, new RegExp(rubyBinPath));
136-
assert.strictEqual(env.GEM_ROOT, `${path.dirname(rubyBinPath)}/lib/ruby/gems/${major}.${minor}.0`);
137-
assert.strictEqual(version, RUBY_VERSION);
138-
assert.notStrictEqual(yjit, undefined);
87+
test("Invokes `shadowenv exec -- ruby` and strips BUNDLE_GEMFILE coming from shadowenv", async () => {
88+
const originalBundleGemfile = process.env.BUNDLE_GEMFILE;
89+
process.env.BUNDLE_GEMFILE = "/from/process/env/Gemfile";
90+
91+
try {
92+
const stub = stubActivation([
93+
{
94+
...FAKE_ACTIVATION,
95+
env: { ...FAKE_ACTIVATION.env, PATH: "/fake/ruby/bin", BUNDLE_GEMFILE: "/from/shadowenv/Gemfile" },
96+
},
97+
]);
98+
99+
const { env, version, yjit, gemPath } = await new Shadowenv(
100+
workspaceFolder,
101+
outputChannel,
102+
context,
103+
async () => {},
104+
).activate();
105+
106+
assert.ok(stub.calledOnce);
107+
assert.match(stub.firstCall.args[0] as string, /shadowenv exec -- ruby$/);
108+
// Shadowenv's BUNDLE_GEMFILE must not leak into the final env; the server needs to control this value
109+
assert.notStrictEqual(env.BUNDLE_GEMFILE, "/from/shadowenv/Gemfile");
110+
assert.strictEqual(env.BUNDLE_GEMFILE, "/from/process/env/Gemfile");
111+
assert.strictEqual(env.PATH, "/fake/ruby/bin");
112+
assert.strictEqual(version, "3.3.5");
113+
assert.strictEqual(yjit, true);
114+
assert.deepStrictEqual(gemPath, ["/fake/gem/path"]);
115+
} finally {
116+
if (originalBundleGemfile === undefined) {
117+
delete process.env.BUNDLE_GEMFILE;
118+
} else {
119+
process.env.BUNDLE_GEMFILE = originalBundleGemfile;
120+
}
121+
}
139122
});
140123

141-
test("Untrusted workspace offers to trust it", async () => {
142-
fs.writeFileSync(path.join(workspacePath, ".shadowenv.d", "500_ruby.lisp"), shadowLispFile);
143-
144-
const stub = sinon.stub(vscode.window, "showErrorMessage").resolves("Trust workspace" as any);
145-
146-
const shadowenv = new Shadowenv(workspaceFolder, outputChannel, context, async () => {});
147-
const { env, version, yjit } = await shadowenv.activate();
124+
test("Prompts to trust the workspace when shadowenv reports it is untrusted, and retries on accept", async () => {
125+
const activationStub = stubActivation([new Error("untrusted shadowenv program"), FAKE_ACTIVATION]);
148126

149-
assert.match(env.PATH!, new RegExp(rubyBinPath));
150-
assert.match(env.GEM_HOME!, new RegExp(`\\.gem\\/ruby\\/${major}\\.${minor}\\.${patch}`));
151-
assert.strictEqual(version, RUBY_VERSION);
152-
assert.notStrictEqual(yjit, undefined);
127+
const showError = sandbox.stub(vscode.window, "showErrorMessage") as sinon.SinonStub;
128+
showError.resolves("Trust workspace");
129+
const execStub = sandbox.stub(common, "asyncExec").resolves({ stdout: "", stderr: "" });
153130

154-
assert.ok(stub.calledOnce);
131+
const result = await new Shadowenv(workspaceFolder, outputChannel, context, async () => {}).activate();
155132

156-
stub.restore();
133+
assert.ok(showError.calledOnce);
134+
assert.ok(execStub.calledOnce);
135+
assert.match(execStub.firstCall.args[0], /^shadowenv trust$/);
136+
assert.strictEqual(activationStub.callCount, 2);
137+
assert.strictEqual(result.version, "3.3.5");
157138
});
158139

159-
test("Deciding not to trust the workspace fails activation", async () => {
160-
fs.writeFileSync(path.join(workspacePath, ".shadowenv.d", "500_ruby.lisp"), shadowLispFile);
140+
test("Rejects with UntrustedWorkspaceError when the user declines to trust the workspace", async () => {
141+
stubActivation([new Error("untrusted shadowenv program")]);
161142

162-
const stub = sinon.stub(vscode.window, "showErrorMessage").resolves("Cancel" as any);
143+
const showError = sandbox.stub(vscode.window, "showErrorMessage") as sinon.SinonStub;
144+
showError.resolves("Shutdown Ruby LSP");
163145

164-
const shadowenv = new Shadowenv(workspaceFolder, outputChannel, context, async () => {});
165-
166-
await assert.rejects(async () => {
167-
await shadowenv.activate();
168-
});
169-
170-
assert.ok(stub.calledOnce);
171-
172-
stub.restore();
146+
await assert.rejects(
147+
() => new Shadowenv(workspaceFolder, outputChannel, context, async () => {}).activate(),
148+
UntrustedWorkspaceError,
149+
);
150+
assert.ok(showError.calledOnce);
173151
});
174152

175-
test("Warns user is shadowenv executable can't be found", async () => {
176-
await asyncExec("shadowenv trust", { cwd: workspacePath });
177-
178-
fs.writeFileSync(path.join(workspacePath, ".shadowenv.d", "500_ruby.lisp"), shadowLispFile);
179-
180-
const shadowenv = new Shadowenv(workspaceFolder, outputChannel, context, async () => {});
153+
test("Reports a PATH-related error when the shadowenv executable cannot be found", async () => {
154+
stubActivation([new Error("spawn shadowenv ENOENT")]);
155+
const execStub = sandbox.stub(common, "asyncExec").rejects(new Error("shadowenv: command not found"));
181156

182-
// First, reject the call to `shadowenv exec`. Then resolve the call to `which shadowenv` to return nothing
183-
const execStub = sinon
184-
.stub(common, "asyncExec")
185-
.onFirstCall()
186-
.rejects(new Error("shadowenv: command not found"))
187-
.onSecondCall()
188-
.rejects(new Error("shadowenv: command not found"));
157+
await assert.rejects(
158+
() => new Shadowenv(workspaceFolder, outputChannel, context, async () => {}).activate(),
159+
(error: Error) => expectNonReportable(error, /Shadowenv executable not found/),
160+
);
161+
assert.ok(execStub.calledOnce);
162+
assert.match(execStub.firstCall.args[0], /^shadowenv --version$/);
163+
});
189164

190-
await assert.rejects(async () => {
191-
await shadowenv.activate();
192-
});
165+
test("Surfaces the underlying error when activation fails for a non-trust, non-missing reason", async () => {
166+
stubActivation([new Error("boom")]);
167+
const execStub = sandbox.stub(common, "asyncExec").resolves({ stdout: "shadowenv 2.1.5", stderr: "" });
193168

194-
execStub.restore();
169+
await assert.rejects(
170+
() => new Shadowenv(workspaceFolder, outputChannel, context, async () => {}).activate(),
171+
(error: Error) => expectNonReportable(error, /Failed to activate Ruby environment with Shadowenv: boom/),
172+
);
173+
assert.ok(execStub.calledOnce);
174+
assert.match(execStub.firstCall.args[0], /^shadowenv --version$/);
195175
});
196176
});

0 commit comments

Comments
 (0)