feat: implement fuzzy search and replace functionality with Levenshtein distance

- Added `applySearchReplace` function to handle search and replace operations with fuzzy matching capabilities.
- Introduced tests for various scenarios including fuzzy matching with typos, exact matches, and handling whitespace differences.
- Created a parser for search/replace blocks to facilitate the new functionality.
- Updated prompts for search-replace operations to clarify usage and examples.
- Added utility functions for text normalization and language detection based on file extensions.
- Implemented a minimal stdio MCP server for local testing with tools for adding numbers and printing environment variables.
This commit is contained in:
Kunthawat Greethong
2025-12-05 11:28:57 +07:00
parent 11986a0196
commit d22227bb13
312 changed files with 30787 additions and 2829 deletions

View File

@@ -71,16 +71,25 @@ class ProModesDialog {
public close: () => Promise<void>,
) {}
async setSmartContextMode(mode: "balanced" | "off" | "conservative") {
async setSmartContextMode(mode: "balanced" | "off" | "deep") {
await this.page
.getByTestId("smart-context-selector")
.getByRole("button", {
name: mode.charAt(0).toUpperCase() + mode.slice(1),
})
.click();
}
async toggleTurboEdits() {
await this.page.getByRole("switch", { name: "Turbo Edits" }).click();
async setTurboEditsMode(mode: "off" | "classic" | "search-replace") {
await this.page
.getByTestId("turbo-edits-selector")
.getByRole("button", {
name:
mode === "search-replace"
? "Search & replace"
: mode.charAt(0).toUpperCase() + mode.slice(1),
})
.click();
}
}
@@ -330,7 +339,7 @@ export class PageObject {
await this.page.getByRole("button", { name: "Import" }).click();
}
async selectChatMode(mode: "build" | "ask") {
async selectChatMode(mode: "build" | "ask" | "agent") {
await this.page.getByTestId("chat-mode-selector").click();
await this.page.getByRole("option", { name: mode }).click();
}
@@ -362,7 +371,7 @@ export class PageObject {
await expect(this.page.getByRole("dialog")).toMatchAriaSnapshot();
}
async snapshotAppFiles({ name }: { name: string }) {
async snapshotAppFiles({ name, files }: { name: string; files?: string[] }) {
const currentAppName = await this.getCurrentAppName();
if (!currentAppName) {
throw new Error("No app selected");
@@ -374,10 +383,17 @@ export class PageObject {
}
await expect(() => {
const filesData = generateAppFilesSnapshotData(appPath, appPath);
let filesData = generateAppFilesSnapshotData(appPath, appPath);
// Sort by relative path to ensure deterministic output
filesData.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
if (files) {
filesData = filesData.filter((file) =>
files.some(
(f) => normalizePath(f) === normalizePath(file.relativePath),
),
);
}
const snapshotContent = filesData
.map(
@@ -400,7 +416,8 @@ export class PageObject {
async snapshotMessages({
replaceDumpPath = false,
}: { replaceDumpPath?: boolean } = {}) {
timeout,
}: { replaceDumpPath?: boolean; timeout?: number } = {}) {
if (replaceDumpPath) {
// Update page so that "[[dyad-dump-path=*]]" is replaced with a placeholder path
// which is stable across runs.
@@ -417,7 +434,9 @@ export class PageObject {
);
});
}
await expect(this.page.getByTestId("messages-list")).toMatchAriaSnapshot();
await expect(this.page.getByTestId("messages-list")).toMatchAriaSnapshot({
timeout,
});
}
async approveProposal() {
@@ -431,15 +450,48 @@ export class PageObject {
async clickRestart() {
await this.page.getByRole("button", { name: "Restart" }).click();
}
////////////////////////////////
// Inline code editor
////////////////////////////////
async clickEditButton() {
await this.page.locator('button:has-text("Edit")').first().click();
}
async editFileContent(content: string) {
const editor = this.page.locator(".monaco-editor textarea").first();
await editor.focus();
await editor.press("Home");
await editor.type(content);
}
async saveFile() {
await this.page.locator('[data-testid="save-file-button"]').click();
}
async cancelEdit() {
await this.page.locator('button:has-text("Cancel")').first().click();
}
////////////////////////////////
// Preview panel
////////////////////////////////
async selectPreviewMode(mode: "code" | "problems" | "preview" | "configure") {
async selectPreviewMode(
mode: "code" | "problems" | "preview" | "configure" | "security",
) {
await this.page.getByTestId(`${mode}-mode-button`).click();
}
async clickChatActivityButton() {
await this.page.getByTestId("chat-activity-button").click();
}
async snapshotChatActivityList() {
await expect(
this.page.getByTestId("chat-activity-list"),
).toMatchAriaSnapshot();
}
async clickRecheckProblems() {
await this.page.getByTestId("recheck-button").click();
}
@@ -470,8 +522,15 @@ export class PageObject {
.click({ timeout: Timeout.EXTRA_LONG });
}
async clickDeselectComponent() {
await this.page.getByRole("button", { name: "Deselect component" }).click();
async clickDeselectComponent(options?: { index?: number }) {
const buttons = this.page.getByRole("button", {
name: "Deselect component",
});
if (options?.index !== undefined) {
await buttons.nth(options.index).click();
} else {
await buttons.first().click();
}
}
async clickPreviewMoreOptions() {
@@ -530,12 +589,12 @@ export class PageObject {
await expect(this.getChatInputContainer()).toMatchAriaSnapshot();
}
getSelectedComponentDisplay() {
getSelectedComponentsDisplay() {
return this.page.getByTestId("selected-component-display");
}
async snapshotSelectedComponentDisplay() {
await expect(this.getSelectedComponentDisplay()).toMatchAriaSnapshot();
async snapshotSelectedComponentsDisplay() {
await expect(this.getSelectedComponentsDisplay()).toMatchAriaSnapshot();
}
async snapshotPreview({ name }: { name?: string } = {}) {
@@ -546,6 +605,12 @@ export class PageObject {
});
}
async snapshotSecurityFindingsTable() {
await expect(
this.page.getByTestId("security-findings-table"),
).toMatchAriaSnapshot();
}
async snapshotServerDump(
type: "all-messages" | "last-message" | "request" = "all-messages",
{ name = "", dumpIndex = -1 }: { name?: string; dumpIndex?: number } = {},
@@ -648,7 +713,7 @@ export class PageObject {
getChatInput() {
return this.page.locator(
'[data-lexical-editor="true"][aria-placeholder="Ask Dyad to build..."]',
'[data-lexical-editor="true"][aria-placeholder^="Ask Dyad to build"]',
);
}
@@ -664,16 +729,21 @@ export class PageObject {
await this.page.getByRole("button", { name: "Back" }).click();
}
async sendPrompt(prompt: string) {
async sendPrompt(
prompt: string,
{ skipWaitForCompletion = false }: { skipWaitForCompletion?: boolean } = {},
) {
await this.getChatInput().click();
await this.getChatInput().fill(prompt);
await this.page.getByRole("button", { name: "Send message" }).click();
await this.waitForChatCompletion();
if (!skipWaitForCompletion) {
await this.waitForChatCompletion();
}
}
async selectModel({ provider, model }: { provider: string; model: string }) {
await this.page.getByRole("button", { name: "Model: Auto" }).click();
await this.page.getByText(provider).click();
await this.page.getByText(provider, { exact: true }).click();
await this.page.getByText(model, { exact: true }).click();
}
@@ -701,6 +771,24 @@ export class PageObject {
.click();
}
async selectTestAzureModel() {
await this.page.getByRole("button", { name: "Model: Auto" }).click();
await this.page.getByText("Other AI providers").click();
await this.page.getByText("Azure OpenAI", { exact: true }).click();
await this.page.getByText("GPT-5", { exact: true }).click();
}
async setUpAzure({ autoApprove = false }: { autoApprove?: boolean } = {}) {
await this.githubConnector.clearPushEvents();
await this.goToSettingsTab();
if (autoApprove) {
await this.toggleAutoApprove();
}
// Azure should already be configured via environment variables
// so we don't need additional setup steps like setUpDyadProvider
await this.goToAppsTab();
}
async setUpTestProvider() {
await this.page.getByText("Add custom providerConnect to").click();
// Fill out provider dialog
@@ -719,9 +807,7 @@ export class PageObject {
}
async setUpTestModel() {
await this.page
.getByRole("heading", { name: "test-provider Needs Setup" })
.click();
await this.page.getByRole("heading", { name: "test-provider" }).click();
await this.page.getByRole("button", { name: "Add Custom Model" }).click();
await this.page
.getByRole("textbox", { name: "Model ID*" })
@@ -984,6 +1070,7 @@ export class PageObject {
interface ElectronConfig {
preLaunchHook?: ({ userDataDir }: { userDataDir: string }) => Promise<void>;
showSetupScreen?: boolean;
}
// From https://github.com/microsoft/playwright/issues/8208#issuecomment-1435475930
@@ -1046,8 +1133,10 @@ export const test = base.extend<{
process.env.DYAD_ENGINE_URL = "http://localhost:3500/engine/v1";
process.env.DYAD_GATEWAY_URL = "http://localhost:3500/gateway/v1";
process.env.E2E_TEST_BUILD = "true";
// This is just a hack to avoid the AI setup screen.
process.env.OPENAI_API_KEY = "sk-test";
if (!electronConfig.showSetupScreen) {
// This is just a hack to avoid the AI setup screen.
process.env.OPENAI_API_KEY = "sk-test";
}
const baseTmpDir = os.tmpdir();
const userDataDir = path.join(baseTmpDir, `dyad-e2e-tests-${Date.now()}`);
if (electronConfig.preLaunchHook) {
@@ -1131,6 +1220,17 @@ export function testWithConfig(config: ElectronConfig) {
});
}
export function testWithConfigSkipIfWindows(config: ElectronConfig) {
if (os.platform() === "win32") {
return test.skip;
}
return test.extend({
electronConfig: async ({}, use) => {
await use(config);
},
});
}
// Wrapper that skips tests on Windows platform
export const testSkipIfWindows = os.platform() === "win32" ? test.skip : test;
@@ -1163,3 +1263,7 @@ function prettifyDump(
})
.join("\n\n");
}
function normalizePath(path: string): string {
return path.replace(/\\/g, "/");
}