|
4 | 4 | * SPDX-License-Identifier: Apache-2.0 |
5 | 5 | */ |
6 | 6 |
|
7 | | -import { describe, it, expect } from 'vitest'; |
| 7 | +import { afterEach, beforeEach, describe, it, expect } from 'vitest'; |
8 | 8 | import { randomBytes } from 'node:crypto'; |
9 | 9 | import { promises as fsp } from 'node:fs'; |
10 | 10 | import * as os from 'node:os'; |
@@ -44,6 +44,7 @@ import { |
44 | 44 | MAX_WORKSPACE_PATH_LENGTH, |
45 | 45 | RestoreInProgressError, |
46 | 46 | SessionNotFoundError, |
| 47 | + WorkspaceInitConflictError, |
47 | 48 | WorkspaceMismatchError, |
48 | 49 | type AcpChannel, |
49 | 50 | type BridgeOptions, |
@@ -4399,6 +4400,113 @@ describe('createHttpAcpBridge', () => { |
4399 | 4400 | }); |
4400 | 4401 | }); |
4401 | 4402 |
|
| 4403 | + describe('initWorkspace (#4175 Wave 4 PR 17)', () => { |
| 4404 | + /** |
| 4405 | + * Per-test workspace temp dir so the bridge's writeFile lands on a |
| 4406 | + * real path the tests can stat. Cleaned up by `afterEach`. |
| 4407 | + */ |
| 4408 | + let tmpWs: string; |
| 4409 | + |
| 4410 | + beforeEach(async () => { |
| 4411 | + tmpWs = await fsp.mkdtemp(path.join(os.tmpdir(), 'qwen-init-workspace-')); |
| 4412 | + }); |
| 4413 | + |
| 4414 | + afterEach(async () => { |
| 4415 | + await fsp.rm(tmpWs, { recursive: true, force: true }); |
| 4416 | + }); |
| 4417 | + |
| 4418 | + it('creates an empty QWEN.md on a fresh workspace', async () => { |
| 4419 | + const bridge = createHttpAcpBridge({ boundWorkspace: tmpWs }); |
| 4420 | + const res = await bridge.initWorkspace({}, undefined); |
| 4421 | + expect(res.action).toBe('created'); |
| 4422 | + expect(res.path).toBe(path.join(tmpWs, 'QWEN.md')); |
| 4423 | + const written = await fsp.readFile(res.path, 'utf8'); |
| 4424 | + expect(written).toBe(''); |
| 4425 | + }); |
| 4426 | + |
| 4427 | + it('treats whitespace-only file as absent (no 409)', async () => { |
| 4428 | + const target = path.join(tmpWs, 'QWEN.md'); |
| 4429 | + await fsp.writeFile(target, ' \n\t\n', 'utf8'); |
| 4430 | + const bridge = createHttpAcpBridge({ boundWorkspace: tmpWs }); |
| 4431 | + const res = await bridge.initWorkspace({}, undefined); |
| 4432 | + expect(res.action).toBe('created'); |
| 4433 | + const written = await fsp.readFile(target, 'utf8'); |
| 4434 | + expect(written).toBe(''); |
| 4435 | + }); |
| 4436 | + |
| 4437 | + it('throws WorkspaceInitConflictError when content exists and force is omitted', async () => { |
| 4438 | + const target = path.join(tmpWs, 'QWEN.md'); |
| 4439 | + const original = '# Project notes\n\nimportant stuff'; |
| 4440 | + await fsp.writeFile(target, original, 'utf8'); |
| 4441 | + const bridge = createHttpAcpBridge({ boundWorkspace: tmpWs }); |
| 4442 | + const err = await bridge.initWorkspace({}, undefined).catch((e) => e); |
| 4443 | + expect(err).toBeInstanceOf(WorkspaceInitConflictError); |
| 4444 | + expect((err as WorkspaceInitConflictError).path).toBe(target); |
| 4445 | + expect((err as WorkspaceInitConflictError).existingSize).toBe( |
| 4446 | + Buffer.byteLength(original, 'utf8'), |
| 4447 | + ); |
| 4448 | + // Original content must be preserved on conflict. |
| 4449 | + expect(await fsp.readFile(target, 'utf8')).toBe(original); |
| 4450 | + }); |
| 4451 | + |
| 4452 | + it('overwrites with action:overwrote when force is true', async () => { |
| 4453 | + const target = path.join(tmpWs, 'QWEN.md'); |
| 4454 | + await fsp.writeFile(target, '# Old', 'utf8'); |
| 4455 | + const bridge = createHttpAcpBridge({ boundWorkspace: tmpWs }); |
| 4456 | + const res = await bridge.initWorkspace({ force: true }, undefined); |
| 4457 | + expect(res.action).toBe('overwrote'); |
| 4458 | + expect(await fsp.readFile(target, 'utf8')).toBe(''); |
| 4459 | + }); |
| 4460 | + |
| 4461 | + it('does NOT spawn an ACP child', async () => { |
| 4462 | + let factoryCalls = 0; |
| 4463 | + const bridge = createHttpAcpBridge({ |
| 4464 | + boundWorkspace: tmpWs, |
| 4465 | + channelFactory: async () => { |
| 4466 | + factoryCalls += 1; |
| 4467 | + throw new Error('channel factory should not be invoked'); |
| 4468 | + }, |
| 4469 | + }); |
| 4470 | + await bridge.initWorkspace({}, undefined); |
| 4471 | + expect(factoryCalls).toBe(0); |
| 4472 | + }); |
| 4473 | + |
| 4474 | + it('fan-outs workspace_initialized to live session buses', async () => { |
| 4475 | + const factory: ChannelFactory = async () => { |
| 4476 | + const { clientStream, agentStream } = createInMemoryChannel(); |
| 4477 | + new AgentSideConnection(() => new FakeAgent() as Agent, agentStream); |
| 4478 | + return { |
| 4479 | + stream: clientStream, |
| 4480 | + exited: new Promise< |
| 4481 | + | { exitCode: number | null; signalCode: NodeJS.Signals | null } |
| 4482 | + | undefined |
| 4483 | + >(() => {}), |
| 4484 | + kill: async () => {}, |
| 4485 | + killSync: () => {}, |
| 4486 | + }; |
| 4487 | + }; |
| 4488 | + const bridge = createHttpAcpBridge({ |
| 4489 | + boundWorkspace: tmpWs, |
| 4490 | + channelFactory: factory, |
| 4491 | + }); |
| 4492 | + const session = await bridge.spawnOrAttach({ workspaceCwd: tmpWs }); |
| 4493 | + const abort = new AbortController(); |
| 4494 | + const it = bridge |
| 4495 | + .subscribeEvents(session.sessionId, { signal: abort.signal }) |
| 4496 | + [Symbol.asyncIterator](); |
| 4497 | + const res = await bridge.initWorkspace({}, session.clientId); |
| 4498 | + const next = await it.next(); |
| 4499 | + expect(next.value?.type).toBe('workspace_initialized'); |
| 4500 | + expect(next.value?.data).toEqual({ |
| 4501 | + path: res.path, |
| 4502 | + action: 'created', |
| 4503 | + }); |
| 4504 | + expect(next.value?.originatorClientId).toBe(session.clientId); |
| 4505 | + abort.abort(); |
| 4506 | + await bridge.shutdown(); |
| 4507 | + }); |
| 4508 | + }); |
| 4509 | + |
4402 | 4510 | describe('subscribeEvents', () => { |
4403 | 4511 | it('throws SessionNotFoundError for unknown session ids', () => { |
4404 | 4512 | const bridge = makeBridge({ |
|
0 commit comments