@embeddable.com/sdk-core 4.2.0 → 4.3.0-next.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.
@@ -1,4 +1,7 @@
1
- import generate from "./generate";
1
+ import generate, {
2
+ resetForTesting, TRIGGER_BUILD_ITERATION_LIMIT,
3
+ triggerWebComponentRebuild, generateDTS
4
+ } from "./generate";
2
5
  import * as fs from "node:fs/promises";
3
6
  import * as path from "node:path";
4
7
  import { checkNodeVersion } from "./utils";
@@ -52,6 +55,10 @@ vi.mock("node:fs/promises", () => ({
52
55
  rename: vi.fn(),
53
56
  cp: vi.fn(),
54
57
  rm: vi.fn(),
58
+ copyFile: vi.fn(),
59
+ stat: vi.fn(),
60
+ truncate: vi.fn(),
61
+ appendFile: vi.fn(),
55
62
  }));
56
63
 
57
64
  vi.mock("node:path", async () => {
@@ -69,6 +76,10 @@ vi.mock("@stencil/core/compiler", () => ({
69
76
  }));
70
77
 
71
78
  describe("generate", () => {
79
+ const watcherMock = vi.fn().mockResolvedValue({
80
+ hasError: false,
81
+ on: vi.fn(),
82
+ });
72
83
  beforeEach(() => {
73
84
  vi.mocked(checkNodeVersion).mockResolvedValue(true);
74
85
  vi.mocked(fs.readdir).mockResolvedValue([
@@ -86,6 +97,7 @@ describe("generate", () => {
86
97
  hasError: false,
87
98
  }),
88
99
  destroy: vi.fn(),
100
+ createWatcher: watcherMock,
89
101
  } as any);
90
102
 
91
103
  vi.mocked(getContentHash).mockReturnValue("hash");
@@ -124,7 +136,9 @@ describe("generate", () => {
124
136
  dev: {
125
137
  watch: true,
126
138
  logger: vi.fn(),
127
- sys: vi.fn(),
139
+ sys: vi.mocked({
140
+ onProcessInterrupt: vi.fn(),
141
+ }),
128
142
  },
129
143
  };
130
144
 
@@ -134,6 +148,7 @@ describe("generate", () => {
134
148
  await generate(ctx as unknown as ResolvedEmbeddableConfig, "sdk-react");
135
149
 
136
150
  expect(createCompiler).toHaveBeenCalled();
151
+ expect(watcherMock).toHaveBeenCalled();
137
152
 
138
153
  expect(fs.writeFile).toHaveBeenCalledWith(
139
154
  "componentDir/component.tsx",
@@ -144,6 +159,7 @@ describe("generate", () => {
144
159
  config: {
145
160
  configPath: "webComponentRoot/stencil.config.ts",
146
161
  devMode: true,
162
+ watchIgnoredRegex: [/\.css$/, /\.d\.ts$/, /\.js$/],
147
163
  maxConcurrentWorkers: process.platform === "win32" ? 0 : 8,
148
164
  minifyCss: false,
149
165
  minifyJs: false,
@@ -161,7 +177,175 @@ describe("generate", () => {
161
177
  },
162
178
  initTsConfig: true,
163
179
  logger: expect.any(Function),
164
- sys: expect.any(Function),
180
+ sys: {
181
+ onProcessInterrupt: expect.any(Function),
182
+ },
165
183
  });
166
184
  });
167
185
  });
186
+
187
+ describe("triggerWebComponentRebuild", () => {
188
+ beforeEach(() => {
189
+ vi.clearAllMocks();
190
+ resetForTesting();
191
+ });
192
+
193
+ it("should store original file stats on first call and append file", async () => {
194
+ const mockStats = { size: 123 };
195
+ vi.mocked(fs.stat).mockResolvedValue(mockStats as any);
196
+
197
+ await triggerWebComponentRebuild(
198
+ config as unknown as ResolvedEmbeddableConfig,
199
+ );
200
+
201
+ const filePath = path.resolve(config.client.componentDir, "component.tsx");
202
+ expect(fs.stat).toHaveBeenCalledWith(filePath);
203
+ expect(fs.appendFile).toHaveBeenCalledWith(filePath, " ");
204
+ });
205
+
206
+ it("should append file and not call stat after first build", async () => {
207
+ const mockStats = { size: 123 };
208
+ vi.mocked(fs.stat).mockResolvedValue(mockStats as any);
209
+
210
+ for (let i = 0; i < 3; i++) {
211
+ await triggerWebComponentRebuild(
212
+ config as unknown as ResolvedEmbeddableConfig,
213
+ );
214
+ }
215
+
216
+ expect(fs.stat).toHaveBeenCalledTimes(1); // only once
217
+ expect(fs.appendFile).toHaveBeenCalledTimes(3);
218
+ expect(fs.truncate).not.toHaveBeenCalled();
219
+ });
220
+
221
+ it("should reset file using truncate on the 6th call and reset count", async () => {
222
+ const mockStats = { size: 321 };
223
+ vi.mocked(fs.stat).mockResolvedValue(mockStats as any);
224
+ vi.mocked(path.resolve).mockReturnValue("componentDir/component.tsx");
225
+
226
+ for (let i = 0; i < TRIGGER_BUILD_ITERATION_LIMIT; i++) {
227
+ await triggerWebComponentRebuild(
228
+ config as unknown as ResolvedEmbeddableConfig,
229
+ );
230
+ }
231
+
232
+ expect(fs.truncate).not.toHaveBeenCalled();
233
+
234
+ vi.mocked(fs.appendFile).mockClear();
235
+ vi.mocked(fs.truncate).mockClear();
236
+
237
+ // now truncate should be called
238
+ await triggerWebComponentRebuild(
239
+ config as unknown as ResolvedEmbeddableConfig,
240
+ );
241
+ const filePath = path.resolve(config.client.componentDir, "component.tsx");
242
+
243
+ expect(fs.truncate).toHaveBeenCalledWith(filePath, mockStats.size);
244
+ expect(fs.appendFile).not.toHaveBeenCalledWith(filePath, " ");
245
+ });
246
+ });
247
+
248
+ describe("generateDTS", () => {
249
+ beforeEach(() => {
250
+ vi.mocked(fs.readdir).mockResolvedValue([
251
+ "embeddable-wrapper.esm.js",
252
+ ] as any);
253
+ vi.mocked(path.resolve).mockImplementation((...args) => args.join("/"));
254
+ // Template contains both tokens so we can verify replacement
255
+ vi.mocked(fs.readFile).mockResolvedValue(
256
+ "replace-this-with-component-name {{RENDER_IMPORT}}",
257
+ );
258
+ vi.mocked(loadConfig).mockResolvedValue({ config: {} } as any);
259
+ vi.mocked(createCompiler).mockResolvedValue({
260
+ build: vi.fn().mockResolvedValue({ hasError: false }),
261
+ destroy: vi.fn(),
262
+ createWatcher: vi.fn(),
263
+ } as any);
264
+ vi.mocked(findFiles).mockResolvedValue([["", ""]]);
265
+ Object.defineProperties(process, { chdir: { value: vi.fn() } });
266
+ });
267
+
268
+ it("should write an empty style.css", async () => {
269
+ await generateDTS(config as unknown as ResolvedEmbeddableConfig);
270
+
271
+ expect(fs.writeFile).toHaveBeenCalledWith(
272
+ "componentDir/style.css",
273
+ "",
274
+ );
275
+ });
276
+
277
+ it("should write component.tsx with stub render and embeddable-component tag", async () => {
278
+ await generateDTS(config as unknown as ResolvedEmbeddableConfig);
279
+
280
+ expect(fs.writeFile).toHaveBeenCalledWith(
281
+ "componentDir/component.tsx",
282
+ expect.stringContaining("embeddable-component"),
283
+ );
284
+ expect(fs.writeFile).toHaveBeenCalledWith(
285
+ "componentDir/component.tsx",
286
+ expect.stringContaining("const render = () => {};"),
287
+ );
288
+ });
289
+
290
+ it("should call loadConfig with devMode=false and sourceMap=false", async () => {
291
+ await generateDTS(config as unknown as ResolvedEmbeddableConfig);
292
+
293
+ expect(loadConfig).toHaveBeenCalledWith(
294
+ expect.objectContaining({
295
+ config: expect.objectContaining({
296
+ devMode: false,
297
+ sourceMap: false,
298
+ minifyJs: false,
299
+ minifyCss: false,
300
+ }),
301
+ }),
302
+ );
303
+ });
304
+
305
+ it("should not create a watcher (not watch mode)", async () => {
306
+ const createWatcherMock = vi.fn();
307
+ vi.mocked(createCompiler).mockResolvedValue({
308
+ build: vi.fn().mockResolvedValue({ hasError: false }),
309
+ destroy: vi.fn(),
310
+ createWatcher: createWatcherMock,
311
+ } as any);
312
+
313
+ await generateDTS(config as unknown as ResolvedEmbeddableConfig);
314
+
315
+ expect(createWatcherMock).not.toHaveBeenCalled();
316
+ });
317
+ });
318
+
319
+ describe("generate stencil build error", () => {
320
+ beforeEach(() => {
321
+ vi.mocked(path.resolve).mockImplementation((...args) => args.join("/"));
322
+ vi.mocked(fs.readFile).mockResolvedValue("");
323
+ vi.mocked(fs.readdir).mockResolvedValue([] as any);
324
+ vi.mocked(loadConfig).mockResolvedValue({ config: {} } as any);
325
+ vi.mocked(findFiles).mockResolvedValue([["", ""]]);
326
+ });
327
+
328
+ it("should throw when Stencil build has errors", async () => {
329
+ vi.mocked(createCompiler).mockResolvedValue({
330
+ build: vi.fn().mockResolvedValue({
331
+ hasError: true,
332
+ diagnostics: [{ messageText: "type error" }],
333
+ }),
334
+ destroy: vi.fn(),
335
+ createWatcher: vi.fn(),
336
+ } as any);
337
+
338
+ const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
339
+
340
+ await expect(
341
+ generate(config as unknown as ResolvedEmbeddableConfig, "sdk-react"),
342
+ ).rejects.toThrow("Stencil build error");
343
+
344
+ expect(consoleSpy).toHaveBeenCalledWith(
345
+ "Stencil build error:",
346
+ expect.anything(),
347
+ );
348
+
349
+ consoleSpy.mockRestore();
350
+ });
351
+ });
package/src/generate.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
3
  import { createNodeLogger, createNodeSys } from "@stencil/core/sys/node";
4
- import { createCompiler, loadConfig } from "@stencil/core/compiler";
4
+ import {
5
+ CompilerWatcher,
6
+ createCompiler,
7
+ loadConfig,
8
+ } from "@stencil/core/compiler";
5
9
  import { PluginName, ResolvedEmbeddableConfig } from "./defineConfig";
6
10
  import {
7
11
  findFiles,
@@ -9,6 +13,8 @@ import {
9
13
  } from "@embeddable.com/sdk-utils";
10
14
 
11
15
  import * as sorcery from "sorcery";
16
+ import { Stats } from "node:fs";
17
+ import type { Logger } from "@stencil/core/internal";
12
18
 
13
19
  const STYLE_IMPORTS_TOKEN = "{{STYLES_IMPORT}}";
14
20
  const RENDER_IMPORT_TOKEN = "{{RENDER_IMPORT}}";
@@ -16,19 +22,92 @@ const RENDER_IMPORT_TOKEN = "{{RENDER_IMPORT}}";
16
22
  // stencil doesn't support dynamic component tag name, so we need to replace it manually
17
23
  const COMPONENT_TAG_TOKEN = "replace-this-with-component-name";
18
24
 
25
+ let triggeredBuildCount = 0;
26
+ /**
27
+ * Stencil watcher doesnt react on file metadata changes,
28
+ * so we have to change the file content to trigger a rebuild by appending a space character.
29
+ * This constant defines how many times the space character can be appended before the file is truncated back to its original size.
30
+ */
31
+ export const TRIGGER_BUILD_ITERATION_LIMIT = 5;
32
+ let originalFileStats: Stats | null = null;
33
+
34
+ export function resetForTesting() {
35
+ triggeredBuildCount = 0;
36
+ originalFileStats = null;
37
+ }
38
+
39
+ /**
40
+ * Triggers a rebuild of a Stencil web component by modifying the `component.tsx` file.
41
+ *
42
+ * This function works by appending a space character to the file, which causes Stencil's watcher
43
+ * to detect a change and rebuild the component. After every TRIGGER_BUILD_ITERATION_LIMIT rebuilds, the file is truncated back
44
+ * to its original size to prevent indefinite growth and reset the internal rebuild counter.
45
+ *
46
+ * Append and truncate are used instead of rewriting the file to ensure minimal I/O overhead and preserve file metadata.
47
+ */
48
+ export async function triggerWebComponentRebuild(
49
+ ctx: ResolvedEmbeddableConfig,
50
+ ): Promise<void> {
51
+ const filePath = path.resolve(ctx.client.componentDir, "component.tsx");
52
+
53
+ if (triggeredBuildCount === 0) {
54
+ // store original file stats on the first build
55
+ originalFileStats = await fs.stat(filePath);
56
+ }
57
+
58
+ if (triggeredBuildCount === TRIGGER_BUILD_ITERATION_LIMIT && originalFileStats) {
59
+ await fs.truncate(filePath, originalFileStats.size);
60
+ triggeredBuildCount = 0; // reset the counter after resetting the file
61
+ } else {
62
+ await fs.appendFile(filePath, " ");
63
+ triggeredBuildCount++;
64
+ }
65
+ }
66
+
19
67
  export default async (
20
68
  ctx: ResolvedEmbeddableConfig,
21
69
  pluginName: PluginName,
22
- ) => {
70
+ ): Promise<void | CompilerWatcher> => {
23
71
  await injectCSS(ctx, pluginName);
24
72
 
25
73
  await injectBundleRender(ctx, pluginName);
26
74
 
27
- await runStencil(ctx);
75
+ const watcher = await runStencil(ctx);
76
+
77
+ if (watcher) {
78
+ watcher.on("buildFinish", () => {
79
+ // stencil always changes the working directory to the root of the web component.
80
+ // We need to change it back to the client root directory
81
+ process.chdir(ctx.client.rootDir);
82
+ generateSourceMap(ctx, pluginName);
83
+ });
84
+ } else {
85
+ await generateSourceMap(ctx, pluginName);
86
+ }
28
87
 
29
- await generateSourceMap(ctx, pluginName);
88
+ return watcher;
30
89
  };
31
90
 
91
+ /**
92
+ * Generates only the d.ts type declaration files using Stencil, without performing a full build.
93
+ * Used in dev mode to pre-generate types before the watcher starts, avoiding a double-build
94
+ * triggered by the watcher reacting to freshly generated d.ts files.
95
+ *
96
+ * Key differences from the default generate function:
97
+ * - Writes an empty style.css stub (no real CSS injection needed for type generation)
98
+ * - Injects a no-op render stub instead of the real render import
99
+ * - Always creates a fresh sys (never reuses ctx.dev?.sys) to avoid watcher interference
100
+ */
101
+ export async function generateDTS(
102
+ ctx: ResolvedEmbeddableConfig,
103
+ ): Promise<void> {
104
+ await injectEmptyCSS(ctx);
105
+
106
+ await injectBundleRenderStub(ctx);
107
+
108
+ await runStencil(ctx, { dtsOnly: true });
109
+ }
110
+
32
111
  async function injectCSS(
33
112
  ctx: ResolvedEmbeddableConfig,
34
113
  pluginName: PluginName,
@@ -39,12 +118,14 @@ async function injectCSS(
39
118
  );
40
119
  const allFiles = await fs.readdir(CUSTOMER_BUILD);
41
120
 
121
+ const importFilePath = path.relative(
122
+ ctx.client.componentDir,
123
+ path.resolve(ctx.client.buildDir, ctx[pluginName].outputOptions.buildName),
124
+ );
125
+
42
126
  const imports = allFiles
43
127
  .filter((fileName) => fileName.endsWith(".css"))
44
- .map(
45
- (fileName) =>
46
- `@import '../../${ctx[pluginName].outputOptions.buildName}/${fileName}';`,
47
- );
128
+ .map((fileName) => `@import '${importFilePath}/${fileName}';`);
48
129
 
49
130
  const componentLibraries = ctx.client.componentLibraries;
50
131
  for (const componentLibrary of componentLibraries) {
@@ -76,7 +157,11 @@ async function injectBundleRender(
76
157
  ctx: ResolvedEmbeddableConfig,
77
158
  pluginName: PluginName,
78
159
  ) {
79
- const importStr = `import render from '../../${ctx[pluginName].outputOptions.buildName}/${ctx[pluginName].outputOptions.fileName}';`;
160
+ const importFilePath = path.relative(
161
+ ctx.client.componentDir,
162
+ path.resolve(ctx.client.buildDir, ctx[pluginName].outputOptions.buildName),
163
+ );
164
+ const importStr = `import render from '${importFilePath}/${ctx[pluginName].outputOptions.fileName}';`;
80
165
 
81
166
  let content = await fs.readFile(
82
167
  path.resolve(ctx.core.templatesDir, "component.tsx.template"),
@@ -93,6 +178,27 @@ async function injectBundleRender(
93
178
  );
94
179
  }
95
180
 
181
+ async function injectEmptyCSS(ctx: ResolvedEmbeddableConfig) {
182
+ await fs.writeFile(path.resolve(ctx.client.componentDir, "style.css"), "");
183
+ }
184
+
185
+ async function injectBundleRenderStub(
186
+ ctx: ResolvedEmbeddableConfig,
187
+ ) {
188
+ const stubStr = `const render = () => {};`;
189
+
190
+ let content = await fs.readFile(
191
+ path.resolve(ctx.core.templatesDir, "component.tsx.template"),
192
+ "utf8",
193
+ );
194
+
195
+ content = content.replace(COMPONENT_TAG_TOKEN, "embeddable-component");
196
+ await fs.writeFile(
197
+ path.resolve(ctx.client.componentDir, "component.tsx"),
198
+ content.replace(RENDER_IMPORT_TOKEN, stubStr),
199
+ );
200
+ }
201
+
96
202
  async function addComponentTagName(filePath: string, bundleHash: string) {
97
203
  // find entry file with a name *.entry.js
98
204
  const entryFiles = await findFiles(path.dirname(filePath), /.*\.entry\.js/);
@@ -123,10 +229,20 @@ async function addComponentTagName(filePath: string, bundleHash: string) {
123
229
  ]);
124
230
  }
125
231
 
126
- async function runStencil(ctx: ResolvedEmbeddableConfig): Promise<void> {
127
- const logger = ctx.dev?.logger || createNodeLogger();
128
- const sys = ctx.dev?.sys || createNodeSys({ process });
129
- const devMode = !!ctx.dev?.watch;
232
+ async function runStencil(
233
+ ctx: ResolvedEmbeddableConfig,
234
+ options?: { dtsOnly?: boolean },
235
+ ): Promise<void | CompilerWatcher> {
236
+ const logger = (options?.dtsOnly ? createNodeLogger() : ctx.dev?.logger || createNodeLogger()) as Logger;
237
+ const sys = options?.dtsOnly ? createNodeSys({ process }) : (ctx.dev?.sys || createNodeSys({ process }));
238
+ const devMode = !!ctx.dev?.watch && !options?.dtsOnly;
239
+ if (options?.dtsOnly) {
240
+ logger.setLevel("error")
241
+ logger.createTimeSpan = () => ({
242
+ duration: () => 0,
243
+ finish: () => 0,
244
+ });
245
+ }
130
246
 
131
247
  const isWindows = process.platform === "win32";
132
248
 
@@ -137,6 +253,8 @@ async function runStencil(ctx: ResolvedEmbeddableConfig): Promise<void> {
137
253
  config: {
138
254
  devMode,
139
255
  maxConcurrentWorkers: isWindows ? 0 : 8, // workers break on windows
256
+ // we will trigger a rebuild by updating the component.tsx file (see triggerBuild function)
257
+ watchIgnoredRegex: [/\.css$/, /\.d\.ts$/, /\.js$/],
140
258
  rootDir: ctx.client.webComponentRoot,
141
259
  configPath: path.resolve(
142
260
  ctx.client.webComponentRoot,
@@ -145,9 +263,9 @@ async function runStencil(ctx: ResolvedEmbeddableConfig): Promise<void> {
145
263
  tsconfig: path.resolve(ctx.client.webComponentRoot, "tsconfig.json"),
146
264
  namespace: "embeddable-wrapper",
147
265
  srcDir: ctx.client.componentDir,
148
- sourceMap: true, // always generate source maps in both dev and prod
149
- minifyJs: !devMode,
150
- minifyCss: !devMode,
266
+ sourceMap: !options?.dtsOnly, // always generate source maps in both dev and prod
267
+ minifyJs: !devMode && !options?.dtsOnly,
268
+ minifyCss: !devMode && !options?.dtsOnly,
151
269
  outputTargets: [
152
270
  {
153
271
  type: "dist",
@@ -158,17 +276,23 @@ async function runStencil(ctx: ResolvedEmbeddableConfig): Promise<void> {
158
276
  });
159
277
 
160
278
  const compiler = await createCompiler(validated.config);
279
+
280
+ if (devMode) {
281
+ sys.onProcessInterrupt(() => {
282
+ compiler.destroy();
283
+ });
284
+ return await compiler.createWatcher();
285
+ }
286
+
161
287
  const buildResults = await compiler.build();
162
288
 
163
- if (!devMode) {
164
- if (buildResults.hasError) {
165
- console.error("Stencil build error:", buildResults.diagnostics);
166
- throw new Error("Stencil build error");
167
- } else {
168
- await handleStencilBuildOutput(ctx);
169
- }
170
- await compiler.destroy();
289
+ if (buildResults.hasError) {
290
+ console.error("Stencil build error:", buildResults.diagnostics);
291
+ throw new Error("Stencil build error");
292
+ } else {
293
+ await handleStencilBuildOutput(ctx);
171
294
  }
295
+ await compiler.destroy();
172
296
 
173
297
  process.chdir(ctx.client.rootDir);
174
298
  }
@@ -0,0 +1,126 @@
1
+ import {
2
+ createWatcherLock,
3
+ preventContentLength,
4
+ waitUntilFileStable,
5
+ } from "./dev.utils";
6
+ import { ServerResponse } from "http";
7
+ import * as fs from "node:fs/promises";
8
+ import {createReadStream} from "node:fs";
9
+ import { Readable } from "node:stream";
10
+
11
+ vi.mock("node:fs/promises", async () => {
12
+ let size = 100;
13
+ return {
14
+ stat: vi.fn().mockImplementation(() => ({ size })),
15
+ };
16
+ });
17
+
18
+ vi.mock("node:fs", async () => ({
19
+ createReadStream: vi.fn()
20
+ }));
21
+
22
+ describe("preventContentLength", () => {
23
+ it("should not allow setting 'content-length' header", () => {
24
+ const headers: Record<string, string> = {};
25
+ const res = {
26
+ setHeader: (key: string, value: any) => {
27
+ headers[key.toLowerCase()] = value;
28
+ },
29
+ } as unknown as ServerResponse;
30
+
31
+ preventContentLength(res);
32
+ res.setHeader("content-length", "1234");
33
+ res.setHeader("x-custom-header", "abc");
34
+
35
+ expect(headers["content-length"]).toBeUndefined();
36
+ expect(headers["x-custom-header"]).toBe("abc");
37
+ });
38
+ });
39
+
40
+ describe("createWatcherLock", () => {
41
+ it("should block and unblock correctly", async () => {
42
+ const lock = createWatcherLock();
43
+
44
+ lock.lock();
45
+ let unlocked = false;
46
+
47
+ const waiter = lock.waitUntilFree().then(() => {
48
+ unlocked = true;
49
+ });
50
+
51
+ // Still locked
52
+ expect(unlocked).toBe(false);
53
+
54
+ lock.unlock();
55
+
56
+ // Wait for Promise resolution
57
+ await waiter;
58
+ expect(unlocked).toBe(true);
59
+ });
60
+
61
+ it("should resolve immediately if not locked", async () => {
62
+ const lock = createWatcherLock();
63
+ await expect(lock.waitUntilFree()).resolves.toBeUndefined();
64
+ });
65
+
66
+ it("should not resolve until unlock is called", async () => {
67
+ const lock = createWatcherLock();
68
+ lock.lock();
69
+
70
+ let resolved = false;
71
+ lock.waitUntilFree().then(() => {
72
+ resolved = true;
73
+ });
74
+
75
+ await new Promise((r) => setTimeout(r, 20));
76
+ expect(resolved).toBe(false);
77
+
78
+ lock.unlock();
79
+ await new Promise((r) => setTimeout(r, 0)); // allow promise to resolve
80
+ expect(resolved).toBe(true);
81
+ });
82
+ });
83
+
84
+ describe("waitUntilFileStable", () => {
85
+ it("should resolve when file becomes stable and has the expected tail", async () => {
86
+ const filePath = "mock/path.js";
87
+ const expectedTail = "sourceMappingURL";
88
+ let size = 10;
89
+
90
+ const mockStat = vi.fn().mockImplementation(() => {
91
+ return Promise.resolve({ size });
92
+ });
93
+
94
+ vi.mocked(fs.stat).mockImplementation(mockStat);
95
+
96
+ const streamData = "some data\n// sourceMappingURL=something.js";
97
+
98
+ vi.mocked(createReadStream).mockImplementation(() => {
99
+ return Readable.from([streamData]) as any;
100
+ });
101
+
102
+ await expect(waitUntilFileStable(filePath, expectedTail, {
103
+ maxAttempts: 5,
104
+ })).resolves.toBeUndefined();
105
+
106
+ expect(fs.stat).toHaveBeenCalled();
107
+ expect(createReadStream).toHaveBeenCalled();
108
+ });
109
+
110
+ it("should throw if file never stabilizes", async () => {
111
+ const filePath = "mock/path.js";
112
+ const expectedTail = "sourceMappingURL";
113
+
114
+ vi.mocked(fs.stat).mockResolvedValue({ size: 0 } as any);
115
+
116
+ vi.mocked(createReadStream).mockImplementation(() => {
117
+ return Readable.from([""]) as any;
118
+ });
119
+
120
+ await expect(waitUntilFileStable(filePath, expectedTail, {
121
+ maxAttempts: 3,
122
+ })).rejects.toThrow("File did not stabilize");
123
+
124
+ expect(fs.stat).toHaveBeenCalled();
125
+ });
126
+ });