@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.
Files changed (72) hide show
  1. package/.storybook/main.ts +43 -0
  2. package/CHANGELOG.md +565 -0
  3. package/package.json +15 -7
  4. package/scripts/generate-stdlib-types.ts +25 -2
  5. package/src/components/ActionCard.tsx +309 -0
  6. package/src/components/CodeEditor/CodeEditor.tsx +132 -9
  7. package/src/components/CodeEditor/TypefoxEditor.tsx +1024 -0
  8. package/src/components/CodeEditor/bracketKeyGroups.test.ts +120 -0
  9. package/src/components/CodeEditor/bracketKeyGroups.ts +205 -0
  10. package/src/components/CodeEditor/generateTypeDefinitions.ts +4 -4
  11. package/src/components/CodeEditor/generated/builtin-modules.json +1 -0
  12. package/src/components/CodeEditor/importSpecifiers.test.ts +286 -0
  13. package/src/components/CodeEditor/importSpecifiers.ts +267 -0
  14. package/src/components/CodeEditor/index.ts +26 -0
  15. package/src/components/CodeEditor/monacoTsService.ts +217 -0
  16. package/src/components/CodeEditor/popoutTitle.test.ts +37 -0
  17. package/src/components/CodeEditor/popoutTitle.ts +31 -0
  18. package/src/components/CodeEditor/scriptContext.test.ts +41 -0
  19. package/src/components/CodeEditor/scriptContext.ts +76 -1
  20. package/src/components/CodeEditor/scriptDiagnostics.test.ts +135 -0
  21. package/src/components/CodeEditor/scriptDiagnostics.ts +172 -0
  22. package/src/components/CodeEditor/templateValidation.ts +51 -0
  23. package/src/components/CodeEditor/types.ts +168 -0
  24. package/src/components/CodeEditor/validateJsonTemplate.test.ts +61 -0
  25. package/src/components/CodeEditor/validateJsonTemplate.ts +26 -0
  26. package/src/components/CodeEditor/validateScripts.ts +132 -0
  27. package/src/components/CodeEditor/validateXmlTemplate.test.ts +34 -0
  28. package/src/components/CodeEditor/validateXmlTemplate.ts +35 -0
  29. package/src/components/CodeEditor/validateYamlTemplate.test.ts +39 -0
  30. package/src/components/CodeEditor/validateYamlTemplate.ts +28 -0
  31. package/src/components/Dialog.tsx +32 -11
  32. package/src/components/DurationInput.tsx +121 -0
  33. package/src/components/DynamicForm/DynamicForm.tsx +27 -1
  34. package/src/components/DynamicForm/FormField.tsx +138 -10
  35. package/src/components/DynamicForm/KeyValueEditor.tsx +2 -169
  36. package/src/components/DynamicForm/MultiTypeEditorField.tsx +83 -9
  37. package/src/components/DynamicForm/SecretEnvEditor.tsx +315 -0
  38. package/src/components/DynamicForm/index.ts +6 -0
  39. package/src/components/DynamicForm/secretEnv.logic.test.ts +126 -0
  40. package/src/components/DynamicForm/secretEnv.logic.ts +87 -0
  41. package/src/components/DynamicForm/types.ts +83 -1
  42. package/src/components/DynamicForm/utils.ts +32 -0
  43. package/src/components/Popover.tsx +6 -1
  44. package/src/components/ScriptTestPanel.logic.test.ts +139 -0
  45. package/src/components/ScriptTestPanel.logic.ts +137 -0
  46. package/src/components/ScriptTestPanel.tsx +394 -0
  47. package/src/components/Sheet.tsx +21 -6
  48. package/src/components/TemplateInput.tsx +104 -0
  49. package/src/components/TemplateInputToggle.tsx +111 -0
  50. package/src/components/TemplateValueInput.test.ts +98 -0
  51. package/src/components/TemplateValueInput.tsx +470 -0
  52. package/src/components/TimeOfDayInput.tsx +116 -0
  53. package/src/components/VariablePicker.tsx +271 -0
  54. package/src/components/comboboxInteraction.ts +39 -0
  55. package/src/components/portalContainer.ts +24 -0
  56. package/src/hooks/useInitOnceForKey.test.ts +27 -0
  57. package/src/hooks/useInitOnceForKey.ts +21 -18
  58. package/src/index.ts +9 -0
  59. package/stories/ActionCard.stories.tsx +122 -0
  60. package/stories/Alert.stories.tsx +5 -5
  61. package/stories/CodeEditor.stories.tsx +47 -2
  62. package/stories/DurationInput.stories.tsx +59 -0
  63. package/stories/ScriptTestPanel.stories.tsx +106 -0
  64. package/stories/SecretEnvEditor.stories.tsx +80 -0
  65. package/stories/TemplateInputToggle.stories.tsx +77 -0
  66. package/stories/TemplateValueInput.stories.tsx +65 -0
  67. package/stories/TimeOfDayInput.stories.tsx +34 -0
  68. package/stories/VariablePicker.stories.tsx +109 -0
  69. package/tsconfig.json +1 -0
  70. package/src/components/CodeEditor/MonacoEditor.tsx +0 -616
  71. package/src/components/CodeEditor/monacoStdlib.ts +0 -62
  72. 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
- // or the Bun globals here MonacoEditor mounts the bundled upstream
94
- // `@types/node` + `bun-types` declarations into Monaco's virtual
95
- // filesystem via `ensureMonacoStdlib`, so all of that is already in scope.
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
+ });