|
1 | 1 | import { describe, expect, it, beforeEach, afterEach, mock, type Mock } from "bun:test"; |
| 2 | +import type { DisplayedMessage } from "@/common/types/message"; |
2 | 3 | import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; |
3 | 4 | import type { StreamStartEvent, ToolCallStartEvent } from "@/common/types/stream"; |
4 | 5 | import type { WorkspaceActivitySnapshot, WorkspaceChatMessage } from "@/common/orpc/types"; |
@@ -1662,6 +1663,133 @@ describe("WorkspaceStore", () => { |
1662 | 1663 | expect(stayedVisibleAfterCaughtUp).toBe(true); |
1663 | 1664 | }); |
1664 | 1665 |
|
| 1666 | + it("shows replayed init output before caught-up when switching back to a workspace", async () => { |
| 1667 | + const workspaceId = "workspace-init-replay"; |
| 1668 | + const otherWorkspaceId = "workspace-init-other"; |
| 1669 | + const firstLine = "Preparing workspace..."; |
| 1670 | + const replayedLine = "Syncing repository over SSH..."; |
| 1671 | + let subscriptionCount = 0; |
| 1672 | + let releaseSecondInitOutput: (() => void) | undefined; |
| 1673 | + let releaseSecondCaughtUp: (() => void) | undefined; |
| 1674 | + |
| 1675 | + const getInitMessage = (): { |
| 1676 | + state: ReturnType<WorkspaceStore["getWorkspaceState"]>; |
| 1677 | + initMessage: Extract<DisplayedMessage, { type: "workspace-init" }> | undefined; |
| 1678 | + } => { |
| 1679 | + const state = store.getWorkspaceState(workspaceId); |
| 1680 | + const initMessage = state.messages.find( |
| 1681 | + (message): message is Extract<DisplayedMessage, { type: "workspace-init" }> => |
| 1682 | + message.type === "workspace-init" |
| 1683 | + ); |
| 1684 | + return { state, initMessage }; |
| 1685 | + }; |
| 1686 | + |
| 1687 | + mockOnChat.mockImplementation(async function* ( |
| 1688 | + input?: { workspaceId: string; mode?: unknown }, |
| 1689 | + options?: { signal?: AbortSignal } |
| 1690 | + ): AsyncGenerator<WorkspaceChatMessage, void, unknown> { |
| 1691 | + if (input?.workspaceId !== workspaceId) { |
| 1692 | + await waitForAbortSignal(options?.signal); |
| 1693 | + return; |
| 1694 | + } |
| 1695 | + |
| 1696 | + subscriptionCount += 1; |
| 1697 | + |
| 1698 | + if (subscriptionCount === 1) { |
| 1699 | + yield { type: "caught-up" }; |
| 1700 | + await Promise.resolve(); |
| 1701 | + yield { |
| 1702 | + type: "init-start", |
| 1703 | + hookPath: "/tmp/project/.mux/init", |
| 1704 | + timestamp: 1_000, |
| 1705 | + }; |
| 1706 | + await Promise.resolve(); |
| 1707 | + yield { |
| 1708 | + type: "init-output", |
| 1709 | + line: firstLine, |
| 1710 | + isError: false, |
| 1711 | + timestamp: 1_001, |
| 1712 | + }; |
| 1713 | + await waitForAbortSignal(options?.signal); |
| 1714 | + return; |
| 1715 | + } |
| 1716 | + |
| 1717 | + yield { |
| 1718 | + type: "init-start", |
| 1719 | + hookPath: "/tmp/project/.mux/init", |
| 1720 | + timestamp: 2_000, |
| 1721 | + }; |
| 1722 | + await new Promise<void>((resolve) => { |
| 1723 | + releaseSecondInitOutput = resolve; |
| 1724 | + }); |
| 1725 | + yield { |
| 1726 | + type: "init-output", |
| 1727 | + line: replayedLine, |
| 1728 | + isError: false, |
| 1729 | + timestamp: 2_001, |
| 1730 | + }; |
| 1731 | + await new Promise<void>((resolve) => { |
| 1732 | + releaseSecondCaughtUp = resolve; |
| 1733 | + }); |
| 1734 | + yield { type: "caught-up", replay: "full" }; |
| 1735 | + await waitForAbortSignal(options?.signal); |
| 1736 | + }); |
| 1737 | + |
| 1738 | + createAndAddWorkspace(store, workspaceId); |
| 1739 | + |
| 1740 | + const sawInitialInit = await waitUntil(() => { |
| 1741 | + const { state, initMessage } = getInitMessage(); |
| 1742 | + return ( |
| 1743 | + state.loading === false && |
| 1744 | + initMessage?.status === "running" && |
| 1745 | + initMessage.lines[0]?.line === firstLine |
| 1746 | + ); |
| 1747 | + }); |
| 1748 | + expect(sawInitialInit).toBe(true); |
| 1749 | + |
| 1750 | + createAndAddWorkspace(store, otherWorkspaceId); |
| 1751 | + store.setActiveWorkspaceId(workspaceId); |
| 1752 | + |
| 1753 | + const sawEmptyReconnectInit = await waitUntil(() => { |
| 1754 | + const { state, initMessage } = getInitMessage(); |
| 1755 | + return ( |
| 1756 | + subscriptionCount >= 2 && |
| 1757 | + state.loading === false && |
| 1758 | + state.isHydratingTranscript === false && |
| 1759 | + initMessage?.status === "running" && |
| 1760 | + initMessage.lines.length === 0 |
| 1761 | + ); |
| 1762 | + }); |
| 1763 | + expect(sawEmptyReconnectInit).toBe(true); |
| 1764 | + |
| 1765 | + releaseSecondInitOutput?.(); |
| 1766 | + |
| 1767 | + const replayedInitBeforeCaughtUp = await waitUntil(() => { |
| 1768 | + const { state, initMessage } = getInitMessage(); |
| 1769 | + return ( |
| 1770 | + subscriptionCount >= 2 && |
| 1771 | + state.loading === false && |
| 1772 | + state.isHydratingTranscript === false && |
| 1773 | + initMessage?.status === "running" && |
| 1774 | + initMessage.lines.length === 1 && |
| 1775 | + initMessage.lines[0]?.line === replayedLine |
| 1776 | + ); |
| 1777 | + }); |
| 1778 | + expect(replayedInitBeforeCaughtUp).toBe(true); |
| 1779 | + |
| 1780 | + releaseSecondCaughtUp?.(); |
| 1781 | + |
| 1782 | + const stayedVisibleAfterCaughtUp = await waitUntil(() => { |
| 1783 | + const { state, initMessage } = getInitMessage(); |
| 1784 | + return ( |
| 1785 | + !state.loading && |
| 1786 | + initMessage?.status === "running" && |
| 1787 | + initMessage.lines[0]?.line === replayedLine |
| 1788 | + ); |
| 1789 | + }); |
| 1790 | + expect(stayedVisibleAfterCaughtUp).toBe(true); |
| 1791 | + }); |
| 1792 | + |
1665 | 1793 | it("active workspace still shows starting during legitimate startup gap", async () => { |
1666 | 1794 | const workspaceId = "stream-starting-active-workspace"; |
1667 | 1795 |
|
|
0 commit comments