Boilerplate free tests (#277)
This commit is contained in:
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -41,6 +41,8 @@ jobs:
|
|||||||
run: npx playwright install chromium --with-deps
|
run: npx playwright install chromium --with-deps
|
||||||
- name: Build
|
- name: Build
|
||||||
run: npm run pre:e2e
|
run: npm run pre:e2e
|
||||||
|
- name: Prep test server
|
||||||
|
run: cd testing/fake-llm-server && npm install && npm run build && cd -
|
||||||
- name: E2E tests
|
- name: E2E tests
|
||||||
# Add debug logging to make it easier to see what's failing
|
# Add debug logging to make it easier to see what's failing
|
||||||
run: DEBUG=pw:browser npm run e2e
|
run: DEBUG=pw:browser npm run e2e
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -6,6 +6,9 @@ yarn-debug.log*
|
|||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# dist
|
||||||
|
dist/
|
||||||
|
|
||||||
# playwright
|
# playwright
|
||||||
playwright-report/
|
playwright-report/
|
||||||
test-results/
|
test-results/
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"rules": {
|
"rules": {
|
||||||
"eslint/no-unused-vars": "error"
|
"eslint/no-unused-vars": "error",
|
||||||
|
"eslint/no-empty-pattern": "off"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,4 +3,5 @@ build
|
|||||||
coverage
|
coverage
|
||||||
# generated files
|
# generated files
|
||||||
drizzle/
|
drizzle/
|
||||||
**/pnpm-lock.yaml
|
**/pnpm-lock.yaml
|
||||||
|
**/snapshots/**
|
||||||
24
e2e-tests/helpers/codegen.js
Normal file
24
e2e-tests/helpers/codegen.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/*
|
||||||
|
* From: https://github.com/microsoft/playwright/issues/5181#issuecomment-2769098576
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* cd e2e-tests/helpers && node codegen.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { _electron: electron } = require("playwright");
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const browser = await electron.launch({
|
||||||
|
args: [
|
||||||
|
"../../out/dyad-darwin-arm64/dyad.app/Contents/Resources/app.asar/.vite/build/main.js",
|
||||||
|
"--enable-logging",
|
||||||
|
"--user-data-dir=/tmp/dyad-e2e-tests",
|
||||||
|
],
|
||||||
|
executablePath: "../../out/dyad-darwin-arm64/dyad.app/Contents/MacOS/dyad",
|
||||||
|
});
|
||||||
|
const context = await browser.context();
|
||||||
|
await context.route("**/*", (route) => route.continue());
|
||||||
|
|
||||||
|
await require("node:timers/promises").setTimeout(3000); // wait for the window to load
|
||||||
|
await browser.windows()[0].pause(); // .pause() opens the Playwright-Inspector for manual recording
|
||||||
|
})();
|
||||||
78
e2e-tests/helpers/test_helper.ts
Normal file
78
e2e-tests/helpers/test_helper.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { test as base } from "@playwright/test";
|
||||||
|
import { findLatestBuild, parseElectronApp } from "electron-playwright-helpers";
|
||||||
|
import { ElectronApplication, _electron as electron } from "playwright";
|
||||||
|
|
||||||
|
const showDebugLogs = process.env.DEBUG_LOGS === "true";
|
||||||
|
|
||||||
|
// From https://github.com/microsoft/playwright/issues/8208#issuecomment-1435475930
|
||||||
|
//
|
||||||
|
// Note how we mark the fixture as { auto: true }.
|
||||||
|
// This way it is always instantiated, even if the test does not use it explicitly.
|
||||||
|
export const test = base.extend<{
|
||||||
|
attachScreenshotsToReport: void;
|
||||||
|
electronApp: ElectronApplication;
|
||||||
|
}>({
|
||||||
|
attachScreenshotsToReport: [
|
||||||
|
async ({ page }, use, testInfo) => {
|
||||||
|
await use();
|
||||||
|
|
||||||
|
// After the test we can check whether the test passed or failed.
|
||||||
|
if (testInfo.status !== testInfo.expectedStatus) {
|
||||||
|
const screenshot = await page.screenshot();
|
||||||
|
await testInfo.attach("screenshot", {
|
||||||
|
body: screenshot,
|
||||||
|
contentType: "image/png",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ auto: true },
|
||||||
|
],
|
||||||
|
electronApp: async ({}, use) => {
|
||||||
|
// find the latest build in the out directory
|
||||||
|
const latestBuild = findLatestBuild();
|
||||||
|
// parse the directory and find paths and other info
|
||||||
|
const appInfo = parseElectronApp(latestBuild);
|
||||||
|
process.env.E2E_TEST_BUILD = "true";
|
||||||
|
// This is just a hack to avoid the AI setup screen.
|
||||||
|
process.env.OPENAI_API_KEY = "sk-test";
|
||||||
|
const electronApp = await electron.launch({
|
||||||
|
args: [
|
||||||
|
appInfo.main,
|
||||||
|
"--enable-logging",
|
||||||
|
`--user-data-dir=/tmp/dyad-e2e-tests-${Date.now()}`,
|
||||||
|
],
|
||||||
|
executablePath: appInfo.executable,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("electronApp launched!");
|
||||||
|
if (showDebugLogs) {
|
||||||
|
// Listen to main process output immediately
|
||||||
|
electronApp.process().stdout?.on("data", (data) => {
|
||||||
|
console.log(`MAIN_PROCESS_STDOUT: ${data.toString()}`);
|
||||||
|
});
|
||||||
|
electronApp.process().stderr?.on("data", (data) => {
|
||||||
|
console.error(`MAIN_PROCESS_STDERR: ${data.toString()}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
electronApp.on("close", () => {
|
||||||
|
console.log(`Electron app closed listener:`);
|
||||||
|
});
|
||||||
|
|
||||||
|
electronApp.on("window", async (page) => {
|
||||||
|
const filename = page.url()?.split("/").pop();
|
||||||
|
console.log(`Window opened: ${filename}`);
|
||||||
|
|
||||||
|
// capture errors
|
||||||
|
page.on("pageerror", (error) => {
|
||||||
|
console.error(error);
|
||||||
|
});
|
||||||
|
// capture console messages
|
||||||
|
page.on("console", (msg) => {
|
||||||
|
console.log(msg.text());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await use(electronApp);
|
||||||
|
await electronApp.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,91 +1,52 @@
|
|||||||
/**
|
import { expect } from "@playwright/test";
|
||||||
* Example Playwright script for Electron
|
import { test } from "./helpers/test_helper";
|
||||||
* showing/testing various API features
|
|
||||||
* in both renderer and main processes
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { expect, test as base } from "@playwright/test";
|
test("renders the first page", async ({ electronApp }) => {
|
||||||
import { findLatestBuild, parseElectronApp } from "electron-playwright-helpers";
|
const page = await electronApp.firstWindow();
|
||||||
import { ElectronApplication, Page, _electron as electron } from "playwright";
|
|
||||||
|
|
||||||
let electronApp: ElectronApplication;
|
|
||||||
|
|
||||||
// From https://github.com/microsoft/playwright/issues/8208#issuecomment-1435475930
|
|
||||||
//
|
|
||||||
// Note how we mark the fixture as { auto: true }.
|
|
||||||
// This way it is always instantiated, even if the test does not use it explicitly.
|
|
||||||
export const test = base.extend<{ attachScreenshotsToReport: void }>({
|
|
||||||
attachScreenshotsToReport: [
|
|
||||||
async ({}, use, testInfo) => {
|
|
||||||
await use();
|
|
||||||
|
|
||||||
// After the test we can check whether the test passed or failed.
|
|
||||||
if (testInfo.status !== testInfo.expectedStatus) {
|
|
||||||
const screenshot = await page.screenshot();
|
|
||||||
await testInfo.attach("screenshot", {
|
|
||||||
body: screenshot,
|
|
||||||
contentType: "image/png",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ auto: true },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
|
||||||
// find the latest build in the out directory
|
|
||||||
const latestBuild = findLatestBuild();
|
|
||||||
// parse the directory and find paths and other info
|
|
||||||
const appInfo = parseElectronApp(latestBuild);
|
|
||||||
process.env.E2E_TEST_BUILD = "true";
|
|
||||||
// This is just a hack to avoid the AI setup screen.
|
|
||||||
process.env.OPENAI_API_KEY = "sk-test";
|
|
||||||
electronApp = await electron.launch({
|
|
||||||
args: [
|
|
||||||
appInfo.main,
|
|
||||||
"--enable-logging",
|
|
||||||
"--user-data-dir=/tmp/dyad-e2e-tests",
|
|
||||||
],
|
|
||||||
executablePath: appInfo.executable,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("electronApp launched!");
|
|
||||||
|
|
||||||
// Listen to main process output immediately
|
|
||||||
electronApp.process().stdout?.on("data", (data) => {
|
|
||||||
console.log(`MAIN_PROCESS_STDOUT: ${data.toString()}`);
|
|
||||||
});
|
|
||||||
electronApp.process().stderr?.on("data", (data) => {
|
|
||||||
console.error(`MAIN_PROCESS_STDERR: ${data.toString()}`);
|
|
||||||
});
|
|
||||||
electronApp.on("close", () => {
|
|
||||||
console.log(`Electron app closed listener:`);
|
|
||||||
});
|
|
||||||
|
|
||||||
electronApp.on("window", async (page) => {
|
|
||||||
const filename = page.url()?.split("/").pop();
|
|
||||||
console.log(`Window opened: ${filename}`);
|
|
||||||
|
|
||||||
// capture errors
|
|
||||||
page.on("pageerror", (error) => {
|
|
||||||
console.error(error);
|
|
||||||
});
|
|
||||||
// capture console messages
|
|
||||||
page.on("console", (msg) => {
|
|
||||||
console.log(msg.text());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.afterAll(async () => {
|
|
||||||
await electronApp.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
let page: Page;
|
|
||||||
|
|
||||||
test("renders the first page", async () => {
|
|
||||||
page = await electronApp.firstWindow();
|
|
||||||
await page.waitForSelector("h1");
|
await page.waitForSelector("h1");
|
||||||
const text = await page.$eval("h1", (el) => el.textContent);
|
const text = await page.$eval("h1", (el) => el.textContent);
|
||||||
expect(text).toBe("Build your dream app");
|
expect(text).toBe("Build your dream app");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("simple message to custom test model", async ({ electronApp }) => {
|
||||||
|
const page = await electronApp.firstWindow();
|
||||||
|
await page.getByRole("link", { name: "Settings" }).click();
|
||||||
|
await page.getByText("Add custom providerConnect to").click();
|
||||||
|
|
||||||
|
// Fill out provider dialog
|
||||||
|
await page.getByRole("textbox", { name: "Provider ID" }).fill("testing");
|
||||||
|
await page.getByRole("textbox", { name: "Display Name" }).click();
|
||||||
|
await page
|
||||||
|
.getByRole("textbox", { name: "Display Name" })
|
||||||
|
.fill("test-provider");
|
||||||
|
await page.getByText("API Base URLThe base URL for").click();
|
||||||
|
await page
|
||||||
|
.getByRole("textbox", { name: "API Base URL" })
|
||||||
|
.fill("http://localhost:3500/v1");
|
||||||
|
await page.getByRole("button", { name: "Add Provider" }).click();
|
||||||
|
|
||||||
|
// Create custom model
|
||||||
|
await page
|
||||||
|
.getByRole("heading", { name: "test-provider Needs Setup" })
|
||||||
|
.click();
|
||||||
|
await page.getByRole("button", { name: "Add Custom Model" }).click();
|
||||||
|
await page.getByRole("textbox", { name: "Model ID*" }).fill("test-model");
|
||||||
|
await page.getByRole("textbox", { name: "Model ID*" }).press("Tab");
|
||||||
|
await page.getByRole("textbox", { name: "Name*" }).fill("test-model");
|
||||||
|
await page.getByRole("button", { name: "Add Model" }).click();
|
||||||
|
|
||||||
|
// Go to apps page and select custom model
|
||||||
|
await page.getByRole("link", { name: "Apps" }).click();
|
||||||
|
await page.getByRole("button", { name: "Model: Auto" }).click();
|
||||||
|
await page.getByText("test-provider").click();
|
||||||
|
await page.getByText("test-model").click();
|
||||||
|
|
||||||
|
// Enter prompt and send
|
||||||
|
await page.getByRole("textbox", { name: "Ask Dyad to build..." }).click();
|
||||||
|
await page.getByRole("textbox", { name: "Ask Dyad to build..." }).fill("hi");
|
||||||
|
await page.getByRole("button", { name: "Start new chat" }).click();
|
||||||
|
|
||||||
|
// Make sure it's done
|
||||||
|
await expect(page.getByRole("button", { name: "Retry" })).not.toBeVisible();
|
||||||
|
await expect(page.getByTestId("messages-list")).toMatchAriaSnapshot();
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
- paragraph: hi
|
||||||
|
- 'button "Thinking `<dyad-write>`: I''ll think about the problem and write a bug report. <dyad-write> <dyad-write path=\"file1.txt\"> Fake dyad write </dyad-write>"':
|
||||||
|
- img
|
||||||
|
- img
|
||||||
|
- paragraph:
|
||||||
|
- code: "`<dyad-write>`"
|
||||||
|
- text: ": I'll think about the problem and write a bug report."
|
||||||
|
- paragraph: <dyad-write>
|
||||||
|
- paragraph: <dyad-write path="file1.txt"> Fake dyad write </dyad-write>
|
||||||
|
- img
|
||||||
|
- text: file1.txt
|
||||||
|
- img
|
||||||
|
- text: file1.txt
|
||||||
|
- paragraph: More EOM
|
||||||
|
- button "Retry":
|
||||||
|
- img
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "dyad",
|
"name": "dyad",
|
||||||
"version": "0.6.0",
|
"version": "0.7.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "dyad",
|
"name": "dyad",
|
||||||
"version": "0.6.0",
|
"version": "0.7.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "^1.2.8",
|
"@ai-sdk/anthropic": "^1.2.8",
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ import { PlaywrightTestConfig } from "@playwright/test";
|
|||||||
const config: PlaywrightTestConfig = {
|
const config: PlaywrightTestConfig = {
|
||||||
testDir: "./e2e-tests",
|
testDir: "./e2e-tests",
|
||||||
maxFailures: 1,
|
maxFailures: 1,
|
||||||
|
timeout: process.env.CI ? 30_000 : 10_000,
|
||||||
|
// Use a custom snapshot path template because Playwright's default
|
||||||
|
// is platform-specific which isn't necessary for Dyad e2e tests
|
||||||
|
// which should be platform agnostic (we don't do screenshots; only textual diffs).
|
||||||
|
snapshotPathTemplate:
|
||||||
|
"{testDir}/{testFileDir}/snapshots/{testFileName}_{arg}{ext}",
|
||||||
|
|
||||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||||
reporter: "html",
|
reporter: "html",
|
||||||
@@ -18,6 +24,11 @@ const config: PlaywrightTestConfig = {
|
|||||||
// screenshot: "on",
|
// screenshot: "on",
|
||||||
// video: "retain-on-failure",
|
// video: "retain-on-failure",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
webServer: {
|
||||||
|
command: `cd testing/fake-llm-server && npm start`,
|
||||||
|
url: "http://localhost:3500/health",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
@@ -34,7 +34,11 @@ export const MessagesList = forwardRef<HTMLDivElement, MessagesListProps>(
|
|||||||
const selectedChatId = useAtomValue(selectedChatIdAtom);
|
const selectedChatId = useAtomValue(selectedChatIdAtom);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-y-auto p-4" ref={ref}>
|
<div
|
||||||
|
className="flex-1 overflow-y-auto p-4"
|
||||||
|
ref={ref}
|
||||||
|
data-testid="messages-list"
|
||||||
|
>
|
||||||
{messages.length > 0 ? (
|
{messages.length > 0 ? (
|
||||||
messages.map((message, index) => (
|
messages.map((message, index) => (
|
||||||
<ChatMessage
|
<ChatMessage
|
||||||
|
|||||||
90
testing/fake-llm-server/dist/index.js
vendored
90
testing/fake-llm-server/dist/index.js
vendored
@@ -1,90 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
var __importDefault =
|
|
||||||
(this && this.__importDefault) ||
|
|
||||||
function (mod) {
|
|
||||||
return mod && mod.__esModule ? mod : { default: mod };
|
|
||||||
};
|
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
|
||||||
const express_1 = __importDefault(require("express"));
|
|
||||||
const http_1 = require("http");
|
|
||||||
const cors_1 = __importDefault(require("cors"));
|
|
||||||
// Create Express app
|
|
||||||
const app = (0, express_1.default)();
|
|
||||||
app.use((0, cors_1.default)());
|
|
||||||
app.use(express_1.default.json());
|
|
||||||
const PORT = 3500;
|
|
||||||
// Helper function to create OpenAI-like streaming response chunks
|
|
||||||
function createStreamChunk(content, role = "assistant", isLast = false) {
|
|
||||||
const chunk = {
|
|
||||||
id: `chatcmpl-${Date.now()}`,
|
|
||||||
object: "chat.completion.chunk",
|
|
||||||
created: Math.floor(Date.now() / 1000),
|
|
||||||
model: "fake-model",
|
|
||||||
choices: [
|
|
||||||
{
|
|
||||||
index: 0,
|
|
||||||
delta: isLast ? {} : { content, role },
|
|
||||||
finish_reason: isLast ? "stop" : null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
return `data: ${JSON.stringify(chunk)}\n\n${isLast ? "data: [DONE]\n\n" : ""}`;
|
|
||||||
}
|
|
||||||
// Handle POST requests to /v1/chat/completions
|
|
||||||
app.post("/v1/chat/completions", (req, res) => {
|
|
||||||
const { stream = false } = req.body;
|
|
||||||
// Non-streaming response
|
|
||||||
if (!stream) {
|
|
||||||
return res.json({
|
|
||||||
id: `chatcmpl-${Date.now()}`,
|
|
||||||
object: "chat.completion",
|
|
||||||
created: Math.floor(Date.now() / 1000),
|
|
||||||
model: "fake-model",
|
|
||||||
choices: [
|
|
||||||
{
|
|
||||||
index: 0,
|
|
||||||
message: {
|
|
||||||
role: "assistant",
|
|
||||||
content: "hello world",
|
|
||||||
},
|
|
||||||
finish_reason: "stop",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Streaming response
|
|
||||||
res.setHeader("Content-Type", "text/event-stream");
|
|
||||||
res.setHeader("Cache-Control", "no-cache");
|
|
||||||
res.setHeader("Connection", "keep-alive");
|
|
||||||
// Split the "hello world" message into characters to simulate streaming
|
|
||||||
const message = "hello world";
|
|
||||||
const messageChars = message.split("");
|
|
||||||
// Stream each character with a delay
|
|
||||||
let index = 0;
|
|
||||||
// Send role first
|
|
||||||
res.write(createStreamChunk("", "assistant"));
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
if (index < messageChars.length) {
|
|
||||||
res.write(createStreamChunk(messageChars[index]));
|
|
||||||
index++;
|
|
||||||
} else {
|
|
||||||
// Send the final chunk
|
|
||||||
res.write(createStreamChunk("", "assistant", true));
|
|
||||||
clearInterval(interval);
|
|
||||||
res.end();
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
});
|
|
||||||
// Start the server
|
|
||||||
const server = (0, http_1.createServer)(app);
|
|
||||||
server.listen(PORT, () => {
|
|
||||||
console.log(`Fake LLM server running on http://localhost:${PORT}`);
|
|
||||||
});
|
|
||||||
// Handle SIGINT (Ctrl+C)
|
|
||||||
process.on("SIGINT", () => {
|
|
||||||
console.log("Shutting down fake LLM server");
|
|
||||||
server.close(() => {
|
|
||||||
console.log("Server closed");
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -50,6 +50,10 @@ const CANNED_MESSAGE = `
|
|||||||
More
|
More
|
||||||
EOM`;
|
EOM`;
|
||||||
|
|
||||||
|
app.get("/health", (req, res) => {
|
||||||
|
res.send("OK");
|
||||||
|
});
|
||||||
|
|
||||||
// Handle POST requests to /v1/chat/completions
|
// Handle POST requests to /v1/chat/completions
|
||||||
app.post("/v1/chat/completions", (req, res) => {
|
app.post("/v1/chat/completions", (req, res) => {
|
||||||
const { stream = false, messages = [] } = req.body;
|
const { stream = false, messages = [] } = req.body;
|
||||||
|
|||||||
Reference in New Issue
Block a user