|
1 | 1 | import * as assert from 'assert'; |
2 | 2 | import * as sinon from 'sinon'; |
| 3 | +import { Terminal } from 'vscode'; |
| 4 | +import * as windowApis from '../../../common/window.apis'; |
3 | 5 | import * as workspaceApis from '../../../common/workspace.apis'; |
| 6 | +import * as shellDetector from '../../../features/common/shellDetector'; |
4 | 7 | import { |
5 | 8 | ACT_TYPE_COMMAND, |
6 | 9 | ACT_TYPE_OFF, |
7 | 10 | ACT_TYPE_SHELL, |
8 | 11 | AutoActivationType, |
9 | 12 | getAutoActivationType, |
10 | 13 | shouldActivateInCurrentTerminal, |
| 14 | + waitForShellIntegration, |
11 | 15 | } from '../../../features/terminal/utils'; |
12 | 16 |
|
13 | 17 | interface MockWorkspaceConfig { |
@@ -545,3 +549,154 @@ suite('Terminal Utils - shouldActivateInCurrentTerminal', () => { |
545 | 549 | ); |
546 | 550 | }); |
547 | 551 | }); |
| 552 | + |
| 553 | +suite('Terminal Utils - waitForShellIntegration', () => { |
| 554 | + let mockGetConfiguration: sinon.SinonStub; |
| 555 | + let identifyTerminalShellStub: sinon.SinonStub; |
| 556 | + let onDidChangeTerminalShellIntegrationStub: sinon.SinonStub; |
| 557 | + let onDidWriteTerminalDataStub: sinon.SinonStub; |
| 558 | + |
| 559 | + function setupLongTimeoutConfig() { |
| 560 | + // Make the timeout effectively infinite so tests resolve via the listener, |
| 561 | + // not the timer. Avoids flakiness while keeping the race code paths exercised. |
| 562 | + const config = { |
| 563 | + get: sinon.stub(), |
| 564 | + inspect: sinon.stub(), |
| 565 | + update: sinon.stub(), |
| 566 | + }; |
| 567 | + config.get.withArgs('shellIntegration.timeout').returns(60_000); |
| 568 | + config.get.withArgs('shellIntegration.enabled', true).returns(true); |
| 569 | + mockGetConfiguration.withArgs('terminal.integrated').returns(config); |
| 570 | + } |
| 571 | + |
| 572 | + setup(() => { |
| 573 | + mockGetConfiguration = sinon.stub(workspaceApis, 'getConfiguration'); |
| 574 | + identifyTerminalShellStub = sinon.stub(shellDetector, 'identifyTerminalShell'); |
| 575 | + onDidChangeTerminalShellIntegrationStub = sinon.stub(windowApis, 'onDidChangeTerminalShellIntegration'); |
| 576 | + onDidWriteTerminalDataStub = sinon.stub(windowApis, 'onDidWriteTerminalData'); |
| 577 | + |
| 578 | + // Default: dispose-only fake event registrations. Tests that need to fire |
| 579 | + // events override these via .callsFake. |
| 580 | + const fakeDisposable = { dispose: () => undefined }; |
| 581 | + onDidChangeTerminalShellIntegrationStub.returns(fakeDisposable); |
| 582 | + onDidWriteTerminalDataStub.returns(fakeDisposable); |
| 583 | + }); |
| 584 | + |
| 585 | + teardown(() => { |
| 586 | + sinon.restore(); |
| 587 | + }); |
| 588 | + |
| 589 | + test('returns false immediately when terminal is undefined', async () => { |
| 590 | + const result = await waitForShellIntegration(undefined); |
| 591 | + |
| 592 | + assert.strictEqual(result, false); |
| 593 | + sinon.assert.notCalled(identifyTerminalShellStub); |
| 594 | + sinon.assert.notCalled(onDidChangeTerminalShellIntegrationStub); |
| 595 | + }); |
| 596 | + |
| 597 | + test('returns true immediately when terminal.shellIntegration is already set', async () => { |
| 598 | + const terminal = { shellIntegration: {} } as unknown as Terminal; |
| 599 | + |
| 600 | + const result = await waitForShellIntegration(terminal); |
| 601 | + |
| 602 | + assert.strictEqual(result, true); |
| 603 | + sinon.assert.notCalled(identifyTerminalShellStub); |
| 604 | + sinon.assert.notCalled(onDidChangeTerminalShellIntegrationStub); |
| 605 | + }); |
| 606 | + |
| 607 | + test('returns false immediately for nu without registering event listeners', async () => { |
| 608 | + const terminal = {} as Terminal; |
| 609 | + identifyTerminalShellStub.returns('nu'); |
| 610 | + |
| 611 | + const result = await waitForShellIntegration(terminal); |
| 612 | + |
| 613 | + assert.strictEqual(result, false); |
| 614 | + sinon.assert.calledOnce(identifyTerminalShellStub); |
| 615 | + sinon.assert.notCalled(onDidChangeTerminalShellIntegrationStub); |
| 616 | + sinon.assert.notCalled(onDidWriteTerminalDataStub); |
| 617 | + }); |
| 618 | + |
| 619 | + test('returns false immediately for cmd', async () => { |
| 620 | + const terminal = {} as Terminal; |
| 621 | + identifyTerminalShellStub.returns('cmd'); |
| 622 | + |
| 623 | + const result = await waitForShellIntegration(terminal); |
| 624 | + |
| 625 | + assert.strictEqual(result, false); |
| 626 | + sinon.assert.notCalled(onDidChangeTerminalShellIntegrationStub); |
| 627 | + }); |
| 628 | + |
| 629 | + test('returns false immediately for csh / tcsh / ksh / xonsh', async () => { |
| 630 | + const unsupported = ['csh', 'tcsh', 'ksh', 'xonsh']; |
| 631 | + for (const shell of unsupported) { |
| 632 | + identifyTerminalShellStub.resetHistory(); |
| 633 | + identifyTerminalShellStub.returns(shell); |
| 634 | + onDidChangeTerminalShellIntegrationStub.resetHistory(); |
| 635 | + |
| 636 | + const result = await waitForShellIntegration({} as Terminal); |
| 637 | + |
| 638 | + assert.strictEqual(result, false, `expected false for shell '${shell}'`); |
| 639 | + sinon.assert.notCalled(onDidChangeTerminalShellIntegrationStub); |
| 640 | + } |
| 641 | + }); |
| 642 | + |
| 643 | + test('falls through to event race for bash (supported shell)', async () => { |
| 644 | + setupLongTimeoutConfig(); |
| 645 | + const terminal = {} as Terminal; |
| 646 | + identifyTerminalShellStub.returns('bash'); |
| 647 | + |
| 648 | + let listenerRef: ((e: { terminal: Terminal }) => void) | undefined; |
| 649 | + onDidChangeTerminalShellIntegrationStub.callsFake((listener: (e: { terminal: Terminal }) => void) => { |
| 650 | + listenerRef = listener; |
| 651 | + return { dispose: () => undefined }; |
| 652 | + }); |
| 653 | + |
| 654 | + const racePromise = waitForShellIntegration(terminal); |
| 655 | + // Yield once so the Promise.race body has a chance to register listeners. |
| 656 | + await new Promise<void>((r) => setImmediate(r)); |
| 657 | + assert.ok(listenerRef, 'shell integration listener should be registered'); |
| 658 | + listenerRef!({ terminal }); |
| 659 | + |
| 660 | + const result = await racePromise; |
| 661 | + assert.strictEqual(result, true); |
| 662 | + sinon.assert.calledOnce(onDidChangeTerminalShellIntegrationStub); |
| 663 | + }); |
| 664 | + |
| 665 | + test('falls through to event race when shell type is unknown', async () => { |
| 666 | + setupLongTimeoutConfig(); |
| 667 | + const terminal = {} as Terminal; |
| 668 | + identifyTerminalShellStub.returns('unknown'); |
| 669 | + |
| 670 | + let listenerRef: ((e: { terminal: Terminal }) => void) | undefined; |
| 671 | + onDidChangeTerminalShellIntegrationStub.callsFake((listener: (e: { terminal: Terminal }) => void) => { |
| 672 | + listenerRef = listener; |
| 673 | + return { dispose: () => undefined }; |
| 674 | + }); |
| 675 | + |
| 676 | + const racePromise = waitForShellIntegration(terminal); |
| 677 | + await new Promise<void>((r) => setImmediate(r)); |
| 678 | + listenerRef!({ terminal }); |
| 679 | + |
| 680 | + const result = await racePromise; |
| 681 | + assert.strictEqual(result, true); |
| 682 | + }); |
| 683 | + |
| 684 | + test('falls through to event race when identifyTerminalShell throws', async () => { |
| 685 | + setupLongTimeoutConfig(); |
| 686 | + const terminal = {} as Terminal; |
| 687 | + identifyTerminalShellStub.throws(new Error('detection failed')); |
| 688 | + |
| 689 | + let listenerRef: ((e: { terminal: Terminal }) => void) | undefined; |
| 690 | + onDidChangeTerminalShellIntegrationStub.callsFake((listener: (e: { terminal: Terminal }) => void) => { |
| 691 | + listenerRef = listener; |
| 692 | + return { dispose: () => undefined }; |
| 693 | + }); |
| 694 | + |
| 695 | + const racePromise = waitForShellIntegration(terminal); |
| 696 | + await new Promise<void>((r) => setImmediate(r)); |
| 697 | + listenerRef!({ terminal }); |
| 698 | + |
| 699 | + const result = await racePromise; |
| 700 | + assert.strictEqual(result, true, 'should not regress when detection throws'); |
| 701 | + }); |
| 702 | +}); |
0 commit comments