@superblocksteam/sdk 2.0.156 → 2.0.157-next.1

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 (62) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/cli-replacement/automatic-upgrades.d.ts.map +1 -1
  3. package/dist/cli-replacement/automatic-upgrades.js +4 -8
  4. package/dist/cli-replacement/automatic-upgrades.js.map +1 -1
  5. package/dist/cli-replacement/automatic-upgrades.test.js +2 -1
  6. package/dist/cli-replacement/automatic-upgrades.test.js.map +1 -1
  7. package/dist/cli-replacement/dev-s3-restore.test.mjs +6 -2
  8. package/dist/cli-replacement/dev-s3-restore.test.mjs.map +1 -1
  9. package/dist/cli-replacement/dev-startup-git-before-dbfs-order.test.mjs +6 -2
  10. package/dist/cli-replacement/dev-startup-git-before-dbfs-order.test.mjs.map +1 -1
  11. package/dist/cli-replacement/dev-startup-lock-acquisition.test.d.mts +2 -0
  12. package/dist/cli-replacement/dev-startup-lock-acquisition.test.d.mts.map +1 -0
  13. package/dist/cli-replacement/dev-startup-lock-acquisition.test.mjs +487 -0
  14. package/dist/cli-replacement/dev-startup-lock-acquisition.test.mjs.map +1 -0
  15. package/dist/cli-replacement/dev.d.mts +6 -6
  16. package/dist/cli-replacement/dev.d.mts.map +1 -1
  17. package/dist/cli-replacement/dev.interception.test.mjs +12 -12
  18. package/dist/cli-replacement/dev.interception.test.mjs.map +1 -1
  19. package/dist/cli-replacement/dev.mjs +100 -42
  20. package/dist/cli-replacement/dev.mjs.map +1 -1
  21. package/dist/dev-utils/dev-server.d.mts +34 -1
  22. package/dist/dev-utils/dev-server.d.mts.map +1 -1
  23. package/dist/dev-utils/dev-server.mjs +69 -70
  24. package/dist/dev-utils/dev-server.mjs.map +1 -1
  25. package/dist/dev-utils/dev-server.shutdown-lock.test.d.mts +2 -0
  26. package/dist/dev-utils/dev-server.shutdown-lock.test.d.mts.map +1 -0
  27. package/dist/dev-utils/dev-server.shutdown-lock.test.mjs +48 -0
  28. package/dist/dev-utils/dev-server.shutdown-lock.test.mjs.map +1 -0
  29. package/dist/dev-utils/fatal-process-barrier.d.mts +71 -0
  30. package/dist/dev-utils/fatal-process-barrier.d.mts.map +1 -0
  31. package/dist/dev-utils/fatal-process-barrier.mjs +161 -0
  32. package/dist/dev-utils/fatal-process-barrier.mjs.map +1 -0
  33. package/dist/dev-utils/fatal-process-barrier.spawn-fixture.d.mts +2 -0
  34. package/dist/dev-utils/fatal-process-barrier.spawn-fixture.d.mts.map +1 -0
  35. package/dist/dev-utils/fatal-process-barrier.spawn-fixture.mjs +9 -0
  36. package/dist/dev-utils/fatal-process-barrier.spawn-fixture.mjs.map +1 -0
  37. package/dist/dev-utils/fatal-process-barrier.test.d.mts +2 -0
  38. package/dist/dev-utils/fatal-process-barrier.test.d.mts.map +1 -0
  39. package/dist/dev-utils/fatal-process-barrier.test.mjs +281 -0
  40. package/dist/dev-utils/fatal-process-barrier.test.mjs.map +1 -0
  41. package/dist/telemetry/logging.js +1 -1
  42. package/dist/telemetry/logging.js.map +1 -1
  43. package/dist/telemetry/safe-stringify.d.ts.map +1 -1
  44. package/dist/telemetry/safe-stringify.js +25 -9
  45. package/dist/telemetry/safe-stringify.js.map +1 -1
  46. package/package.json +10 -6
  47. package/src/cli-replacement/automatic-upgrades.test.ts +2 -1
  48. package/src/cli-replacement/automatic-upgrades.ts +4 -11
  49. package/src/cli-replacement/dev-s3-restore.test.mts +6 -2
  50. package/src/cli-replacement/dev-startup-git-before-dbfs-order.test.mts +6 -2
  51. package/src/cli-replacement/dev-startup-lock-acquisition.test.mts +573 -0
  52. package/src/cli-replacement/dev.interception.test.mts +12 -12
  53. package/src/cli-replacement/dev.mts +112 -56
  54. package/src/dev-utils/dev-server.mts +93 -88
  55. package/src/dev-utils/dev-server.shutdown-lock.test.mts +58 -0
  56. package/src/dev-utils/fatal-process-barrier.mts +262 -0
  57. package/src/dev-utils/fatal-process-barrier.spawn-fixture.mts +12 -0
  58. package/src/dev-utils/fatal-process-barrier.test.mts +346 -0
  59. package/src/telemetry/logging.ts +1 -1
  60. package/src/telemetry/safe-stringify.ts +25 -10
  61. package/test/safe-stringify.test.mts +25 -0
  62. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,573 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as http from "node:http";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+
6
+ import {
7
+ afterEach,
8
+ beforeEach,
9
+ describe,
10
+ expect,
11
+ it,
12
+ vi,
13
+ type Mock,
14
+ } from "vitest";
15
+
16
+ const execMock = vi.fn();
17
+ const execFileMock = vi.fn();
18
+ vi.mock("node:child_process", () => ({
19
+ default: { exec: execMock, execFile: execFileMock },
20
+ exec: execMock,
21
+ execFile: execFileMock,
22
+ }));
23
+
24
+ vi.mock("node:readline", () => ({
25
+ default: {
26
+ createInterface: vi.fn(() => ({ question: vi.fn(), close: vi.fn() })),
27
+ },
28
+ createInterface: vi.fn(() => ({ question: vi.fn(), close: vi.fn() })),
29
+ }));
30
+
31
+ vi.mock("fs-extra", () => ({
32
+ default: {
33
+ existsSync: vi.fn(() => true),
34
+ readFileSync: vi.fn(() => "{}"),
35
+ },
36
+ }));
37
+
38
+ vi.mock("package-manager-detector/detect", () => ({
39
+ detect: vi.fn(async () => ({
40
+ name: "npm",
41
+ agent: "npm",
42
+ version: "10.0.0",
43
+ })),
44
+ }));
45
+
46
+ vi.mock("package-manager-detector", () => ({
47
+ resolveCommand: vi.fn(() => ({
48
+ command: "npm",
49
+ args: ["install", "--fund=false", "--audit=false"],
50
+ })),
51
+ }));
52
+
53
+ vi.mock("read-pkg", () => ({
54
+ readPackage: vi.fn(async () => ({
55
+ name: "test-app",
56
+ dependencies: { "@superblocksteam/library": "1.0.0" },
57
+ })),
58
+ }));
59
+
60
+ const mockLogger = {
61
+ info: vi.fn(),
62
+ warn: vi.fn(),
63
+ error: vi.fn(),
64
+ debug: vi.fn(),
65
+ };
66
+
67
+ vi.mock("../telemetry/logging.js", () => ({
68
+ getLogger: () => mockLogger,
69
+ getErrorMeta: vi.fn(() => ({})),
70
+ }));
71
+
72
+ vi.mock("../telemetry/index.js", () => ({
73
+ getTracer: () => ({
74
+ startActiveSpan: vi.fn(
75
+ async (
76
+ _name: string,
77
+ fn: (span: {
78
+ end: () => void;
79
+ setStatus: () => void;
80
+ }) => Promise<unknown>,
81
+ ) => fn({ end: vi.fn(), setStatus: vi.fn() }),
82
+ ),
83
+ }),
84
+ isCloudPrem: false,
85
+ shutdownTelemetry: vi.fn(async () => true),
86
+ }));
87
+
88
+ vi.mock("@opentelemetry/api", () => ({
89
+ SpanStatusCode: { ERROR: 2 },
90
+ }));
91
+
92
+ vi.mock("colorette", () => ({
93
+ green: (s: string) => s,
94
+ }));
95
+
96
+ vi.mock("@superblocksteam/shared", async (importOriginal) => ({
97
+ // dev.mts now transitively loads @superblocksteam/shared (via the npm-error
98
+ // classifier re-exported from vite-plugin-file-sync/npm-registry — APPS-4450),
99
+ // pulling in many named exports (DeploymentTypeEnum, ISocket, …). Spread the
100
+ // real module so the closed overrides below don't have to enumerate them.
101
+ ...((await importOriginal()) as Record<string, unknown>),
102
+ buildGithubSuperblocksSyncWorkflow: vi.fn(),
103
+ buildGithubSuperblocksSyncWorkflowFromBaseUrl: vi.fn(),
104
+ ConflictError: class ConflictError extends Error {},
105
+ DEFAULT_NON_GIT_BRANCH: "__non_git__",
106
+ isGitHubRemoteUrl: vi.fn(() => false),
107
+ NotFoundError: class NotFoundError extends Error {},
108
+ SUPERBLOCKS_LIVE_GIT_BRANCH: "__live__",
109
+ }));
110
+
111
+ vi.mock("@superblocksteam/util", () => ({
112
+ maskUnixSignals: async function maskUnixSignals(fn: () => Promise<void>) {
113
+ return fn();
114
+ },
115
+ }));
116
+
117
+ vi.mock("@superblocksteam/vite-plugin-file-sync/ai-service", () => ({
118
+ AiService: class {
119
+ initialize = vi.fn(async () => undefined);
120
+ notifyMigrationAppArtifactsReady = vi.fn();
121
+ removeIntegrationCache = vi.fn(async () => undefined);
122
+ remediateDeclaredDependencies = vi.fn(async () => undefined);
123
+ chatSessionStore = { invalidateCache: vi.fn() };
124
+ getNpmRegistryClient = mockGetNpmRegistryClient;
125
+ },
126
+ AiServiceFeatureFlags: class {
127
+ static create = vi.fn(() => ({}));
128
+ },
129
+ SnapshotManager: class {
130
+ executePendingRestore = vi.fn(async () => false);
131
+ },
132
+ hasEditLifecycleCapability: vi.fn(() => false),
133
+ isSdkApiTemplate: vi.fn(() => false),
134
+ stripResolvedFromLockfile: vi.fn(async () => undefined),
135
+ }));
136
+
137
+ vi.mock("@superblocksteam/vite-plugin-file-sync/npm-registry", () => ({
138
+ shouldIgnoreInstallScripts: vi.fn(() => false),
139
+ maybeWriteNpmrcForDir: vi.fn(async () => {
140
+ // Delay before the log push so this test fails if syncHomeNpmrc is
141
+ // fire-and-forget (initiation order alone would still look correct).
142
+ await new Promise((resolve) => setTimeout(resolve, 5));
143
+ startupOpLog.push("project-npmrc:maybeWriteNpmrcForDir");
144
+ }),
145
+ }));
146
+
147
+ const { startupOpLog, buildMockGitService, mockGetNpmRegistryClient } =
148
+ vi.hoisted(() => {
149
+ const startupOpLog: string[] = [];
150
+ const mockGetNpmRegistryClient = vi.fn(
151
+ ():
152
+ | {
153
+ getConfig: ReturnType<typeof vi.fn>;
154
+ organizationId: string;
155
+ }
156
+ | undefined => ({
157
+ organizationId: "org-1",
158
+ getConfig: vi.fn(async () => ({
159
+ source: "not-configured",
160
+ config: { configured: false },
161
+ })),
162
+ }),
163
+ );
164
+ function buildMockGitService(cwd: string) {
165
+ return {
166
+ workDir: cwd,
167
+ configure: vi.fn(async () => {
168
+ startupOpLog.push("git:configure");
169
+ }),
170
+ init: vi.fn(async () => {
171
+ startupOpLog.push("git:init");
172
+ }),
173
+ addRemote: vi.fn(async () => {
174
+ startupOpLog.push("git:addRemote");
175
+ }),
176
+ fetch: vi.fn(async () => {
177
+ startupOpLog.push("git:fetch");
178
+ }),
179
+ raw: vi.fn(async (args: string | string[]) => {
180
+ const argv = Array.isArray(args) ? args : [args];
181
+ startupOpLog.push(`git:raw:${argv.join(" ")}`);
182
+ if (argv[0] === "ls-remote" && argv.includes("__live__")) {
183
+ return "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef\trefs/heads/__live__\n";
184
+ }
185
+ if (argv[0] === "branch" && argv.includes("--show-current")) {
186
+ return "__live__\n";
187
+ }
188
+ if (argv[0] === "commit") {
189
+ return "";
190
+ }
191
+ return "";
192
+ }),
193
+ push: vi.fn(async () => {
194
+ startupOpLog.push("git:push");
195
+ }),
196
+ getDefaultBranch: vi.fn(async () => "main"),
197
+ getRemotes: vi.fn(async () => [
198
+ { name: "origin", refs: { fetch: "https://example.test/repo.git" } },
199
+ ]),
200
+ remote: vi.fn(async () => {
201
+ startupOpLog.push("git:remote");
202
+ }),
203
+ revparse: vi.fn(async (ref: string) => {
204
+ startupOpLog.push(`git:revparse:${ref}`);
205
+ return "abc123\n";
206
+ }),
207
+ checkout: vi.fn(async () => {
208
+ startupOpLog.push("git:checkout");
209
+ }),
210
+ checkoutOrCreate: vi.fn(async () => {
211
+ startupOpLog.push("git:checkoutOrCreate");
212
+ }),
213
+ };
214
+ }
215
+ return { startupOpLog, buildMockGitService, mockGetNpmRegistryClient };
216
+ });
217
+
218
+ vi.mock("@superblocksteam/vite-plugin-file-sync/git-service", () => ({
219
+ createGitService: vi.fn((cwd: string) => {
220
+ startupOpLog.push("createGitService");
221
+ return buildMockGitService(cwd);
222
+ }),
223
+ }));
224
+
225
+ const mockLockService = {
226
+ acquireLock: vi.fn(async () => undefined),
227
+ forceTakeover: vi.fn(async () => undefined),
228
+ inspectLock: vi.fn(async () => ({
229
+ ownerName: "someone@example.com",
230
+ canForceTakeover: false,
231
+ lockLifetimeMs: 60_000,
232
+ timeSinceLastActivityMs: 0,
233
+ })),
234
+ setOnLockConflict: vi.fn(),
235
+ relinquishLock: vi.fn(async () => undefined),
236
+ notifyClosedByServer: vi.fn(async () => undefined),
237
+ shutdownAndExit: vi.fn(async () => undefined),
238
+ setIsRestarting: vi.fn(),
239
+ isLocked: false,
240
+ // The git-service mock below simulates a live/target branch mismatch, which
241
+ // is what drives `ensureRuntimeDbfsBranchConsistency`. Both of the methods it
242
+ // calls have to exist here or the first one throws a TypeError that an outer
243
+ // catch swallows — leaving the suite green without ever having run the
244
+ // interaction.
245
+ isLockedWhenSettled: vi.fn(async () => false),
246
+ switchBranch: vi.fn(async () => undefined),
247
+ connectedUsers: [],
248
+ wasRecentlyActive: false,
249
+ timeSinceLastActivity: 0,
250
+ };
251
+
252
+ vi.mock("@superblocksteam/vite-plugin-file-sync/lock-service", () => ({
253
+ LockService: class {
254
+ acquireLock = mockLockService.acquireLock;
255
+ forceTakeover = mockLockService.forceTakeover;
256
+ inspectLock = mockLockService.inspectLock;
257
+ setOnLockConflict = mockLockService.setOnLockConflict;
258
+ relinquishLock = mockLockService.relinquishLock;
259
+ notifyClosedByServer = mockLockService.notifyClosedByServer;
260
+ shutdownAndExit = mockLockService.shutdownAndExit;
261
+ setIsRestarting = mockLockService.setIsRestarting;
262
+ isLocked = mockLockService.isLocked;
263
+ isLockedWhenSettled = mockLockService.isLockedWhenSettled;
264
+ switchBranch = mockLockService.switchBranch;
265
+ connectedUsers = mockLockService.connectedUsers;
266
+ wasRecentlyActive = mockLockService.wasRecentlyActive;
267
+ timeSinceLastActivity = mockLockService.timeSinceLastActivity;
268
+ },
269
+ LockType: { CSB: "CSB", LOCAL: "LOCAL" },
270
+ }));
271
+
272
+ vi.mock("@superblocksteam/vite-plugin-file-sync/operation-queue", () => ({
273
+ OperationQueue: class {
274
+ enqueue = vi.fn(async (fn: () => Promise<unknown>) => fn());
275
+ },
276
+ }));
277
+
278
+ vi.mock("@superblocksteam/vite-plugin-file-sync/server-rpc", () => ({
279
+ AutoConnectingRpcClient: class {},
280
+ }));
281
+
282
+ const mockSyncService = {
283
+ downloadDirectory: vi.fn(async () => {
284
+ startupOpLog.push("dbfs:downloadDirectory");
285
+ }),
286
+ uploadDirectory: vi.fn(async () => undefined),
287
+ uploadDirectoryNowIfNeeded: vi.fn(async () => undefined),
288
+ cancelPendingSync: vi.fn(),
289
+ };
290
+
291
+ vi.mock("@superblocksteam/vite-plugin-file-sync/sync-service", () => ({
292
+ SyncService: class {
293
+ downloadDirectory = mockSyncService.downloadDirectory;
294
+ uploadDirectory = mockSyncService.uploadDirectory;
295
+ uploadDirectoryNowIfNeeded = mockSyncService.uploadDirectoryNowIfNeeded;
296
+ cancelPendingSync = mockSyncService.cancelPendingSync;
297
+ },
298
+ }));
299
+
300
+ vi.mock("@superblocksteam/vite-plugin-file-sync/draft-interface", () => ({}));
301
+
302
+ vi.mock("./version-detection.js", () => ({
303
+ getCurrentCliVersion: vi.fn(async () => ({
304
+ version: "2.0.0",
305
+ alias: undefined,
306
+ })),
307
+ }));
308
+
309
+ const mockCheckVersions = vi.fn(async () => ({
310
+ cliUpdated: false,
311
+ upgradePromises: [] as Promise<void>[],
312
+ }));
313
+ vi.mock("./automatic-upgrades.js", () => ({
314
+ checkVersionsAndWritePackageJson: (
315
+ ...args: Parameters<typeof mockCheckVersions>
316
+ ) => mockCheckVersions(...args),
317
+ buildInstallEnv: (userconfigPath: string) => ({
318
+ ...process.env,
319
+ NPM_CONFIG_USERCONFIG: userconfigPath,
320
+ PNPM_CONFIG_USERCONFIG: userconfigPath,
321
+ }),
322
+ }));
323
+
324
+ // The home `~/.npmrc` writer (APPS-4231) is a best-effort step that runs
325
+ // before auto-upgrade. Mock it out here so this test stays focused on the
326
+ // git-before-DBFS ordering. The `AiService` mock above exposes a minimal
327
+ // `getNpmRegistryClient()` stub so the call site doesn't `TypeError`
328
+ // and silently swallow this mock.
329
+ vi.mock("./home-npmrc.mjs", () => ({
330
+ syncHomeNpmrc: vi.fn(async () => {
331
+ // Longer delay than maybeWriteNpmrcForDir: if both run without await,
332
+ // project pushes first and the order assertion fails.
333
+ await new Promise((resolve) => setTimeout(resolve, 30));
334
+ startupOpLog.push("home-npmrc:syncHomeNpmrc");
335
+ return {
336
+ outcome: "skipped-not-configured",
337
+ path: "/tmp/.superblocks/npmrc",
338
+ };
339
+ }),
340
+ superblocksNpmrcPath: vi.fn(() => "/tmp/.superblocks/npmrc"),
341
+ superblocksLogsPath: vi.fn((appDir: string) => `${appDir}/.superblocks/logs`),
342
+ }));
343
+
344
+ vi.mock("./git-repo-setup.mjs", () => ({
345
+ ensureRemoteHasDefaultBranch: vi.fn(async () => undefined),
346
+ getGitErrorFields: vi.fn(() => ({})),
347
+ }));
348
+
349
+ const mockCreateDevServer = vi.fn(async () => http.createServer());
350
+ vi.mock("../dev-utils/dev-server.mjs", () => ({
351
+ createDevServer: (...args: Parameters<typeof mockCreateDevServer>) =>
352
+ mockCreateDevServer(...args),
353
+ }));
354
+
355
+ vi.mock("../index.js", () => ({
356
+ AUTO_UPGRADE_EXIT_CODE: 99,
357
+ }));
358
+
359
+ const SERVER_DBFS_HASH = "dbfs-head-after-clark";
360
+
361
+ function buildMockSdk() {
362
+ const hashLocalDirectory = vi
363
+ .fn()
364
+ .mockImplementationOnce(async () => {
365
+ startupOpLog.push("sdk:hashLocalDirectory:before-dbfs");
366
+ return { hash: "git-tree-without-uncommitted-clark-change" };
367
+ })
368
+ .mockImplementationOnce(async () => {
369
+ startupOpLog.push("sdk:hashLocalDirectory:after-dbfs");
370
+ return { hash: SERVER_DBFS_HASH };
371
+ });
372
+
373
+ return {
374
+ hashLocalDirectory,
375
+ dbfsGetApplicationDirectoryHash: vi.fn(async () => SERVER_DBFS_HASH),
376
+ getApplicationGitConfig: vi.fn(async () => ({
377
+ gitRemoteUrl: "https://github.com/example/superblocks-app.git",
378
+ hasCredential: false,
379
+ })),
380
+ fetchCurrentUser: vi.fn(async () => ({
381
+ user: {
382
+ id: "user-1",
383
+ name: "Test User",
384
+ email: "test@example.com",
385
+ currentOrganizationId: "org-1",
386
+ },
387
+ organizations: [{ id: "org-1", pluginExecutionVersions: {} }],
388
+ flagBootstrap: {},
389
+ })),
390
+ getFeatureFlagsForCurrentUser: vi.fn(async () => ({
391
+ devServerCloudInactivityTimeoutMinutes: () => 30,
392
+ devServerLocalInactivityTimeoutMinutes: () => 30,
393
+ enableSessionRecording: () => false,
394
+ removeManualCommitsEnabled: () => false,
395
+ })),
396
+ getFeatureFlagsForUser: vi.fn(() => ({
397
+ devServerCloudInactivityTimeoutMinutes: () => 30,
398
+ devServerLocalInactivityTimeoutMinutes: () => 30,
399
+ })),
400
+ };
401
+ }
402
+
403
+ function buildDevOptions(cwd: string, overrides: Record<string, unknown> = {}) {
404
+ return {
405
+ cwd,
406
+ downloadFirst: true,
407
+ autoUpgradeMode: "skip-upgrade",
408
+ applicationConfig: {
409
+ id: "app-test",
410
+ branchName: "main",
411
+ },
412
+ tokenConfig: {
413
+ superblocksBaseUrl: "https://example.test",
414
+ token: "test-token",
415
+ },
416
+ tokenManager: {
417
+ getToken: vi.fn(() => "test-token"),
418
+ onTokenRefresh: vi.fn(),
419
+ updateToken: vi.fn(),
420
+ getCurrentToken: vi.fn(() => "test-token"),
421
+ },
422
+ getCurrentToken: vi.fn(() => "test-token"),
423
+ sdk: buildMockSdk(),
424
+ ...overrides,
425
+ };
426
+ }
427
+
428
+ function setupExecMock() {
429
+ execMock.mockImplementation(
430
+ (
431
+ _cmd: string,
432
+ _opts: unknown,
433
+ cb?: (err: null, result: { stdout: string }) => void,
434
+ ) => {
435
+ startupOpLog.push("installPackages:exec");
436
+ if (typeof _opts === "function") {
437
+ (_opts as (err: null, result: { stdout: string }) => void)(null, {
438
+ stdout: "installed",
439
+ });
440
+ } else if (cb) {
441
+ cb(null, { stdout: "installed" });
442
+ }
443
+ },
444
+ );
445
+ }
446
+
447
+ describe("dev startup: who takes the live-edit lock, and when (ENG-6090)", () => {
448
+ let testCwd: string;
449
+
450
+ beforeEach(async () => {
451
+ vi.clearAllMocks();
452
+ delete process.env.SUPERBLOCKS_IS_CSB;
453
+ mockGetNpmRegistryClient.mockImplementation(() => ({
454
+ organizationId: "org-1",
455
+ getConfig: vi.fn(async () => ({
456
+ source: "not-configured",
457
+ config: { configured: false },
458
+ })),
459
+ }));
460
+ startupOpLog.length = 0;
461
+ setupExecMock();
462
+ mockCheckVersions.mockResolvedValue({
463
+ cliUpdated: false,
464
+ upgradePromises: [],
465
+ });
466
+ testCwd = await fs.mkdtemp(path.join(os.tmpdir(), "sb-dev-lock-acquire-"));
467
+ const { readPackage } = await import("read-pkg");
468
+ (readPackage as Mock).mockResolvedValue({
469
+ name: "test-app",
470
+ dependencies: { "@superblocksteam/library": "1.0.0" },
471
+ });
472
+ });
473
+
474
+ afterEach(async () => {
475
+ await fs.rm(testCwd, { recursive: true, force: true });
476
+ });
477
+
478
+ it.each([
479
+ ["cloud", "true"],
480
+ ["local", undefined],
481
+ ])("does not take the lock at boot on the %s path", async (_name, isCsb) => {
482
+ if (isCsb) {
483
+ process.env.SUPERBLOCKS_IS_CSB = isCsb;
484
+ }
485
+ const { dev } = await import("./dev.mjs");
486
+
487
+ await dev(buildDevOptions(testCwd) as any);
488
+
489
+ // Nothing startup does is gated on holding the lock — not the DBFS
490
+ // download, not the package install, not the hash upload. Taking it before
491
+ // any client exists is holding it on behalf of nobody, which is the state
492
+ // the startup no-socket watchdog existed to clean up. A local dev server
493
+ // has no more claim to it at boot than a sandbox does: the browser tab
494
+ // that has not opened yet is the client, not the process.
495
+ expect(mockLockService.acquireLock).not.toHaveBeenCalled();
496
+ expect(mockLockService.forceTakeover).not.toHaveBeenCalled();
497
+ // ...and it still boots and serves.
498
+ expect(mockSyncService.downloadDirectory).toHaveBeenCalled();
499
+ expect(mockCreateDevServer).toHaveBeenCalled();
500
+ });
501
+
502
+ it("hands the conflict handler to the lock service instead of racing it at boot", async () => {
503
+ const { dev } = await import("./dev.mjs");
504
+
505
+ await dev(buildDevOptions(testCwd) as any);
506
+
507
+ // The reporting that used to wrap the boot-time acquire still exists; it is
508
+ // wired to where acquisition actually happens now.
509
+ expect(mockLockService.setOnLockConflict).toHaveBeenCalledWith(
510
+ expect.any(Function),
511
+ );
512
+ });
513
+
514
+ it("reports a conflict without stopping the dev server", async () => {
515
+ const { dev } = await import("./dev.mjs");
516
+ mockLockService.inspectLock.mockResolvedValueOnce({
517
+ ownerName: "someone@example.com",
518
+ canForceTakeover: false,
519
+ lockLifetimeMs: 120_000,
520
+ timeSinceLastActivityMs: 0,
521
+ });
522
+
523
+ await dev(buildDevOptions(testCwd) as any);
524
+ const onConflict = mockLockService.setOnLockConflict.mock.calls[0]![0];
525
+ await onConflict(new Error("Another user is currently editing this app."));
526
+
527
+ // Someone else holds it, so this client cannot edit — but the sandbox is
528
+ // still serving preview and the lock does not decide how long it lives.
529
+ expect(mockLockService.relinquishLock).not.toHaveBeenCalled();
530
+ expect(mockLockService.shutdownAndExit).not.toHaveBeenCalled();
531
+ expect(mockLogger.warn).toHaveBeenCalledWith(
532
+ expect.stringContaining("Could not take the live-edit lock"),
533
+ );
534
+ });
535
+
536
+ it("stops on an unresolvable conflict under the VS Code extension", async () => {
537
+ process.env.SUPERBLOCKS_VSCODE = "true";
538
+ const { shutdownTelemetry } = await import("../telemetry/index.js");
539
+ const exit = vi
540
+ .spyOn(process, "exit")
541
+ .mockImplementation(() => undefined as never);
542
+ const { dev } = await import("./dev.mjs");
543
+
544
+ await dev(buildDevOptions(testCwd) as any);
545
+ const onConflict = mockLockService.setOnLockConflict.mock.calls[0]![0];
546
+ await onConflict(new Error("Another user is currently editing this app."));
547
+
548
+ // The extension answers a takeover prompt by restarting this command with
549
+ // --force-takeover, and that hand-off has always ended with this process
550
+ // gone. Staying up would leave the replacement fighting us for the port.
551
+ expect(exit).toHaveBeenCalledWith(1);
552
+ // Through the same teardown as the other two fatal exits, not
553
+ // `shutdownAndExit`: that one exits synchronously from its own `finally`,
554
+ // which made the span end and the telemetry flush below it unreachable —
555
+ // so the trace for the conflict never left the process.
556
+ expect(mockLockService.shutdownAndExit).not.toHaveBeenCalled();
557
+ expect(mockLockService.relinquishLock).toHaveBeenCalled();
558
+ expect(shutdownTelemetry).toHaveBeenCalled();
559
+ exit.mockRestore();
560
+ });
561
+
562
+ it("honours an explicit force takeover at boot", async () => {
563
+ process.env.SUPERBLOCKS_IS_CSB = "true";
564
+ const { dev } = await import("./dev.mjs");
565
+
566
+ // The one thing that still acquires at boot, and it is not lock-type
567
+ // specific: someone asked for it by name.
568
+ await dev(buildDevOptions(testCwd, { forceTakeover: true }) as any);
569
+
570
+ expect(mockLockService.forceTakeover).toHaveBeenCalled();
571
+ expect(mockLockService.acquireLock).not.toHaveBeenCalled();
572
+ });
573
+ });
@@ -315,7 +315,7 @@ describe("runFatalExitShutdown", () => {
315
315
  removeIntegrationCache: async () => {
316
316
  calls.push("cache");
317
317
  },
318
- shutdownLockService: async () => {
318
+ relinquishLock: async () => {
319
319
  calls.push("lock");
320
320
  },
321
321
  });
@@ -323,36 +323,36 @@ describe("runFatalExitShutdown", () => {
323
323
  });
324
324
 
325
325
  it("still releases the lock when cache removal rejects", async () => {
326
- const shutdownLockService = vi.fn(async () => {});
326
+ const relinquishLock = vi.fn(async () => {});
327
327
  await expect(
328
328
  runFatalExitShutdown({
329
329
  logger: makeLogger(),
330
330
  removeIntegrationCache: async () => {
331
331
  throw new Error("ai-service IPC is gone");
332
332
  },
333
- shutdownLockService,
333
+ relinquishLock,
334
334
  }),
335
335
  ).resolves.toBeUndefined();
336
- expect(shutdownLockService).toHaveBeenCalledOnce();
336
+ expect(relinquishLock).toHaveBeenCalledOnce();
337
337
  });
338
338
 
339
339
  it("still releases the lock when cache removal throws synchronously", async () => {
340
- const shutdownLockService = vi.fn(async () => {});
340
+ const relinquishLock = vi.fn(async () => {});
341
341
  await runFatalExitShutdown({
342
342
  logger: makeLogger(),
343
343
  removeIntegrationCache: () => {
344
344
  throw new Error("sync boom");
345
345
  },
346
- shutdownLockService,
346
+ relinquishLock,
347
347
  });
348
- expect(shutdownLockService).toHaveBeenCalledOnce();
348
+ expect(relinquishLock).toHaveBeenCalledOnce();
349
349
  });
350
350
 
351
351
  it("never rejects when the lock release itself rejects", async () => {
352
352
  await expect(
353
353
  runFatalExitShutdown({
354
354
  logger: makeLogger(),
355
- shutdownLockService: async () => {
355
+ relinquishLock: async () => {
356
356
  throw new Error("lock server unreachable");
357
357
  },
358
358
  }),
@@ -370,20 +370,20 @@ describe("runFatalExitShutdown", () => {
370
370
  it("abandons a step that never settles and still runs the later steps", async () => {
371
371
  vi.useFakeTimers();
372
372
  try {
373
- const shutdownLockService = vi.fn(async () => {});
373
+ const relinquishLock = vi.fn(async () => {});
374
374
  const settled = vi.fn();
375
375
  const done = runFatalExitShutdown({
376
376
  logger: makeLogger(),
377
377
  removeIntegrationCache: () => new Promise<void>(() => {}),
378
- shutdownLockService,
378
+ relinquishLock,
379
379
  }).then(settled);
380
380
 
381
381
  await vi.advanceTimersByTimeAsync(stepTimeoutMs - 1);
382
- expect(shutdownLockService).not.toHaveBeenCalled();
382
+ expect(relinquishLock).not.toHaveBeenCalled();
383
383
 
384
384
  await vi.advanceTimersByTimeAsync(2);
385
385
  await done;
386
- expect(shutdownLockService).toHaveBeenCalledOnce();
386
+ expect(relinquishLock).toHaveBeenCalledOnce();
387
387
  expect(settled).toHaveBeenCalledOnce();
388
388
  } finally {
389
389
  vi.useRealTimers();