merge(web): parité layout AppImage desktop + switch TUI/CLI custom — #208 (QA verte)
This commit is contained in:
@ -211,4 +211,119 @@ describe("HttpAgentGateway WS round-trip (B6 frames)", () => {
|
||||
}));
|
||||
await expect(resPromise).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
});
|
||||
|
||||
it("routes structured chat through chat.* frames and HTTP close/cancel commands", async () => {
|
||||
const calls: { url: string; init: unknown }[] = [];
|
||||
const socket = new FakeSocket();
|
||||
const ws = new WsLiveClient({
|
||||
wsUrl: "wss://host",
|
||||
socketFactory: () => {
|
||||
queueMicrotask(() => socket.onopen?.());
|
||||
return socket;
|
||||
},
|
||||
});
|
||||
const gw = new HttpAgentGateway(
|
||||
new HttpInvoker({
|
||||
baseUrl: "https://host",
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url, init });
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => undefined,
|
||||
text: async () => "",
|
||||
};
|
||||
},
|
||||
}),
|
||||
ws,
|
||||
);
|
||||
|
||||
const launched = gw.launchAgentChat("proj-1", "agent-1", {
|
||||
cwd: "/srv/app",
|
||||
nodeId: "node-1",
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
conversationId: "resume-9",
|
||||
});
|
||||
const launch = await replyToLast(socket, 0, (id) => ({
|
||||
kind: "chat.attached",
|
||||
replyTo: id,
|
||||
payload: {
|
||||
sessionId: "chat-1",
|
||||
cellKind: "chat",
|
||||
assignedConversationId: "conv-1",
|
||||
},
|
||||
}));
|
||||
await expect(launched).resolves.toEqual({
|
||||
sessionId: "chat-1",
|
||||
cellKind: "chat",
|
||||
assignedConversationId: "conv-1",
|
||||
});
|
||||
expect(launch.kind).toBe("agent.launch");
|
||||
expect(launch.payload).toEqual({
|
||||
projectId: "proj-1",
|
||||
agentId: "agent-1",
|
||||
nodeId: "node-1",
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
cellKind: "chat",
|
||||
conversationId: "resume-9",
|
||||
freshStart: false,
|
||||
});
|
||||
|
||||
const reattached = gw.reattachAgentChat("chat-1", () => {});
|
||||
const attach = await replyToLast(socket, 1, (id) => ({
|
||||
kind: "chat.attached",
|
||||
replyTo: id,
|
||||
payload: {
|
||||
sessionId: "chat-1",
|
||||
scrollback: [{ kind: "final", content: "Recovered reply" }],
|
||||
},
|
||||
}));
|
||||
await expect(reattached).resolves.toEqual({
|
||||
sessionId: "chat-1",
|
||||
scrollback: [{ kind: "final", content: "Recovered reply" }],
|
||||
});
|
||||
expect(attach.kind).toBe("chat.attach");
|
||||
expect(attach.payload).toEqual({ sessionId: "chat-1" });
|
||||
|
||||
const chunks: unknown[] = [];
|
||||
const sent = gw.sendAgentChat("chat-1", "Hello", (chunk) => chunks.push(chunk), {
|
||||
attachments: [{ path: "/tmp/note.txt", filename: "note.txt" }],
|
||||
});
|
||||
const chatSend = await replyToLast(socket, 2, (id) => ({
|
||||
kind: "chat.output",
|
||||
replyTo: id,
|
||||
payload: {
|
||||
sessionId: "chat-1",
|
||||
chunk: { kind: "final", content: "Hello back" },
|
||||
},
|
||||
}));
|
||||
await sent;
|
||||
expect(chatSend.kind).toBe("chat.send");
|
||||
expect(chatSend.payload).toEqual({
|
||||
sessionId: "chat-1",
|
||||
prompt: "Hello",
|
||||
attachments: [{ path: "/tmp/note.txt", filename: "note.txt" }],
|
||||
});
|
||||
expect(chunks).toEqual([{ kind: "final", content: "Hello back" }]);
|
||||
|
||||
await gw.cancelAgentChat("chat-1");
|
||||
await gw.closeAgentChat("chat-1");
|
||||
await gw.detachAgentChat("chat-1");
|
||||
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(JSON.parse((calls[0].init as { body: string }).body)).toEqual({
|
||||
command: "cancel_agent_chat",
|
||||
args: { sessionId: "chat-1" },
|
||||
});
|
||||
expect(JSON.parse((calls[1].init as { body: string }).body)).toEqual({
|
||||
command: "close_agent_session",
|
||||
args: { sessionId: "chat-1" },
|
||||
});
|
||||
expect(JSON.parse(socket.sent[3])).toMatchObject({
|
||||
kind: "chat.detach",
|
||||
payload: { sessionId: "chat-1" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -14,6 +14,8 @@
|
||||
* survives JSON.
|
||||
*/
|
||||
|
||||
import type { ReplyChunk } from "@/domain";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Base64 helpers (binary PTY bytes over JSON frames)
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -137,5 +139,18 @@ export interface StatusPayload {
|
||||
/** Payload of a `chat.output` frame (structured assistant stream). */
|
||||
export interface ChatOutputPayload {
|
||||
sessionId: string;
|
||||
chunk: unknown; // ReplyChunk-shaped (kind-tagged); normalized by the caller.
|
||||
chunk: ReplyChunk;
|
||||
}
|
||||
|
||||
/** Payload of a `chat.attached` acknowledgement. */
|
||||
export interface ChatAttachedPayload {
|
||||
sessionId?: string;
|
||||
session?: {
|
||||
sessionId: string;
|
||||
projectId?: string;
|
||||
nodeId?: string | null;
|
||||
};
|
||||
cellKind?: "chat" | "pty";
|
||||
assignedConversationId?: string;
|
||||
scrollback?: ReplyChunk[];
|
||||
}
|
||||
|
||||
@ -25,9 +25,11 @@ import type {
|
||||
AppExitWorkGuardState,
|
||||
DomainEvent,
|
||||
EffortSelection,
|
||||
ExecuteSlashCommandResult,
|
||||
HealthReport,
|
||||
ReplyChunk,
|
||||
ResumableAgent,
|
||||
SlashCommand,
|
||||
Sprint,
|
||||
TerminalSession,
|
||||
Ticket,
|
||||
@ -40,6 +42,7 @@ import type {
|
||||
Unsubscribe,
|
||||
} from "@/domain";
|
||||
import type {
|
||||
AgentChatHandle,
|
||||
AgentGateway,
|
||||
ChatDraftScope,
|
||||
ChatDraftSnapshot,
|
||||
@ -48,7 +51,9 @@ import type {
|
||||
CreateTicketInput,
|
||||
LiveAgent,
|
||||
OpenTerminalOptions,
|
||||
ReattachAgentChatResult,
|
||||
ReattachResult,
|
||||
SendAgentChatOptions,
|
||||
StoppedLiveAgent,
|
||||
SystemGateway,
|
||||
TerminalGateway,
|
||||
@ -59,7 +64,7 @@ import type {
|
||||
} from "@/ports";
|
||||
import type { HttpInvoker } from "./httpInvoker";
|
||||
import { WsLiveClient } from "./wsLiveClient";
|
||||
import { type ChatOutputPayload } from "./frames";
|
||||
import { type ChatAttachedPayload, type ChatOutputPayload } from "./frames";
|
||||
import { unsupportedOnWeb } from "./unsupported";
|
||||
|
||||
/**
|
||||
@ -96,6 +101,14 @@ export function makeWsTerminalHandle(
|
||||
};
|
||||
}
|
||||
|
||||
function chatSessionId(payload: ChatAttachedPayload): string {
|
||||
const sessionId = payload.sessionId ?? payload.session?.sessionId;
|
||||
if (!sessionId) {
|
||||
throw { code: "INVALID_CHAT_ACK", message: "chat acknowledgement omitted sessionId" };
|
||||
}
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
export class HttpSystemGateway implements SystemGateway {
|
||||
constructor(
|
||||
private readonly http: HttpInvoker,
|
||||
@ -306,6 +319,114 @@ export class HttpAgentGateway implements AgentGateway {
|
||||
scrollback: res.scrollback,
|
||||
};
|
||||
}
|
||||
|
||||
async launchAgentChat(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
options: OpenTerminalOptions,
|
||||
): Promise<AgentChatHandle> {
|
||||
const ack = await this.ws.send("agent.launch", {
|
||||
projectId,
|
||||
agentId,
|
||||
nodeId: options.nodeId ?? null,
|
||||
rows: options.rows,
|
||||
cols: options.cols,
|
||||
cellKind: "chat",
|
||||
conversationId: options.conversationId ?? null,
|
||||
freshStart: options.freshStart ?? false,
|
||||
});
|
||||
if (ack.kind !== "chat.attached") {
|
||||
throw {
|
||||
code: "STRUCTURED_ROUTED_TO_NON_CHAT",
|
||||
message: `custom CLI launch returned ${ack.kind}; expected chat.attached`,
|
||||
};
|
||||
}
|
||||
const payload = ack.payload as unknown as ChatAttachedPayload;
|
||||
if (payload.cellKind && payload.cellKind !== "chat") {
|
||||
throw {
|
||||
code: "STRUCTURED_ROUTED_TO_NON_CHAT",
|
||||
message: `custom CLI launch returned cellKind=${payload.cellKind}; expected chat`,
|
||||
};
|
||||
}
|
||||
const sessionId = chatSessionId(payload);
|
||||
return {
|
||||
sessionId,
|
||||
cellKind: "chat",
|
||||
...(payload.assignedConversationId
|
||||
? { assignedConversationId: payload.assignedConversationId }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
async reattachAgentChat(
|
||||
sessionId: string,
|
||||
onChunk: (chunk: ReplyChunk) => void,
|
||||
): Promise<ReattachAgentChatResult> {
|
||||
const ack = await this.ws.send("chat.attach", { sessionId });
|
||||
if (ack.kind !== "chat.attached") {
|
||||
throw {
|
||||
code: "CHAT_ATTACH_FAILED",
|
||||
message: `chat attach returned ${ack.kind}; expected chat.attached`,
|
||||
};
|
||||
}
|
||||
const payload = ack.payload as unknown as ChatAttachedPayload;
|
||||
const attachedSessionId = chatSessionId(payload);
|
||||
this.ws.setChatSink(attachedSessionId, onChunk);
|
||||
return {
|
||||
sessionId: attachedSessionId,
|
||||
scrollback: payload.scrollback ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
async detachAgentChat(sessionId: string): Promise<void> {
|
||||
this.ws.removeChatSink(sessionId);
|
||||
await this.ws.sendFireAndForget("chat.detach", { sessionId });
|
||||
}
|
||||
|
||||
async sendAgentChat(
|
||||
sessionId: string,
|
||||
prompt: string,
|
||||
onChunk: (chunk: ReplyChunk) => void,
|
||||
options: SendAgentChatOptions = {},
|
||||
): Promise<void> {
|
||||
this.ws.setChatSink(sessionId, onChunk);
|
||||
const ack = await this.ws.send("chat.send", {
|
||||
sessionId,
|
||||
prompt,
|
||||
attachments: options.attachments ?? [],
|
||||
});
|
||||
const payload = ack.payload as unknown as ChatOutputPayload | undefined;
|
||||
if (payload?.chunk) onChunk(payload.chunk);
|
||||
}
|
||||
|
||||
listSlashCommands(prefix?: string): Promise<SlashCommand[]> {
|
||||
const request = prefix === undefined ? {} : { prefix };
|
||||
return this.http
|
||||
.invoke<{ commands: SlashCommand[] }>("list_slash_commands", { request })
|
||||
.then((res) => res.commands);
|
||||
}
|
||||
|
||||
executeSlashCommand(
|
||||
name: string,
|
||||
options: { sessionId?: string | null; arguments?: unknown[] } = {},
|
||||
): Promise<ExecuteSlashCommandResult> {
|
||||
return this.http.invoke<ExecuteSlashCommandResult>("execute_slash_command", {
|
||||
request: {
|
||||
name,
|
||||
sessionId: options.sessionId ?? null,
|
||||
arguments: options.arguments ?? [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async closeAgentChat(sessionId: string): Promise<void> {
|
||||
this.ws.removeChatSink(sessionId);
|
||||
await this.http.invoke("close_agent_session", { sessionId });
|
||||
}
|
||||
|
||||
async cancelAgentChat(sessionId: string): Promise<void> {
|
||||
await this.http.invoke("cancel_agent_chat", { sessionId });
|
||||
}
|
||||
}
|
||||
|
||||
export class HttpTicketGateway implements TicketGateway {
|
||||
|
||||
83
frontend/src/adapters/http/wsLiveClient.chat.test.ts
Normal file
83
frontend/src/adapters/http/wsLiveClient.chat.test.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import type { WebSocketLike } from "./wsLiveClient";
|
||||
import { WsLiveClient } from "./wsLiveClient";
|
||||
|
||||
class FakeSocket implements WebSocketLike {
|
||||
sent: string[] = [];
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((event: { data: string }) => void) | null = null;
|
||||
onerror: ((event: unknown) => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
|
||||
send(data: string): void {
|
||||
this.sent.push(data);
|
||||
}
|
||||
close(): void {
|
||||
this.onclose?.();
|
||||
}
|
||||
receive(frame: unknown): void {
|
||||
this.onmessage?.({ data: JSON.stringify(frame) });
|
||||
}
|
||||
}
|
||||
|
||||
function connectedClient(): { client: WsLiveClient; socket: FakeSocket } {
|
||||
const socket = new FakeSocket();
|
||||
const client = new WsLiveClient({
|
||||
wsUrl: "wss://host",
|
||||
socketFactory: () => {
|
||||
queueMicrotask(() => socket.onopen?.());
|
||||
return socket;
|
||||
},
|
||||
});
|
||||
return { client, socket };
|
||||
}
|
||||
|
||||
describe("WsLiveClient structured chat routing", () => {
|
||||
it("routes chat.output to the per-session chat sink", async () => {
|
||||
const { client, socket } = connectedClient();
|
||||
await client.ensureConnected();
|
||||
|
||||
const received: unknown[] = [];
|
||||
client.setChatSink("chat-1", (chunk) => received.push(chunk));
|
||||
|
||||
socket.receive({
|
||||
kind: "chat.output",
|
||||
payload: {
|
||||
sessionId: "chat-1",
|
||||
chunk: { kind: "final", content: "done" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(received).toEqual([{ kind: "final", content: "done" }]);
|
||||
|
||||
client.removeChatSink("chat-1");
|
||||
socket.receive({
|
||||
kind: "chat.output",
|
||||
payload: {
|
||||
sessionId: "chat-1",
|
||||
chunk: { kind: "final", content: "ignored" },
|
||||
},
|
||||
});
|
||||
expect(received).toEqual([{ kind: "final", content: "done" }]);
|
||||
});
|
||||
|
||||
it("clears structured chat sinks on disconnect", async () => {
|
||||
const { client, socket } = connectedClient();
|
||||
await client.ensureConnected();
|
||||
|
||||
const received: unknown[] = [];
|
||||
client.setChatSink("chat-1", (chunk) => received.push(chunk));
|
||||
client.disconnect();
|
||||
expect(client.getConnectionState()).toBe("closed");
|
||||
|
||||
socket.receive({
|
||||
kind: "chat.output",
|
||||
payload: {
|
||||
sessionId: "chat-1",
|
||||
chunk: { kind: "final", content: "dropped" },
|
||||
},
|
||||
});
|
||||
expect(received).toEqual([]);
|
||||
});
|
||||
});
|
||||
@ -30,11 +30,12 @@
|
||||
* output sink (a notice line), so no component needs to know about this client.
|
||||
*/
|
||||
|
||||
import type { DomainEvent, Unsubscribe } from "@/domain";
|
||||
import type { DomainEvent, ReplyChunk, Unsubscribe } from "@/domain";
|
||||
import {
|
||||
base64ToBytes,
|
||||
bytesToBase64,
|
||||
type AttachedPayload,
|
||||
type ChatOutputPayload,
|
||||
type ClientFrame,
|
||||
type ClientFrameKind,
|
||||
type OutputPayload,
|
||||
@ -167,6 +168,8 @@ export class WsLiveClient {
|
||||
|
||||
/** Per-session PTY output sinks (sessionId → onData). Low-level routing map. */
|
||||
private readonly outputSinks = new Map<string, (bytes: Uint8Array) => void>();
|
||||
/** Per-session structured chat sinks (sessionId → onChunk). */
|
||||
private readonly chatSinks = new Map<string, (chunk: ReplyChunk) => void>();
|
||||
/** Live terminal subscriptions tracked for reconnection replay + status. */
|
||||
private readonly terminals = new Map<string, TerminalSubscription>();
|
||||
/** Pending command acknowledgements, keyed by client frame id. */
|
||||
@ -236,6 +239,12 @@ export class WsLiveClient {
|
||||
removeOutputSink(sessionId: string): void {
|
||||
this.outputSinks.delete(sessionId);
|
||||
}
|
||||
setChatSink(sessionId: string, onChunk: (chunk: ReplyChunk) => void): void {
|
||||
this.chatSinks.set(sessionId, onChunk);
|
||||
}
|
||||
removeChatSink(sessionId: string): void {
|
||||
this.chatSinks.delete(sessionId);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Connection lifecycle
|
||||
@ -518,6 +527,7 @@ export class WsLiveClient {
|
||||
}
|
||||
this.rejectAllPending({ code: "WS_CLOSED", message: "client disconnected" });
|
||||
this.outputSinks.clear();
|
||||
this.chatSinks.clear();
|
||||
this.terminals.clear();
|
||||
this.domainEventHandler = null;
|
||||
const socket = this.socket;
|
||||
@ -544,6 +554,7 @@ export class WsLiveClient {
|
||||
}
|
||||
this.rejectAllPending({ code: "WS_CLOSED", message: "client disposed" });
|
||||
this.outputSinks.clear();
|
||||
this.chatSinks.clear();
|
||||
this.terminals.clear();
|
||||
this.domainEventHandler = null;
|
||||
this.socket?.close();
|
||||
@ -565,7 +576,10 @@ export class WsLiveClient {
|
||||
return; // Ignore malformed frames.
|
||||
}
|
||||
|
||||
// Route unsolicited streams first (no replyTo).
|
||||
// Route unsolicited streams first. Frames carrying `replyTo` are command
|
||||
// acknowledgements and must fall through to the pending waiter below; in
|
||||
// particular `chat.send` may acknowledge with a `chat.output` frame.
|
||||
if (!frame.replyTo) {
|
||||
switch (frame.kind) {
|
||||
case "terminal.output": {
|
||||
const payload = frame.payload as unknown as OutputPayload;
|
||||
@ -584,9 +598,15 @@ export class WsLiveClient {
|
||||
this.domainEventHandler?.(frame.payload as unknown as DomainEvent);
|
||||
return;
|
||||
}
|
||||
case "chat.output": {
|
||||
const payload = frame.payload as unknown as ChatOutputPayload;
|
||||
this.chatSinks.get(payload.sessionId)?.(payload.chunk);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise it is a reply/ack correlated by `replyTo`.
|
||||
if (frame.replyTo) {
|
||||
|
||||
107
frontend/src/features/web/WebDesktopWorkspace.test.tsx
Normal file
107
frontend/src/features/web/WebDesktopWorkspace.test.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
import { beforeEach, describe, it, expect } from "vitest";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
|
||||
import type { Gateways } from "@/ports";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { createMockGateways, MockWorkStateGateway } from "@/adapters/mock";
|
||||
import { WebSession } from "@/adapters/http";
|
||||
import type { FetchLike } from "@/adapters/http/httpInvoker";
|
||||
import type { FlagStore } from "@/adapters/http/webSession";
|
||||
import { WebApp } from "./WebApp";
|
||||
|
||||
function memStore(): FlagStore {
|
||||
const map = new Map<string, string>();
|
||||
return {
|
||||
getItem: (k) => map.get(k) ?? null,
|
||||
setItem: (k, v) => void map.set(k, v),
|
||||
removeItem: (k) => void map.delete(k),
|
||||
};
|
||||
}
|
||||
|
||||
const okFetch: FetchLike = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ ok: true }),
|
||||
text: async () => "{}",
|
||||
});
|
||||
|
||||
async function seededGateways(): Promise<Gateways> {
|
||||
const gateways = createMockGateways();
|
||||
const project = await gateways.project.createProject("Demo", "/srv/demo");
|
||||
(gateways.workState as MockWorkStateGateway)._setProjectWorkState(project.id, {
|
||||
agents: [
|
||||
{ agentId: "a1", name: "Archi", profileId: "p1", busy: { state: "idle" }, tickets: [] },
|
||||
],
|
||||
conversations: [],
|
||||
});
|
||||
return gateways;
|
||||
}
|
||||
|
||||
function renderPairedWebApp(gateways: Gateways) {
|
||||
const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() });
|
||||
session.markPaired();
|
||||
return render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<WebApp session={session} />
|
||||
</DIProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
window.innerWidth = 1280;
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
});
|
||||
|
||||
describe("WebWorkspace desktop layout", () => {
|
||||
it("mounts the desktop AppImage-style shell with LayoutTabs/LayoutGrid and no detached action", async () => {
|
||||
renderPairedWebApp(await seededGateways());
|
||||
|
||||
expect(
|
||||
(await screen.findByTestId("web-workspace")).getAttribute("data-shell"),
|
||||
).toBe(
|
||||
"desktop-layout",
|
||||
);
|
||||
expect(screen.queryByTestId("web-mobile-task-canvas")).toBeNull();
|
||||
|
||||
fireEvent.click(await screen.findByText("Demo"));
|
||||
|
||||
expect(await screen.findByTestId("layout-tabs")).toBeTruthy();
|
||||
expect(await screen.findByTestId("layout-grid")).toBeTruthy();
|
||||
expect(screen.getByLabelText("right dock")).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: /detach/i })).toBeNull();
|
||||
expect(screen.queryByText(/Fenêtre/)).toBeNull();
|
||||
});
|
||||
|
||||
it("opens panels as floating or docked from the desktop menu bar without a detached web mode", async () => {
|
||||
renderPairedWebApp(await seededGateways());
|
||||
fireEvent.click(await screen.findByText("Demo"));
|
||||
await screen.findByTestId("layout-grid");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Panneaux" }));
|
||||
const menu = await screen.findByRole("menu", { name: "Panneaux" });
|
||||
|
||||
fireEvent.click(within(menu).getByRole("button", { name: "Tickets" }));
|
||||
const submenu = await screen.findByRole("menu", { name: "Tickets" });
|
||||
fireEvent.click(within(submenu).getByRole("button", { name: "Flottant" }));
|
||||
|
||||
expect(await screen.findByRole("dialog", { name: "Tickets" })).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: /detached/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("can dock a panel to the left from the menu bar while keeping the grid in the main area", async () => {
|
||||
renderPairedWebApp(await seededGateways());
|
||||
fireEvent.click(await screen.findByText("Demo"));
|
||||
await screen.findByTestId("layout-grid");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Panneaux" }));
|
||||
const menu = await screen.findByRole("menu", { name: "Panneaux" });
|
||||
fireEvent.click(within(menu).getByRole("button", { name: "Project context" }));
|
||||
const submenu = await screen.findByRole("menu", { name: "Project context" });
|
||||
fireEvent.click(within(submenu).getByRole("button", { name: "Ancré à gauche" }));
|
||||
|
||||
expect(await screen.findByLabelText("left dock")).toBeTruthy();
|
||||
expect(await screen.findByLabelText("Project context panel")).toBeTruthy();
|
||||
expect(screen.getByTestId("layout-grid")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user