Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitleaksignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
8f797d824d664ccecaf1ce356509db5a3e3b739d:e2e/tests/enterprise/oidc.spec.js:generic-api-key:176
c506a468bcc77f790cfbb2d412fb9674a47db7cb:e2e/tests/console/mcpBridgePermissions.spec.js:generic-api-key:9
c506a468bcc77f790cfbb2d412fb9674a47db7cb:src/utils/mcp/consumePendingPair.test.ts:generic-api-key:27
5012b6fa7b20dd00bdc0c1bf188d8e3b48d36c0a:e2e/utils/mcpFakeWebSocket.js:generic-api-key:5
4 changes: 3 additions & 1 deletion e2e/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,9 @@ Cypress.Commands.add("clearSimulatedWarnings", () => {
cy.execQuery("select simulate_warnings('', '');")
})

Cypress.Commands.add("getByDataHook", (name) => cy.get(`[data-hook="${name}"]`))
Cypress.Commands.add("getByDataHook", (name, options) =>
cy.get(`[data-hook="${name}"]`, options),
)

Cypress.Commands.add("getByRole", (name) => cy.get(`[role="${name}"]`))

Expand Down
133 changes: 133 additions & 0 deletions e2e/tests/console/agentBackgroundEdits.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/// <reference types="cypress" />

// E2E coverage for background (passive) agent notebook edits over the MCP
// bridge: creating and editing a notebook never steals the active tab, the
// footer popper announces the change, and View lands on the edited notebook
// with the agent's cells persisted.

const contextPath = process.env.QDB_HTTP_CONTEXT_WEB_CONSOLE || ""
const baseUrl = `http://localhost:9999${contextPath}`

const {
installFakeWebSocket,
TEST_BRIDGE_TOKEN,
TEST_BRIDGE_URL,
} = require("../../utils/mcpFakeWebSocket")

const deepLinkSuffix = () =>
`?mcp-pair=1&mcp-ws=${encodeURIComponent(TEST_BRIDGE_URL)}` +
`&mcp-token=${encodeURIComponent(TEST_BRIDGE_TOKEN)}`

const loginAndVisitDeepLink = () => {
cy.visit(`${baseUrl}/${deepLinkSuffix()}`, {
onBeforeLoad: (win) => {
win.localStorage.clear()
win.sessionStorage.clear()
win.indexedDB.deleteDatabase("web-console")
win.localStorage.setItem(
"mcp:permissions",
JSON.stringify({ grantSchemaAccess: true, read: true, write: true }),
)
installFakeWebSocket(win)
},
})
cy.loginWithUserAndPassword()
}

const waitForPaired = () => {
cy.window({ timeout: 10000 }).its("__mcpFakeWS").should("exist")
cy.window({ timeout: 10000 }).should((win) => {
expect(win.__mcpFakeWS.framesOfType("hello").length).to.be.greaterThan(0)
})
cy.window().then((win) => win.__mcpFakeWS.helloAck())
cy.getByDataHook("mcp-bridge-status-pill", { timeout: 10000 }).should(
"contain",
"MCP connected",
)
}

const toolResult = (win, requestId) =>
win.__mcpFakeWS
.framesOfType("tool_result")
.find((r) => r.requestId === requestId)

const activeTabTitle = () =>
cy.get(".chrome-tab[active]").first().invoke("attr", "data-tab-title")

const expectActiveTabTitle = (title) =>
cy.get(".chrome-tab[active]").should("have.attr", "data-tab-title", title)

const awaitToolResult = (id) => {
cy.window({ timeout: 10000 }).should((win) => {
expect(toolResult(win, id), `result for ${id}`).to.exist
})
return cy.window().then((win) => {
const result = toolResult(win, id)
expect(result.isError, `tool ${id} errored`).to.not.equal(true)
// The single-line JSON payload may be wrapped by a permissions notice
// above and a since-last-check block below.
const text = result.content[0].text
const payloadLine = text
.split("\n")
.find((line) => line.trimStart().startsWith("{"))
expect(payloadLine, `JSON payload in result for ${id}`).to.exist
return JSON.parse(payloadLine)
})
}

describe("agent background notebook edits (e2e)", () => {
it("creates and fills a notebook in the background; View opens it with the cells persisted", () => {
loginAndVisitDeepLink()
cy.getByDataHook("mcp-pair-consent-connect").click()
waitForPaired()

activeTabTitle().then((initialTab) => {
// Agent creates a notebook — the active tab must not change.
cy.window()
.then((win) =>
awaitToolResult(
win.__mcpFakeWS.toolCall("create_notebook", {
label: "Agent NB",
}),
),
)
.then((created) => {
expect(created.bufferId).to.be.a("number")
expect(created.hint).to.match(/background/i)
cy.get(`.chrome-tab[data-tab-title="Agent NB"]`).should("exist")
expectActiveTabTitle(initialTab)

// Read → edit, per the freshness gate; still fully in the background.
cy.window().then((win) =>
awaitToolResult(
win.__mcpFakeWS.toolCall("get_notebook_state", {
buffer_id: created.bufferId,
}),
),
)
cy.window().then((win) =>
awaitToolResult(
win.__mcpFakeWS.toolCall("add_cell", {
buffer_id: created.bufferId,
sql: "SELECT 123 as agent_made_this",
after_cell_id: null,
run: false,
type: "sql",
}),
),
)
expectActiveTabTitle(initialTab)

// The footer popper announces the background change.
cy.getByDataHook("agent-changes-popper", { timeout: 10000 })
.should("be.visible")
.and("contain", "Agent NB")

// View lands on the notebook with the agent's cell persisted.
cy.getByDataHook("agent-changes-view").click()
expectActiveTabTitle("Agent NB")
cy.contains("agent_made_this", { timeout: 10000 }).should("exist")
})
})
})
})
88 changes: 5 additions & 83 deletions e2e/tests/console/mcpBridgePermissions.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,89 +5,11 @@
const contextPath = process.env.QDB_HTTP_CONTEXT_WEB_CONSOLE || ""
const baseUrl = `http://localhost:9999${contextPath}`

const TEST_BRIDGE_URL = "ws://127.0.0.1:57123"
const TEST_BRIDGE_TOKEN = "abcdef0123456789abcdef0123456789"
// Must match src/utils/mcp/protocolVersion.ts; mismatches are silently dropped.
const PROTOCOL_VERSION = "1"

// Fake WebSocket installed before the app boots; exposed on `win.__mcpFakeWS`.
const installFakeWebSocket = (win) => {
class FakeWS {
constructor(url) {
this.url = url
this.readyState = 0
this.sent = []
this.listeners = {}
win.__mcpFakeWS = this
// Async "open" mirrors real WebSocket — lets listener registration finish.
win.setTimeout(() => {
this.readyState = 1
this._dispatch("open", {})
}, 0)
}
addEventListener(type, fn) {
if (!this.listeners[type]) this.listeners[type] = new Set()
this.listeners[type].add(fn)
}
removeEventListener(type, fn) {
this.listeners[type]?.delete(fn)
}
send(data) {
if (this.readyState !== 1) {
throw new Error("FakeWS.send while not open")
}
this.sent.push(data)
const parsed = JSON.parse(data)
if (parsed.type === "ping") {
this.receive({
v: PROTOCOL_VERSION,
type: "pong",
nonce: parsed.nonce,
})
}
}
close() {
this.readyState = 3
this._dispatch("close", { code: 1000, reason: "test-close" })
}
receive(payload) {
this._dispatch("message", { data: JSON.stringify(payload) })
}
helloAck() {
this.receive({
v: PROTOCOL_VERSION,
type: "hello_ack",
sessionId: "test-session",
heartbeatIntervalMs: 60000,
seenToolCount: 1,
})
}
toolCall(name, args, requestId) {
const id = requestId || "req-" + Math.random().toString(36).slice(2)
this.receive({
v: PROTOCOL_VERSION,
type: "tool_call",
requestId: id,
name,
arguments: args,
deadlineMs: 15000,
})
return id
}
_dispatch(type, ev) {
this.listeners[type]?.forEach((fn) => fn(ev))
}
framesOfType(type) {
return this.sent.map((s) => JSON.parse(s)).filter((m) => m.type === type)
}
}
// Real-WebSocket statics — MCPBridgeClient reads WebSocket.OPEN.
FakeWS.CONNECTING = 0
FakeWS.OPEN = 1
FakeWS.CLOSING = 2
FakeWS.CLOSED = 3
win.WebSocket = FakeWS
}
const {
installFakeWebSocket,
TEST_BRIDGE_TOKEN,
TEST_BRIDGE_URL,
} = require("../../utils/mcpFakeWebSocket")

// Permission classifier calls /api/v1/sql/validate to distinguish DQL vs DDL/DML.
const installValidateIntercept = () => {
Expand Down
5 changes: 5 additions & 0 deletions e2e/tests/console/tableDetails.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -843,6 +843,8 @@ describe("TableDetailsDrawer", () => {
"AI Assistant is not configured",
)

cy.getByDataHook("table-details-tab-details").realHover()
cy.getByDataHook("tooltip").should("not.exist")
cy.getByDataHook("table-details-tab-details").click()
cy.getByDataHook("table-details-explain-ai").should("be.disabled")
cy.getByDataHook("table-details-explain-ai").realHover()
Expand Down Expand Up @@ -892,6 +894,9 @@ describe("TableDetailsDrawer", () => {
"contain",
"Schema access is not granted to this model",
)

cy.getByDataHook("table-details-tab-details").realHover()
cy.getByDataHook("tooltip").should("not.exist")
cy.getByDataHook("table-details-tab-details").click()
cy.getByDataHook("table-details-explain-ai").should("be.disabled")
cy.getByDataHook("table-details-copy-ddl").should("be.visible").click()
Expand Down
92 changes: 92 additions & 0 deletions e2e/utils/mcpFakeWebSocket.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Fake MCP-bridge WebSocket shared by the bridge e2e specs. Installed before
// the app boots; the instance is exposed on `win.__mcpFakeWS`.

const TEST_BRIDGE_URL = "ws://127.0.0.1:57123"
const TEST_BRIDGE_TOKEN = "abcdef0123456789abcdef0123456789"
Comment thread
emrberk marked this conversation as resolved.
Comment thread
emrberk marked this conversation as resolved.
// Must match src/utils/mcp/protocolVersion.ts; mismatches are silently dropped.
const PROTOCOL_VERSION = "1"

const installFakeWebSocket = (win) => {
class FakeWS {
constructor(url) {
this.url = url
this.readyState = 0
this.sent = []
this.listeners = {}
win.__mcpFakeWS = this
// Async "open" mirrors real WebSocket — lets listener registration finish.
win.setTimeout(() => {
this.readyState = 1
this._dispatch("open", {})
}, 0)
}
addEventListener(type, fn) {
if (!this.listeners[type]) this.listeners[type] = new Set()
this.listeners[type].add(fn)
}
removeEventListener(type, fn) {
this.listeners[type]?.delete(fn)
}
send(data) {
if (this.readyState !== 1) {
throw new Error("FakeWS.send while not open")
}
this.sent.push(data)
const parsed = JSON.parse(data)
if (parsed.type === "ping") {
this.receive({
v: PROTOCOL_VERSION,
type: "pong",
nonce: parsed.nonce,
})
}
}
close() {
this.readyState = 3
this._dispatch("close", { code: 1000, reason: "test-close" })
}
receive(payload) {
this._dispatch("message", { data: JSON.stringify(payload) })
}
helloAck() {
this.receive({
v: PROTOCOL_VERSION,
type: "hello_ack",
sessionId: "test-session",
heartbeatIntervalMs: 60000,
seenToolCount: 1,
})
}
toolCall(name, args, requestId) {
const id = requestId || "req-" + Math.random().toString(36).slice(2)
this.receive({
v: PROTOCOL_VERSION,
type: "tool_call",
requestId: id,
name,
arguments: args,
deadlineMs: 15000,
})
return id
}
_dispatch(type, ev) {
this.listeners[type]?.forEach((fn) => fn(ev))
}
framesOfType(type) {
return this.sent.map((s) => JSON.parse(s)).filter((m) => m.type === type)
}
}
// Real-WebSocket statics — MCPBridgeClient reads WebSocket.OPEN.
FakeWS.CONNECTING = 0
FakeWS.OPEN = 1
FakeWS.CLOSING = 2
FakeWS.CLOSED = 3
win.WebSocket = FakeWS
}

module.exports = {
installFakeWebSocket,
PROTOCOL_VERSION,
TEST_BRIDGE_TOKEN,
TEST_BRIDGE_URL,
}
2 changes: 1 addition & 1 deletion src/components/ExplainQueryButton/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import type { ConversationId } from "../../providers/AIConversationProvider/type
import {
executeAIFlow,
createExplainFlowConfig,
} from "../../utils/executeAIFlow"
} from "../../utils/ai/executeAIFlow"
import { trackEvent } from "../../modules/ConsoleEventTracker"
import { ConsoleEvent } from "../../modules/ConsoleEventTracker/events"

Expand Down
5 changes: 4 additions & 1 deletion src/components/FixQueryButton/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ import { useAIStatus } from "../../providers/AIStatusProvider"
import { useAIConversation } from "../../providers/AIConversationProvider"
import { extractErrorByQueryKey } from "../../scenes/Editor/utils"
import type { ExecutionRefs } from "../../scenes/Editor/index"
import { executeAIFlow, createFixFlowConfig } from "../../utils/executeAIFlow"
import {
executeAIFlow,
createFixFlowConfig,
} from "../../utils/ai/executeAIFlow"
import { trackEvent } from "../../modules/ConsoleEventTracker"
import { ConsoleEvent } from "../../modules/ConsoleEventTracker/events"

Expand Down
4 changes: 3 additions & 1 deletion src/components/Overlay/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import styled, { css } from "styled-components"
import { Overlay as RadixDialogOverlay } from "@radix-ui/react-dialog"
import { Overlay as RadixAlertDialogOverlay } from "@radix-ui/react-alert-dialog"

export const OVERLAY_Z_INDEX = 100

const overlayShow = css`
@keyframes overlayShow {
from {
Expand All @@ -29,7 +31,7 @@ const StyledOverlay = styled.div`
background-color: ${({ theme }) => theme.color.overlayBackground};
position: fixed;
inset: 0;
z-index: 100;
z-index: ${OVERLAY_Z_INDEX};

${overlayShow}
${overlayHide}
Expand Down
Loading
Loading