@checkstack/ui 1.10.0 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.storybook/main.ts +43 -0
- package/CHANGELOG.md +565 -0
- package/package.json +15 -7
- package/scripts/generate-stdlib-types.ts +25 -2
- package/src/components/ActionCard.tsx +309 -0
- package/src/components/CodeEditor/CodeEditor.tsx +132 -9
- package/src/components/CodeEditor/TypefoxEditor.tsx +1024 -0
- package/src/components/CodeEditor/bracketKeyGroups.test.ts +120 -0
- package/src/components/CodeEditor/bracketKeyGroups.ts +205 -0
- package/src/components/CodeEditor/generateTypeDefinitions.ts +4 -4
- package/src/components/CodeEditor/generated/builtin-modules.json +1 -0
- package/src/components/CodeEditor/importSpecifiers.test.ts +286 -0
- package/src/components/CodeEditor/importSpecifiers.ts +267 -0
- package/src/components/CodeEditor/index.ts +26 -0
- package/src/components/CodeEditor/monacoTsService.ts +217 -0
- package/src/components/CodeEditor/popoutTitle.test.ts +37 -0
- package/src/components/CodeEditor/popoutTitle.ts +31 -0
- package/src/components/CodeEditor/scriptContext.test.ts +41 -0
- package/src/components/CodeEditor/scriptContext.ts +76 -1
- package/src/components/CodeEditor/scriptDiagnostics.test.ts +135 -0
- package/src/components/CodeEditor/scriptDiagnostics.ts +172 -0
- package/src/components/CodeEditor/templateValidation.ts +51 -0
- package/src/components/CodeEditor/types.ts +168 -0
- package/src/components/CodeEditor/validateJsonTemplate.test.ts +61 -0
- package/src/components/CodeEditor/validateJsonTemplate.ts +26 -0
- package/src/components/CodeEditor/validateScripts.ts +132 -0
- package/src/components/CodeEditor/validateXmlTemplate.test.ts +34 -0
- package/src/components/CodeEditor/validateXmlTemplate.ts +35 -0
- package/src/components/CodeEditor/validateYamlTemplate.test.ts +39 -0
- package/src/components/CodeEditor/validateYamlTemplate.ts +28 -0
- package/src/components/Dialog.tsx +32 -11
- package/src/components/DurationInput.tsx +121 -0
- package/src/components/DynamicForm/DynamicForm.tsx +27 -1
- package/src/components/DynamicForm/FormField.tsx +138 -10
- package/src/components/DynamicForm/KeyValueEditor.tsx +2 -169
- package/src/components/DynamicForm/MultiTypeEditorField.tsx +83 -9
- package/src/components/DynamicForm/SecretEnvEditor.tsx +315 -0
- package/src/components/DynamicForm/index.ts +6 -0
- package/src/components/DynamicForm/secretEnv.logic.test.ts +126 -0
- package/src/components/DynamicForm/secretEnv.logic.ts +87 -0
- package/src/components/DynamicForm/types.ts +83 -1
- package/src/components/DynamicForm/utils.ts +32 -0
- package/src/components/Popover.tsx +6 -1
- package/src/components/ScriptTestPanel.logic.test.ts +139 -0
- package/src/components/ScriptTestPanel.logic.ts +137 -0
- package/src/components/ScriptTestPanel.tsx +394 -0
- package/src/components/Sheet.tsx +21 -6
- package/src/components/TemplateInput.tsx +104 -0
- package/src/components/TemplateInputToggle.tsx +111 -0
- package/src/components/TemplateValueInput.test.ts +98 -0
- package/src/components/TemplateValueInput.tsx +470 -0
- package/src/components/TimeOfDayInput.tsx +116 -0
- package/src/components/VariablePicker.tsx +271 -0
- package/src/components/comboboxInteraction.ts +39 -0
- package/src/components/portalContainer.ts +24 -0
- package/src/hooks/useInitOnceForKey.test.ts +27 -0
- package/src/hooks/useInitOnceForKey.ts +21 -18
- package/src/index.ts +9 -0
- package/stories/ActionCard.stories.tsx +122 -0
- package/stories/Alert.stories.tsx +5 -5
- package/stories/CodeEditor.stories.tsx +47 -2
- package/stories/DurationInput.stories.tsx +59 -0
- package/stories/ScriptTestPanel.stories.tsx +106 -0
- package/stories/SecretEnvEditor.stories.tsx +80 -0
- package/stories/TemplateInputToggle.stories.tsx +77 -0
- package/stories/TemplateValueInput.stories.tsx +65 -0
- package/stories/TimeOfDayInput.stories.tsx +34 -0
- package/stories/VariablePicker.stories.tsx +109 -0
- package/tsconfig.json +1 -0
- package/src/components/CodeEditor/MonacoEditor.tsx +0 -616
- package/src/components/CodeEditor/monacoStdlib.ts +0 -62
- package/src/components/CodeEditor/monacoWorkers.ts +0 -118
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { extractBracketKeyGroups } from "./bracketKeyGroups";
|
|
3
|
+
|
|
4
|
+
describe("extractBracketKeyGroups", () => {
|
|
5
|
+
it("returns [] when there is no `declare const context`", () => {
|
|
6
|
+
expect(
|
|
7
|
+
extractBracketKeyGroups({ typeDefinitions: "interface Foo { x: string }" }),
|
|
8
|
+
).toEqual([]);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("returns [] when no property keys are non-identifiers", () => {
|
|
12
|
+
const typeDefinitions = `declare const context: {
|
|
13
|
+
event: { payload: { repository: string; count: number } };
|
|
14
|
+
};`;
|
|
15
|
+
expect(extractBracketKeyGroups({ typeDefinitions })).toEqual([]);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("extracts a non-identifier key and its dotted parent path", () => {
|
|
19
|
+
const typeDefinitions = `declare const context: {
|
|
20
|
+
artifacts: {
|
|
21
|
+
"integration-jira.issue": { key: string; summary: string };
|
|
22
|
+
};
|
|
23
|
+
};`;
|
|
24
|
+
expect(extractBracketKeyGroups({ typeDefinitions })).toEqual([
|
|
25
|
+
{ objectExpression: "context.artifacts", keys: ["integration-jira.issue"] },
|
|
26
|
+
]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("groups multiple non-identifier keys under the same parent", () => {
|
|
30
|
+
const typeDefinitions = `declare const context: {
|
|
31
|
+
artifacts: {
|
|
32
|
+
"integration-jira.issue": { key: string };
|
|
33
|
+
"integration-github.pr": { number: number };
|
|
34
|
+
valid: { ok: boolean };
|
|
35
|
+
};
|
|
36
|
+
};`;
|
|
37
|
+
expect(extractBracketKeyGroups({ typeDefinitions })).toEqual([
|
|
38
|
+
{
|
|
39
|
+
objectExpression: "context.artifacts",
|
|
40
|
+
keys: ["integration-jira.issue", "integration-github.pr"],
|
|
41
|
+
},
|
|
42
|
+
]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("does NOT treat string-literal union *values* as keys", () => {
|
|
46
|
+
const typeDefinitions = `declare const context: {
|
|
47
|
+
artifacts: {
|
|
48
|
+
"a-b": { status: "open" | "closed"; url: "https://x" };
|
|
49
|
+
};
|
|
50
|
+
};`;
|
|
51
|
+
expect(extractBracketKeyGroups({ typeDefinitions })).toEqual([
|
|
52
|
+
{ objectExpression: "context.artifacts", keys: ["a-b"] },
|
|
53
|
+
]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("ignores JSDoc and line comments (including braces inside them)", () => {
|
|
57
|
+
const typeDefinitions = `declare const context: {
|
|
58
|
+
/** an object like { not: a real brace } */
|
|
59
|
+
artifacts: {
|
|
60
|
+
// "comment-key": should be ignored
|
|
61
|
+
"real-key": { x: string };
|
|
62
|
+
};
|
|
63
|
+
};`;
|
|
64
|
+
expect(extractBracketKeyGroups({ typeDefinitions })).toEqual([
|
|
65
|
+
{ objectExpression: "context.artifacts", keys: ["real-key"] },
|
|
66
|
+
]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("handles `readonly` and optional `?` modifiers and deeper paths", () => {
|
|
70
|
+
const typeDefinitions = `declare const context: {
|
|
71
|
+
readonly event: {
|
|
72
|
+
readonly meta?: {
|
|
73
|
+
"x-trace.id": { v: string };
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
};`;
|
|
77
|
+
expect(extractBracketKeyGroups({ typeDefinitions })).toEqual([
|
|
78
|
+
{ objectExpression: "context.event.meta", keys: ["x-trace.id"] },
|
|
79
|
+
]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("skips non-identifier keys nested under a non-identifier parent", () => {
|
|
83
|
+
// `context["a-b"]` is not a dotted objectExpression, so the inner key has
|
|
84
|
+
// no addressable parent and must be skipped.
|
|
85
|
+
const typeDefinitions = `declare const context: {
|
|
86
|
+
"a-b": {
|
|
87
|
+
"c-d": { x: string };
|
|
88
|
+
};
|
|
89
|
+
};`;
|
|
90
|
+
expect(extractBracketKeyGroups({ typeDefinitions })).toEqual([
|
|
91
|
+
{ objectExpression: "context", keys: ["a-b"] },
|
|
92
|
+
]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("extracts OPTIONAL quoted keys (`\"key\"?:`) - the real generated shape", () => {
|
|
96
|
+
// Mirrors generateAutomationContextTypes output for upstream artifacts:
|
|
97
|
+
// `readonly artifacts: { readonly "integration-jira.issue"?: {...}; }`.
|
|
98
|
+
const typeDefinitions = `declare const context: {
|
|
99
|
+
readonly artifacts: {
|
|
100
|
+
readonly "integration-jira.issue"?: { key: string; url?: string };
|
|
101
|
+
readonly "integration-github.pr"?: { number: number };
|
|
102
|
+
};
|
|
103
|
+
};`;
|
|
104
|
+
expect(extractBracketKeyGroups({ typeDefinitions })).toEqual([
|
|
105
|
+
{
|
|
106
|
+
objectExpression: "context.artifacts",
|
|
107
|
+
keys: ["integration-jira.issue", "integration-github.pr"],
|
|
108
|
+
},
|
|
109
|
+
]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("respects a custom root name", () => {
|
|
113
|
+
const typeDefinitions = `declare const scope: {
|
|
114
|
+
vars: { "weird key": { x: string } };
|
|
115
|
+
};`;
|
|
116
|
+
expect(
|
|
117
|
+
extractBracketKeyGroups({ typeDefinitions, rootName: "scope" }),
|
|
118
|
+
).toEqual([{ objectExpression: "scope.vars", keys: ["weird key"] }]);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// Stage 2b (monaco-editor -> @typefox/monaco-editor-react migration).
|
|
2
|
+
//
|
|
3
|
+
// The standalone TS worker calls `getCompletionsAtPosition` with no options
|
|
4
|
+
// (tsWorker.js: `getCompletionsAtPosition(fileName, position, void 0)`), so it
|
|
5
|
+
// never surfaces object members whose keys are not valid JS identifiers (e.g.
|
|
6
|
+
// artifact ids like `integration-jira.issue`). The built-in SuggestAdapter also
|
|
7
|
+
// hardcodes `insertText: entry.name` and can't be overridden, so a custom TS
|
|
8
|
+
// worker is not viable. Instead we register our own completion provider that
|
|
9
|
+
// offers those keys as `["key"]` bracket completions.
|
|
10
|
+
//
|
|
11
|
+
// To stay TYPE-DRIVEN (and avoid threading a separate `dottedKeyCompletions`
|
|
12
|
+
// prop), this module derives the groups straight from the injected
|
|
13
|
+
// `context.d.ts`: it finds object members whose property name is a quoted,
|
|
14
|
+
// non-identifier string and reports the dotted access path to their parent
|
|
15
|
+
// object plus the list of such keys.
|
|
16
|
+
|
|
17
|
+
/** A parent object plus the non-identifier keys reachable under it. */
|
|
18
|
+
export interface BracketKeyGroup {
|
|
19
|
+
/** Member-access expression preceding the dot, e.g. `context.artifacts`. */
|
|
20
|
+
objectExpression: string;
|
|
21
|
+
/** Keys to offer; each is inserted as `["<key>"]`. */
|
|
22
|
+
keys: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const IDENTIFIER_RE = /^[A-Za-z_$][\w$]*$/;
|
|
26
|
+
|
|
27
|
+
const isIdentifier = (name: string): boolean => IDENTIFIER_RE.test(name);
|
|
28
|
+
|
|
29
|
+
const isWhitespace = (ch: string): boolean =>
|
|
30
|
+
ch === " " || ch === "\t" || ch === "\n" || ch === "\r";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Extract bracket-notation completion groups from generated TS type
|
|
34
|
+
* definitions. Walks the `declare const <rootName>: { ... }` object literal,
|
|
35
|
+
* tracking the dotted path as it descends, and records every property whose
|
|
36
|
+
* name is a quoted non-identifier string.
|
|
37
|
+
*
|
|
38
|
+
* The walk is a small purpose-built scanner (not a full TS parser): it is only
|
|
39
|
+
* ever fed our own generated `context.d.ts`, whose shape is a plain nested
|
|
40
|
+
* object type. It correctly ignores string-literal type *values* (e.g.
|
|
41
|
+
* `status: "open" | "closed"`), JSDoc/line comments, and the `readonly` /
|
|
42
|
+
* optional (`?`) modifiers. Keys nested under a non-identifier parent are
|
|
43
|
+
* skipped because no dotted `objectExpression` can address them.
|
|
44
|
+
*/
|
|
45
|
+
export const extractBracketKeyGroups = ({
|
|
46
|
+
typeDefinitions,
|
|
47
|
+
rootName = "context",
|
|
48
|
+
}: {
|
|
49
|
+
typeDefinitions: string;
|
|
50
|
+
rootName?: string;
|
|
51
|
+
}): BracketKeyGroup[] => {
|
|
52
|
+
const src = typeDefinitions;
|
|
53
|
+
// Locate `declare const <rootName>` then the opening `{` of its type.
|
|
54
|
+
const declMatch = new RegExp(
|
|
55
|
+
String.raw`declare\s+const\s+${rootName}\b`,
|
|
56
|
+
).exec(src);
|
|
57
|
+
if (!declMatch) {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
let i = src.indexOf("{", declMatch.index + declMatch[0].length);
|
|
61
|
+
if (i === -1) {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Insertion-ordered accumulation so output is stable for tests/snapshots.
|
|
66
|
+
const groups = new Map<string, string[]>();
|
|
67
|
+
const addKey = (objectExpression: string, key: string): void => {
|
|
68
|
+
const existing = groups.get(objectExpression);
|
|
69
|
+
if (existing) {
|
|
70
|
+
if (!existing.includes(key)) {
|
|
71
|
+
existing.push(key);
|
|
72
|
+
}
|
|
73
|
+
} else {
|
|
74
|
+
groups.set(objectExpression, [key]);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// path[k] / dottable[k] describe the object open at brace depth k+1.
|
|
79
|
+
const path: string[] = [rootName];
|
|
80
|
+
const dottable: boolean[] = [true];
|
|
81
|
+
let depth = 1; // we consume the root `{` below
|
|
82
|
+
i += 1; // step past the root object's opening brace
|
|
83
|
+
|
|
84
|
+
// The property name most recently parsed, awaiting its value. Used to label
|
|
85
|
+
// the object we descend into on the next `{`.
|
|
86
|
+
let pendingName: { value: string; identifier: boolean } | null = null;
|
|
87
|
+
|
|
88
|
+
const peekNonWs = (from: number): number => {
|
|
89
|
+
let j = from;
|
|
90
|
+
while (j < src.length && isWhitespace(src[j] ?? "")) {
|
|
91
|
+
j += 1;
|
|
92
|
+
}
|
|
93
|
+
return j;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
while (i < src.length && depth > 0) {
|
|
97
|
+
const ch = src[i] ?? "";
|
|
98
|
+
|
|
99
|
+
// Comments.
|
|
100
|
+
if (ch === "/" && src[i + 1] === "*") {
|
|
101
|
+
const end = src.indexOf("*/", i + 2);
|
|
102
|
+
i = end === -1 ? src.length : end + 2;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (ch === "/" && src[i + 1] === "/") {
|
|
106
|
+
const nl = src.indexOf("\n", i + 2);
|
|
107
|
+
i = nl === -1 ? src.length : nl + 1;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (isWhitespace(ch)) {
|
|
112
|
+
i += 1;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// String token: either a property name (if followed by `:`) or a
|
|
117
|
+
// type-position literal (e.g. a union member) which we ignore.
|
|
118
|
+
if (ch === '"' || ch === "'") {
|
|
119
|
+
const quote = ch;
|
|
120
|
+
let j = i + 1;
|
|
121
|
+
let str = "";
|
|
122
|
+
while (j < src.length && src[j] !== quote) {
|
|
123
|
+
if (src[j] === "\\") {
|
|
124
|
+
str += src[j + 1] ?? "";
|
|
125
|
+
j += 2;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
str += src[j];
|
|
129
|
+
j += 1;
|
|
130
|
+
}
|
|
131
|
+
const afterStr = j + 1; // past closing quote
|
|
132
|
+
// A quoted property name may be optional: `"key"?: ...` (this is exactly
|
|
133
|
+
// how generated artifact keys look, e.g. `"integration-jira.issue"?:`).
|
|
134
|
+
let colon = peekNonWs(afterStr);
|
|
135
|
+
if (src[colon] === "?") {
|
|
136
|
+
colon = peekNonWs(colon + 1);
|
|
137
|
+
}
|
|
138
|
+
if (src[colon] === ":") {
|
|
139
|
+
// Quoted property name. Record under the current object when every
|
|
140
|
+
// ancestor segment is dot-addressable.
|
|
141
|
+
if (dottable.every(Boolean) && !isIdentifier(str)) {
|
|
142
|
+
addKey(path.join("."), str);
|
|
143
|
+
}
|
|
144
|
+
pendingName = { value: str, identifier: isIdentifier(str) };
|
|
145
|
+
i = colon + 1;
|
|
146
|
+
} else {
|
|
147
|
+
// Type-position string literal - not a key.
|
|
148
|
+
i = afterStr;
|
|
149
|
+
}
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Identifier: a property name (if followed by `:`, allowing an optional
|
|
154
|
+
// `?`) or a type keyword / modifier we skip.
|
|
155
|
+
if (/[A-Za-z_$]/.test(ch)) {
|
|
156
|
+
let j = i;
|
|
157
|
+
while (j < src.length && /[\w$]/.test(src[j] ?? "")) {
|
|
158
|
+
j += 1;
|
|
159
|
+
}
|
|
160
|
+
const word = src.slice(i, j);
|
|
161
|
+
let after = peekNonWs(j);
|
|
162
|
+
if (src[after] === "?") {
|
|
163
|
+
after = peekNonWs(after + 1);
|
|
164
|
+
}
|
|
165
|
+
if (src[after] === ":") {
|
|
166
|
+
pendingName = { value: word, identifier: true };
|
|
167
|
+
i = after + 1;
|
|
168
|
+
} else {
|
|
169
|
+
// `readonly`, `string`, `interface`, etc. - not a key for us.
|
|
170
|
+
i = j;
|
|
171
|
+
}
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (ch === "{") {
|
|
176
|
+
depth += 1;
|
|
177
|
+
path.push(pendingName?.value ?? "");
|
|
178
|
+
dottable.push(pendingName?.identifier ?? false);
|
|
179
|
+
pendingName = null;
|
|
180
|
+
i += 1;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (ch === "}") {
|
|
185
|
+
depth -= 1;
|
|
186
|
+
path.pop();
|
|
187
|
+
dottable.pop();
|
|
188
|
+
pendingName = null;
|
|
189
|
+
i += 1;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// End of a property's value (e.g. `: string;`) - the pending name had a
|
|
194
|
+
// non-object type, so it never labels a descent.
|
|
195
|
+
if (ch === ";" || ch === ",") {
|
|
196
|
+
pendingName = null;
|
|
197
|
+
}
|
|
198
|
+
i += 1;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return [...groups].map(([objectExpression, keys]) => ({
|
|
202
|
+
objectExpression,
|
|
203
|
+
keys,
|
|
204
|
+
}));
|
|
205
|
+
};
|
|
@@ -89,10 +89,10 @@ declare const context: {
|
|
|
89
89
|
`);
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
// We deliberately don't redeclare `console`, `fetch`, the Node stdlib,
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
92
|
+
// We deliberately don't redeclare `console`, `fetch`, the Node stdlib, or
|
|
93
|
+
// the Bun globals here: the editor mounts the bundled upstream `@types/node`
|
|
94
|
+
// + `bun-types` declarations into the TypeScript service, so all of that is
|
|
95
|
+
// already in scope.
|
|
96
96
|
return lines.join("\n");
|
|
97
97
|
}
|
|
98
98
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
["assert","assert/strict","async_hooks","buffer","bun","bun:bundle","bun:ffi","bun:jsc","bun:sqlite","bun:test","child_process","cluster","console","constants","crypto","dgram","diagnostics_channel","dns","dns/promises","domain","events","fs","fs/promises","http","http2","https","inspector","inspector/promises","module","net","node:assert","node:assert/strict","node:async_hooks","node:buffer","node:child_process","node:cluster","node:console","node:constants","node:crypto","node:dgram","node:diagnostics_channel","node:dns","node:dns/promises","node:domain","node:events","node:fs","node:fs/promises","node:http","node:http2","node:https","node:inspector","node:inspector/promises","node:module","node:net","node:os","node:path","node:path/posix","node:path/win32","node:perf_hooks","node:process","node:punycode","node:querystring","node:readline","node:readline/promises","node:repl","node:sea","node:stream","node:stream/consumers","node:stream/promises","node:stream/web","node:string_decoder","node:test","node:test/reporters","node:timers","node:timers/promises","node:tls","node:trace_events","node:tty","node:url","node:util","node:util/types","node:v8","node:vm","node:wasi","node:worker_threads","node:zlib","os","path","path/posix","path/win32","perf_hooks","process","punycode","querystring","readline","readline/promises","repl","stream","stream/consumers","stream/promises","stream/web","string_decoder","timers","timers/promises","tls","trace_events","tty","url","util","util/types","v8","vm","wasi","worker_threads","zlib"]
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
classifyBuiltinModule,
|
|
4
|
+
extractBuiltinModuleSpecifiers,
|
|
5
|
+
importablePackageNames,
|
|
6
|
+
importSpecifierCompletionContext,
|
|
7
|
+
mergeImportCompletionEntries,
|
|
8
|
+
packageNameFromSpecifier,
|
|
9
|
+
parseBareImportSpecifiers,
|
|
10
|
+
planAcquisitions,
|
|
11
|
+
} from "./importSpecifiers";
|
|
12
|
+
|
|
13
|
+
describe("packageNameFromSpecifier", () => {
|
|
14
|
+
test("plain", () => {
|
|
15
|
+
expect(packageNameFromSpecifier("lodash")).toBe("lodash");
|
|
16
|
+
});
|
|
17
|
+
test("subpath", () => {
|
|
18
|
+
expect(packageNameFromSpecifier("lodash/fp")).toBe("lodash");
|
|
19
|
+
});
|
|
20
|
+
test("scoped", () => {
|
|
21
|
+
expect(packageNameFromSpecifier("@babel/core")).toBe("@babel/core");
|
|
22
|
+
});
|
|
23
|
+
test("scoped subpath", () => {
|
|
24
|
+
expect(packageNameFromSpecifier("@scope/pkg/sub/deep")).toBe("@scope/pkg");
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("parseBareImportSpecifiers", () => {
|
|
29
|
+
test("default, named, namespace, side-effect, type", () => {
|
|
30
|
+
const src = [
|
|
31
|
+
`import _ from "lodash";`,
|
|
32
|
+
`import { z } from "zod";`,
|
|
33
|
+
`import * as React from "react";`,
|
|
34
|
+
`import "side-effect-pkg";`,
|
|
35
|
+
`import type { Dayjs } from "dayjs";`,
|
|
36
|
+
].join("\n");
|
|
37
|
+
const specs = parseBareImportSpecifiers(src);
|
|
38
|
+
expect(specs.sort()).toEqual(
|
|
39
|
+
["dayjs", "lodash", "react", "side-effect-pkg", "zod"].sort(),
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("export-from, dynamic import, require", () => {
|
|
44
|
+
const src = [
|
|
45
|
+
`export { x } from "pkg-a";`,
|
|
46
|
+
`export * from "pkg-b";`,
|
|
47
|
+
`const p = await import("pkg-c");`,
|
|
48
|
+
`const q = require("pkg-d");`,
|
|
49
|
+
].join("\n");
|
|
50
|
+
expect(parseBareImportSpecifiers(src).sort()).toEqual(
|
|
51
|
+
["pkg-a", "pkg-b", "pkg-c", "pkg-d"].sort(),
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("ignores relative, node:, bun, and context", () => {
|
|
56
|
+
const src = [
|
|
57
|
+
`import a from "./local";`,
|
|
58
|
+
`import b from "../up";`,
|
|
59
|
+
`import { readFile } from "node:fs";`,
|
|
60
|
+
`import { Glob } from "bun";`,
|
|
61
|
+
`const c = context.trigger;`,
|
|
62
|
+
`import "context";`,
|
|
63
|
+
].join("\n");
|
|
64
|
+
expect(parseBareImportSpecifiers(src)).toEqual([]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("scoped + subpath reduce to package name, deduped", () => {
|
|
68
|
+
const src = [
|
|
69
|
+
`import { debounce } from "lodash/debounce";`,
|
|
70
|
+
`import _ from "lodash";`,
|
|
71
|
+
`import { transform } from "@babel/core/lib/x";`,
|
|
72
|
+
].join("\n");
|
|
73
|
+
expect(parseBareImportSpecifiers(src).sort()).toEqual(
|
|
74
|
+
["@babel/core", "lodash"].sort(),
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("the lodash named-import case (acceptance)", () => {
|
|
79
|
+
expect(parseBareImportSpecifiers(`import { debounce } from "lodash";`)).toEqual(
|
|
80
|
+
["lodash"],
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("planAcquisitions", () => {
|
|
86
|
+
test("returns only new specifiers, order-stable, deduped", () => {
|
|
87
|
+
const plan = planAcquisitions({
|
|
88
|
+
specifiers: ["lodash", "zod", "lodash", "react"],
|
|
89
|
+
acquired: new Set(["zod"]),
|
|
90
|
+
});
|
|
91
|
+
expect(plan).toEqual(["lodash", "react"]);
|
|
92
|
+
});
|
|
93
|
+
test("empty when all acquired", () => {
|
|
94
|
+
expect(
|
|
95
|
+
planAcquisitions({
|
|
96
|
+
specifiers: ["a", "b"],
|
|
97
|
+
acquired: new Set(["a", "b"]),
|
|
98
|
+
}),
|
|
99
|
+
).toEqual([]);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("importSpecifierCompletionContext", () => {
|
|
104
|
+
// The `partial` text starts just after the opening quote; the
|
|
105
|
+
// `replaceFromColumn` is its 1-based start column. For a line like
|
|
106
|
+
// `import {} from "lod` the partial is `lod` starting at column 17.
|
|
107
|
+
test("from-clause double quote", () => {
|
|
108
|
+
const line = `import {} from "lod`;
|
|
109
|
+
const ctx = importSpecifierCompletionContext(line);
|
|
110
|
+
expect(ctx).toEqual({ partial: "lod", replaceFromColumn: 17 });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("from-clause single quote", () => {
|
|
114
|
+
const line = `import x from 'lo`;
|
|
115
|
+
expect(importSpecifierCompletionContext(line)).toEqual({
|
|
116
|
+
partial: "lo",
|
|
117
|
+
replaceFromColumn: 16,
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("namespace import (import * as x from)", () => {
|
|
122
|
+
const line = `import * as _ from "lod`;
|
|
123
|
+
expect(importSpecifierCompletionContext(line)?.partial).toBe("lod");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("export ... from", () => {
|
|
127
|
+
const line = `export { x } from "lod`;
|
|
128
|
+
expect(importSpecifierCompletionContext(line)?.partial).toBe("lod");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("bare side-effect import", () => {
|
|
132
|
+
const line = `import "lo`;
|
|
133
|
+
expect(importSpecifierCompletionContext(line)).toEqual({
|
|
134
|
+
partial: "lo",
|
|
135
|
+
replaceFromColumn: 9,
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("dynamic import()", () => {
|
|
140
|
+
const line = `const m = await import("lo`;
|
|
141
|
+
expect(importSpecifierCompletionContext(line)?.partial).toBe("lo");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("require()", () => {
|
|
145
|
+
const line = `const m = require("lo`;
|
|
146
|
+
expect(importSpecifierCompletionContext(line)?.partial).toBe("lo");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("scoped partial keeps the slash", () => {
|
|
150
|
+
const line = `import {} from "@scope/`;
|
|
151
|
+
expect(importSpecifierCompletionContext(line)?.partial).toBe("@scope/");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("empty partial right after the opening quote", () => {
|
|
155
|
+
// `import {} from "` is 16 chars; the cursor sits at column 17 where the
|
|
156
|
+
// (empty) specifier begins.
|
|
157
|
+
const line = `import {} from "`;
|
|
158
|
+
expect(importSpecifierCompletionContext(line)).toEqual({
|
|
159
|
+
partial: "",
|
|
160
|
+
replaceFromColumn: 17,
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("returns null when not in an import string", () => {
|
|
165
|
+
expect(importSpecifierCompletionContext(`const x = 1;`)).toBeNull();
|
|
166
|
+
expect(importSpecifierCompletionContext(`const s = "hello`)).toBeNull();
|
|
167
|
+
expect(importSpecifierCompletionContext(`foo("bar`)).toBeNull();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("returns null once the import string is already closed", () => {
|
|
171
|
+
expect(
|
|
172
|
+
importSpecifierCompletionContext(`import {} from "lodash"`),
|
|
173
|
+
).toBeNull();
|
|
174
|
+
expect(importSpecifierCompletionContext(`import "lodash";`)).toBeNull();
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("importablePackageNames", () => {
|
|
179
|
+
test("excludes @types/* companions", () => {
|
|
180
|
+
expect(
|
|
181
|
+
importablePackageNames(["lodash", "@types/lodash", "zod"]),
|
|
182
|
+
).toEqual(["lodash", "zod"]);
|
|
183
|
+
});
|
|
184
|
+
test("dedupes and sorts", () => {
|
|
185
|
+
expect(
|
|
186
|
+
importablePackageNames(["zod", "lodash", "lodash", "@babel/core"]),
|
|
187
|
+
).toEqual(["@babel/core", "lodash", "zod"]);
|
|
188
|
+
});
|
|
189
|
+
test("scoped non-@types names are kept", () => {
|
|
190
|
+
expect(importablePackageNames(["@babel/core", "@types/babel__core"])).toEqual(
|
|
191
|
+
["@babel/core"],
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe("extractBuiltinModuleSpecifiers", () => {
|
|
197
|
+
// A small fixture mirroring how @types/node + bun-types declare importable
|
|
198
|
+
// modules (top-level `declare module "<spec>"`) alongside wildcard / asset
|
|
199
|
+
// shims that must be filtered out.
|
|
200
|
+
const fixture = [
|
|
201
|
+
`declare module "node:fs" { export function readFile(): void; }`,
|
|
202
|
+
`declare module "fs" { export function readFile(): void; }`,
|
|
203
|
+
`declare module 'node:path' { export const sep: string; }`,
|
|
204
|
+
`declare module "bun" { export const version: string; }`,
|
|
205
|
+
`declare module "bun:test" { export function test(): void; }`,
|
|
206
|
+
// Noise that must be dropped:
|
|
207
|
+
`declare module "*" { const c: unknown; export default c; }`,
|
|
208
|
+
`declare module "*.css" { const c: string; export default c; }`,
|
|
209
|
+
`declare module "*.txt" { const c: string; export default c; }`,
|
|
210
|
+
].join("\n");
|
|
211
|
+
|
|
212
|
+
test("keeps real importable specifiers, drops wildcard/asset shims", () => {
|
|
213
|
+
expect(extractBuiltinModuleSpecifiers(fixture)).toEqual([
|
|
214
|
+
"bun",
|
|
215
|
+
"bun:test",
|
|
216
|
+
"fs",
|
|
217
|
+
"node:fs",
|
|
218
|
+
"node:path",
|
|
219
|
+
]);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("dedupes a repeated declaration", () => {
|
|
223
|
+
const src = `declare module "fs" {}\ndeclare module "fs" {}`;
|
|
224
|
+
expect(extractBuiltinModuleSpecifiers(src)).toEqual(["fs"]);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("does not match an augmentation indented as a non-statement", () => {
|
|
228
|
+
// A `declare module` nested under another block won't be at line start;
|
|
229
|
+
// our anchor (`^\s*`) still allows leading whitespace, which is correct
|
|
230
|
+
// for real top-level declarations. This asserts the simple line-start
|
|
231
|
+
// behavior: a name on its own line is captured.
|
|
232
|
+
const src = ` declare module "node:os" {}`;
|
|
233
|
+
expect(extractBuiltinModuleSpecifiers(src)).toEqual(["node:os"]);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("empty input yields empty list", () => {
|
|
237
|
+
expect(extractBuiltinModuleSpecifiers("")).toEqual([]);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe("classifyBuiltinModule", () => {
|
|
242
|
+
test("bun and bun: specifiers are Bun built-ins", () => {
|
|
243
|
+
expect(classifyBuiltinModule("bun").detail).toBe("Bun built-in");
|
|
244
|
+
expect(classifyBuiltinModule("bun:test").detail).toBe("Bun built-in");
|
|
245
|
+
});
|
|
246
|
+
test("node and bare builtins are Node.js", () => {
|
|
247
|
+
expect(classifyBuiltinModule("node:fs").detail).toBe("Node.js");
|
|
248
|
+
expect(classifyBuiltinModule("fs").detail).toBe("Node.js");
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe("mergeImportCompletionEntries", () => {
|
|
253
|
+
test("built-ins always present; installed packages merged; sorted", () => {
|
|
254
|
+
const merged = mergeImportCompletionEntries({
|
|
255
|
+
builtins: ["node:fs", "bun"],
|
|
256
|
+
installedPackages: ["lodash", "zod"],
|
|
257
|
+
});
|
|
258
|
+
expect(merged.map((e) => e.name)).toEqual([
|
|
259
|
+
"bun",
|
|
260
|
+
"lodash",
|
|
261
|
+
"node:fs",
|
|
262
|
+
"zod",
|
|
263
|
+
]);
|
|
264
|
+
expect(merged.find((e) => e.name === "bun")?.detail).toBe("Bun built-in");
|
|
265
|
+
expect(merged.find((e) => e.name === "node:fs")?.detail).toBe("Node.js");
|
|
266
|
+
expect(merged.find((e) => e.name === "lodash")?.detail).toBe(
|
|
267
|
+
"installed package",
|
|
268
|
+
);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("built-ins present even with an empty allowlist", () => {
|
|
272
|
+
const merged = mergeImportCompletionEntries({
|
|
273
|
+
builtins: ["node:fs", "bun"],
|
|
274
|
+
installedPackages: [],
|
|
275
|
+
});
|
|
276
|
+
expect(merged.map((e) => e.name)).toEqual(["bun", "node:fs"]);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("an installed package colliding with a built-in is labelled installed", () => {
|
|
280
|
+
const merged = mergeImportCompletionEntries({
|
|
281
|
+
builtins: ["fs"],
|
|
282
|
+
installedPackages: ["fs"],
|
|
283
|
+
});
|
|
284
|
+
expect(merged).toEqual([{ name: "fs", detail: "installed package" }]);
|
|
285
|
+
});
|
|
286
|
+
});
|