Skip to content

Commit e472c6b

Browse files
vicbjames-elicx
andauthored
test: add unit test for the optional dependency patch (#277)
Co-authored-by: James Anderson <[email protected]>
1 parent 25eb4b4 commit e472c6b

File tree

4 files changed

+207
-19
lines changed

4 files changed

+207
-19
lines changed
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { optionalDepRule } from "./optional-deps.js";
4+
import { patchCode } from "./util.js";
5+
6+
describe("optional dependecy", () => {
7+
it('should wrap a top-level require("caniuse-lite") in a try-catch', () => {
8+
const code = `t = require("caniuse-lite");`;
9+
expect(patchCode(code, optionalDepRule)).toMatchInlineSnapshot(`
10+
"try {
11+
t = require("caniuse-lite");
12+
} catch {
13+
throw new Error('The optional dependency "caniuse-lite" is not installed');
14+
};"
15+
`);
16+
});
17+
18+
it('should wrap a top-level require("caniuse-lite/data") in a try-catch', () => {
19+
const code = `t = require("caniuse-lite/data");`;
20+
expect(patchCode(code, optionalDepRule)).toMatchInlineSnapshot(
21+
`
22+
"try {
23+
t = require("caniuse-lite/data");
24+
} catch {
25+
throw new Error('The optional dependency "caniuse-lite/data" is not installed');
26+
};"
27+
`
28+
);
29+
});
30+
31+
it('should wrap e.exports = require("caniuse-lite") in a try-catch', () => {
32+
const code = 'e.exports = require("caniuse-lite");';
33+
expect(patchCode(code, optionalDepRule)).toMatchInlineSnapshot(`
34+
"try {
35+
e.exports = require("caniuse-lite");
36+
} catch {
37+
throw new Error('The optional dependency "caniuse-lite" is not installed');
38+
};"
39+
`);
40+
});
41+
42+
it('should wrap module.exports = require("caniuse-lite") in a try-catch', () => {
43+
const code = 'module.exports = require("caniuse-lite");';
44+
expect(patchCode(code, optionalDepRule)).toMatchInlineSnapshot(`
45+
"try {
46+
module.exports = require("caniuse-lite");
47+
} catch {
48+
throw new Error('The optional dependency "caniuse-lite" is not installed');
49+
};"
50+
`);
51+
});
52+
53+
it('should wrap exports.foo = require("caniuse-lite") in a try-catch', () => {
54+
const code = 'exports.foo = require("caniuse-lite");';
55+
expect(patchCode(code, optionalDepRule)).toMatchInlineSnapshot(`
56+
"try {
57+
exports.foo = require("caniuse-lite");
58+
} catch {
59+
throw new Error('The optional dependency "caniuse-lite" is not installed');
60+
};"
61+
`);
62+
});
63+
64+
it('should not wrap require("lodash") in a try-catch', () => {
65+
const code = 't = require("lodash");';
66+
expect(patchCode(code, optionalDepRule)).toMatchInlineSnapshot(`"t = require("lodash");"`);
67+
});
68+
69+
it('should not wrap require("other-module") if it does not match caniuse-lite regex', () => {
70+
const code = 't = require("other-module");';
71+
expect(patchCode(code, optionalDepRule)).toMatchInlineSnapshot(`"t = require("other-module");"`);
72+
});
73+
74+
it("should not wrap a require() call already inside a try-catch", () => {
75+
const code = `
76+
try {
77+
const t = require("caniuse-lite");
78+
} catch {}
79+
`;
80+
expect(patchCode(code, optionalDepRule)).toMatchInlineSnapshot(`
81+
"try {
82+
const t = require("caniuse-lite");
83+
} catch {}
84+
"
85+
`);
86+
});
87+
88+
it("should handle require with subpath and not wrap if already in try-catch", () => {
89+
const code = `
90+
try {
91+
const t = require("caniuse-lite/path");
92+
} catch {}
93+
`;
94+
expect(patchCode(code, optionalDepRule)).toMatchInlineSnapshot(`
95+
"try {
96+
const t = require("caniuse-lite/path");
97+
} catch {}
98+
"
99+
`);
100+
});
101+
});

packages/cloudflare/src/cli/build/patches/ast/optional-deps.ts

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,15 @@
11
import { type SgNode } from "@ast-grep/napi";
22

3-
import { applyRule } from "./util.js";
3+
import { getRuleEdits } from "./util.js";
44

55
/**
66
* Handle optional dependencies.
77
*
8-
* A top level `require(dep)` would throw when the dep is not installed.
8+
* A top level `require(optionalDep)` would throw when the dep is not installed.
99
*
10-
* So we wrap any of
11-
* - `t = require("dep")`
12-
* - `t = require("dep/sub/path")`
13-
* - `t = require("dep/sub/path/" + var)`
14-
* - `e.exports = require("dep")`
15-
*
16-
* in a try/catch (only if not already).
10+
* So we wrap `require(optionalDep)` in a try/catch (if not already present).
1711
*/
18-
const rule = `
12+
export const optionalDepRule = `
1913
rule:
2014
pattern: $$$LHS = require($$$REQ)
2115
has:
@@ -37,5 +31,5 @@ fix: |-
3731
`;
3832

3933
export function patchOptionalDependencies(root: SgNode) {
40-
return applyRule(rule, root);
34+
return getRuleEdits(optionalDepRule, root);
4135
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
3+
import { patchCode } from "./util.js";
4+
5+
describe("patchCode", () => {
6+
afterEach(() => {
7+
vi.clearAllMocks();
8+
});
9+
10+
it("should throw an error if rule has a transform", () => {
11+
expect(() =>
12+
patchCode(`console.log("hi")`, { rule: { pattern: "console.log($MSG)" }, transform: "not supported" })
13+
).toThrow(/not supported/);
14+
});
15+
16+
it("should throw an error if rule has no fix", () => {
17+
expect(() => patchCode(`console.log("hi")`, { rule: { pattern: "console.log($MSG)" } })).toThrow(
18+
/no fix/
19+
);
20+
});
21+
22+
it("should accept yaml rules", () => {
23+
const yamlRule = `
24+
rule:
25+
pattern: a
26+
fix: b
27+
`;
28+
29+
expect(patchCode(`a`, yamlRule)).toEqual("b");
30+
});
31+
32+
it("should apply fix to a single match when once is true", () => {
33+
expect(patchCode(`a+a`, { rule: { pattern: "a" }, fix: "b" }, { once: true })).toEqual("b+a");
34+
});
35+
36+
it("should apply fix to all matches when once is false (default)", () => {
37+
expect(patchCode(`a+a`, { rule: { pattern: "a" }, fix: "b" })).toEqual("b+b");
38+
expect(patchCode(`a+a`, { rule: { pattern: "a" }, fix: "b" }, { once: false })).toEqual("b+b");
39+
});
40+
41+
it("should handle no matches", () => {
42+
expect(patchCode(`a`, { rule: { pattern: "b" }, fix: "c" })).toEqual("a");
43+
});
44+
45+
it("should replace $PLACEHOLDER with match text", () => {
46+
expect(
47+
patchCode(`console.log(message)`, { rule: { pattern: "console.log($MSG)" }, fix: "$MSG" })
48+
).toEqual("message");
49+
});
50+
51+
it("should handle $PLACEHODLERS that are not found in matches", () => {
52+
expect(
53+
patchCode(`console.log(message)`, { rule: { pattern: "console.log($MSG)" }, fix: "$WHAT$$$WHAT" })
54+
).toEqual("$WHAT");
55+
});
56+
57+
it("should replace $$$PLACEHOLDER with match text", () => {
58+
expect(
59+
patchCode(`console.log("hello" + world, "!")`, {
60+
rule: { pattern: "console.log($$$ARGS)" },
61+
fix: "$$$ARGS",
62+
})
63+
).toEqual(`"hello" + world,"!"`);
64+
});
65+
});

packages/cloudflare/src/cli/build/patches/ast/util.ts

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,36 @@
1-
import { type Edit, type NapiConfig, type SgNode } from "@ast-grep/napi";
1+
import { type Edit, Lang, type NapiConfig, parse, type SgNode } from "@ast-grep/napi";
22
import yaml from "yaml";
33

4+
/**
5+
* fix has the same meaning as in yaml rules
6+
* see https://ast-grep.github.io/guide/rewrite-code.html#using-fix-in-yaml-rule
7+
*/
8+
export type RuleConfig = NapiConfig & { fix?: string };
9+
410
/**
511
* Returns the `Edit`s for an ast-grep rule in yaml format
612
*
713
* The rule must have a `fix` to rewrite the matched node.
814
*
915
* Tip: use https://ast-grep.github.io/playground.html to create rules.
1016
*
11-
* @param yamlRule The rule in yaml format
17+
* @param rule The rule. Either a yaml string or an instance of `RuleConfig`
1218
* @param root The root node
1319
* @param once only apply once
1420
* @returns A list of edits.
1521
*/
16-
export function applyRule(yamlRule: string, root: SgNode, { once = false } = {}) {
17-
const rule: NapiConfig & { fix?: string } = yaml.parse(yamlRule);
18-
if (rule.transform) {
22+
export function getRuleEdits(rule: string | RuleConfig, root: SgNode, { once = false } = {}) {
23+
const ruleConfig: RuleConfig = typeof rule === "string" ? yaml.parse(rule) : rule;
24+
if (ruleConfig.transform) {
1925
throw new Error("transform is not supported");
2026
}
21-
if (!rule.fix) {
27+
if (!ruleConfig.fix) {
2228
throw new Error("no fix to apply");
2329
}
2430

25-
const fix = rule.fix;
31+
const fix = ruleConfig.fix;
2632

27-
const matches = once ? [root.find(rule)].filter((m) => m !== null) : root.findAll(rule);
33+
const matches = once ? [root.find(ruleConfig)].filter((m) => m !== null) : root.findAll(ruleConfig);
2834

2935
const edits: Edit[] = [];
3036

@@ -46,3 +52,25 @@ export function applyRule(yamlRule: string, root: SgNode, { once = false } = {})
4652

4753
return edits;
4854
}
55+
56+
/**
57+
* Patches the code from by applying the rule.
58+
*
59+
* This function is mainly for on off edits and tests,
60+
* use `getRuleEdits` to apply multiple rules.
61+
*
62+
* @param code The source code
63+
* @param rule The astgrep rule (yaml or NapiConfig)
64+
* @param lang The language used by the source code
65+
* @param lang Whether to apply the rule only once
66+
* @returns The patched code
67+
*/
68+
export function patchCode(
69+
code: string,
70+
rule: string | RuleConfig,
71+
{ lang = Lang.TypeScript, once = false } = {}
72+
): string {
73+
const node = parse(lang, code).root();
74+
const edits = getRuleEdits(rule, node, { once });
75+
return node.commitEdits(edits);
76+
}

0 commit comments

Comments
 (0)