Make CI run cross-platform (#295)

This commit is contained in:
Will Chen
2025-06-03 13:04:16 -07:00
committed by GitHub
parent 83eb721323
commit 7235eab227
16 changed files with 149 additions and 59 deletions

6
.gitattributes vendored Normal file
View File

@@ -0,0 +1,6 @@
# Set the default behavior, in case people don't have core.autocrlf set.
* text=auto
# Make everything in the e2e-tests/snapshots directory use LF line endings.
# Otherwise we'll get diffs in the snapshots when running on Windows.
e2e-tests/snapshots/** text eol=lf

View File

@@ -17,11 +17,19 @@ defaults:
jobs: jobs:
test: test:
# Why Mac? # Why Mac and Windows?
# I can't run electron playwright on ubuntu-latest and # I can't run electron playwright on ubuntu-latest and
# Linux support for Dyad is experimental so not as important # Linux support for Dyad is experimental so not as important
# as Mac + Windows # as Mac + Windows
runs-on: macos-latest strategy:
fail-fast: false
matrix:
os:
[
{ name: "windows", image: "windows-latest" },
{ name: "macos", image: "macos-latest" },
]
runs-on: ${{ matrix.os.image }}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -34,8 +42,12 @@ jobs:
- name: Install node modules - name: Install node modules
run: npm install run: npm install
- name: Presubmit check (e.g. lint, format) - name: Presubmit check (e.g. lint, format)
# do not run this on Windows (it fails and not necessary)
if: contains(matrix.os.name, 'macos')
run: npm run presubmit run: npm run presubmit
- name: Type-checking - name: Type-checking
# do not run this on windows (it's redunant)
if: contains(matrix.os.name, 'macos')
run: npm run ts run: npm run ts
- name: Install Chromium browser for Playwright - name: Install Chromium browser for Playwright
run: npx playwright install chromium --with-deps run: npx playwright install chromium --with-deps
@@ -44,8 +56,9 @@ jobs:
- name: Prep test server - name: Prep test server
run: cd testing/fake-llm-server && npm install && npm run build && cd - 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 # You can add debug logging to make it easier to see what's failing
run: DEBUG=pw:browser npm run e2e # by adding "DEBUG=pw:browser" in front.
run: npm run e2e
- uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
if: failure() if: failure()
with: with:

View File

@@ -1,4 +1,4 @@
import { test } from "./helpers/test_helper"; import { test, Timeout } from "./helpers/test_helper";
import { expect } from "@playwright/test"; import { expect } from "@playwright/test";
test("write to index, approve, check preview", async ({ po }) => { test("write to index, approve, check preview", async ({ po }) => {
@@ -11,6 +11,8 @@ test("write to index, approve, check preview", async ({ po }) => {
await po.snapshotMessages(); await po.snapshotMessages();
// This can be pretty slow because it's waiting for the app to build. // This can be pretty slow because it's waiting for the app to build.
await expect(po.getPreviewIframeElement()).toBeVisible({ timeout: 15_000 }); await expect(po.getPreviewIframeElement()).toBeVisible({
timeout: Timeout.LONG,
});
await po.snapshotPreview(); await po.snapshotPreview();
}); });

View File

@@ -1,4 +1,4 @@
import { test } from "./helpers/test_helper"; import { test, Timeout } from "./helpers/test_helper";
import { expect } from "@playwright/test"; import { expect } from "@playwright/test";
test("auto-approve", async ({ po }) => { test("auto-approve", async ({ po }) => {
@@ -7,6 +7,8 @@ test("auto-approve", async ({ po }) => {
await po.snapshotMessages(); await po.snapshotMessages();
// This can be pretty slow because it's waiting for the app to build. // This can be pretty slow because it's waiting for the app to build.
await expect(po.getPreviewIframeElement()).toBeVisible({ timeout: 15_000 }); await expect(po.getPreviewIframeElement()).toBeVisible({
timeout: Timeout.LONG,
});
await po.snapshotPreview(); await po.snapshotPreview();
}); });

View File

@@ -1,7 +1,10 @@
import { test } from "./helpers/test_helper"; import { testSkipIfWindows } from "./helpers/test_helper";
// This is useful to make sure the messages are being sent correctly. // This is useful to make sure the messages are being sent correctly.
test("dump messages", async ({ po }) => { //
// Why skip on Windows? The file ordering is not stable between runs
// but unclear why.
testSkipIfWindows("dump messages", async ({ po }) => {
await po.setUp(); await po.setUp();
await po.sendPrompt("[dump]"); await po.sendPrompt("[dump]");
await po.snapshotServerDump(); await po.snapshotServerDump();

View File

@@ -1 +1,3 @@
[[beginning of AI_RULES.md]]
There's already AI rules... There's already AI rules...
[[end of AI_RULES.md]]

View File

@@ -3,9 +3,16 @@ import { findLatestBuild, parseElectronApp } from "electron-playwright-helpers";
import { ElectronApplication, _electron as electron } from "playwright"; import { ElectronApplication, _electron as electron } from "playwright";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import os from "os";
const showDebugLogs = process.env.DEBUG_LOGS === "true"; const showDebugLogs = process.env.DEBUG_LOGS === "true";
export const Timeout = {
// Why make this a constant? In some platforms, perhaps locally,
// we may want to shorten this.
LONG: 30_000,
};
class PageObject { class PageObject {
private userDataDir: string; private userDataDir: string;
@@ -99,7 +106,7 @@ class PageObject {
async snapshotPreview() { async snapshotPreview() {
const iframe = this.getPreviewIframeElement(); const iframe = this.getPreviewIframeElement();
await expect(iframe.contentFrame().locator("body")).toMatchAriaSnapshot({ await expect(iframe.contentFrame().locator("body")).toMatchAriaSnapshot({
timeout: 30_000, timeout: Timeout.LONG,
}); });
} }
@@ -469,19 +476,31 @@ export const test = base.extend<{
}); });
await use(electronApp); await use(electronApp);
await electronApp.close(); // Why are we doing a force kill on Windows?
//
// Otherwise, Playwright will just hang on the test cleanup
// because the electron app does NOT ever fully quit due to
// Windows' strict resource locking (e.g. file locking).
if (os.platform() === "win32") {
electronApp.process().kill();
} else {
await electronApp.close();
}
}, },
{ auto: true }, { auto: true },
], ],
}); });
// Wrapper that skips tests on Windows platform
export const testSkipIfWindows = os.platform() === "win32" ? test.skip : test;
function prettifyDump( function prettifyDump(
dumpContent: string, dumpContent: string,
{ onlyLastMessage = false }: { onlyLastMessage?: boolean } = {}, { onlyLastMessage = false }: { onlyLastMessage?: boolean } = {},
) { ) {
const parsedDump = JSON.parse(dumpContent) as Array<{ const parsedDump = JSON.parse(dumpContent) as Array<{
role: string; role: string;
content: string; content: string | Array<{}>;
}>; }>;
const messages = onlyLastMessage ? parsedDump.slice(-1) : parsedDump; const messages = onlyLastMessage ? parsedDump.slice(-1) : parsedDump;
@@ -491,6 +510,8 @@ function prettifyDump(
const content = Array.isArray(message.content) const content = Array.isArray(message.content)
? JSON.stringify(message.content) ? JSON.stringify(message.content)
: message.content : message.content
// Normalize line endings to always use \n
.replace(/\r\n/g, "\n")
// We remove package.json because it's flaky. // We remove package.json because it's flaky.
// Depending on whether pnpm install is run, it will be modified, // Depending on whether pnpm install is run, it will be modified,
// and the contents and timestamp (thus affecting order) will be affected. // and the contents and timestamp (thus affecting order) will be affected.

View File

@@ -1,4 +1,4 @@
import { test } from "./helpers/test_helper"; import { test, Timeout } from "./helpers/test_helper";
import { expect } from "@playwright/test"; import { expect } from "@playwright/test";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
@@ -15,7 +15,7 @@ test("rebuild app", async ({ po }) => {
await po.clickRebuild(); await po.clickRebuild();
await expect(po.locateLoadingAppPreview()).toBeVisible(); await expect(po.locateLoadingAppPreview()).toBeVisible();
await expect(po.locateLoadingAppPreview()).not.toBeVisible({ await expect(po.locateLoadingAppPreview()).not.toBeVisible({
timeout: 15_000, timeout: Timeout.LONG,
}); });
// Check that the file is removed with the rebuild // Check that the file is removed with the rebuild

View File

@@ -1,4 +1,4 @@
import { test } from "./helpers/test_helper"; import { test, Timeout } from "./helpers/test_helper";
import { expect } from "@playwright/test"; import { expect } from "@playwright/test";
test("restart app", async ({ po }) => { test("restart app", async ({ po }) => {
@@ -8,7 +8,7 @@ test("restart app", async ({ po }) => {
await po.clickRestart(); await po.clickRestart();
await expect(po.locateLoadingAppPreview()).toBeVisible(); await expect(po.locateLoadingAppPreview()).toBeVisible();
await expect(po.locateLoadingAppPreview()).not.toBeVisible({ await expect(po.locateLoadingAppPreview()).not.toBeVisible({
timeout: 15_000, timeout: Timeout.LONG,
}); });
await po.snapshotPreview(); await po.snapshotPreview();

View File

@@ -325,7 +325,9 @@ This structured thinking ensures you:
4. Maintain a consistent approach to problem-solving 4. Maintain a consistent approach to problem-solving
[[beginning of AI_RULES.md]]
There's already AI rules... There's already AI rules...
[[end of AI_RULES.md]]
# REMEMBER # REMEMBER
@@ -377,7 +379,14 @@ You need to first add Supabase to your app and then we can add auth.
=== ===
role: user role: user
message: This is my codebase. <dyad-file path="index.html"> message: This is my codebase. <dyad-file path="AI_RULES.md">
[[beginning of AI_RULES.md]]
There's already AI rules...
[[end of AI_RULES.md]]
</dyad-file>
<dyad-file path="index.html">
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -394,31 +403,6 @@ message: This is my codebase. <dyad-file path="index.html">
</dyad-file> </dyad-file>
<dyad-file path="AI_RULES.md">
There's already AI rules...
</dyad-file>
<dyad-file path="vite.config.ts">
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import path from "path";
export default defineConfig(() => ({
server: {
host: "::",
port: 8080,
},
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
}));
</dyad-file>
<dyad-file path="src/App.tsx"> <dyad-file path="src/App.tsx">
const App = () => <div>Minimal imported app</div>; const App = () => <div>Minimal imported app</div>;
@@ -439,6 +423,26 @@ createRoot(document.getElementById("root")!).render(<App />);
</dyad-file> </dyad-file>
<dyad-file path="vite.config.ts">
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import path from "path";
export default defineConfig(() => ({
server: {
host: "::",
port: 8080,
},
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
}));
</dyad-file>
=== ===

View File

@@ -1,4 +1,4 @@
import { test } from "./helpers/test_helper"; import { test, Timeout } from "./helpers/test_helper";
import { expect } from "@playwright/test"; import { expect } from "@playwright/test";
test("undo", async ({ po }) => { test("undo", async ({ po }) => {
@@ -11,7 +11,7 @@ test("undo", async ({ po }) => {
iframe.contentFrame().getByText("Testing:write-index(2)!"), iframe.contentFrame().getByText("Testing:write-index(2)!"),
).toBeVisible({ ).toBeVisible({
// This can be pretty slow because it's waiting for the app to build. // This can be pretty slow because it's waiting for the app to build.
timeout: 15_000, timeout: Timeout.LONG,
}); });
await po.clickUndo(); await po.clickUndo();
@@ -20,7 +20,7 @@ test("undo", async ({ po }) => {
iframe.contentFrame().getByText("Testing:write-index!"), iframe.contentFrame().getByText("Testing:write-index!"),
).toBeVisible({ ).toBeVisible({
// Also, could be slow. // Also, could be slow.
timeout: 15_000, timeout: Timeout.LONG,
}); });
await po.clickUndo(); await po.clickUndo();
@@ -29,6 +29,6 @@ test("undo", async ({ po }) => {
iframe.contentFrame().getByText("Welcome to Your Blank App"), iframe.contentFrame().getByText("Welcome to Your Blank App"),
).toBeVisible({ ).toBeVisible({
// Also, could be slow. // Also, could be slow.
timeout: 15_000, timeout: Timeout.LONG,
}); });
}); });

24
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "dyad", "name": "dyad",
"version": "0.7.0", "version": "0.8.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "dyad", "name": "dyad",
"version": "0.7.0", "version": "0.8.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "^1.2.8", "@ai-sdk/anthropic": "^1.2.8",
@@ -96,6 +96,7 @@
"@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0", "@typescript-eslint/parser": "^5.62.0",
"@vitest/ui": "^3.1.1", "@vitest/ui": "^3.1.1",
"cross-env": "^7.0.3",
"drizzle-kit": "^0.30.6", "drizzle-kit": "^0.30.6",
"electron": "35.1.4", "electron": "35.1.4",
"eslint": "^8.57.1", "eslint": "^8.57.1",
@@ -7873,6 +7874,25 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cross-env": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
"integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.1"
},
"bin": {
"cross-env": "src/bin/cross-env.js",
"cross-env-shell": "src/bin/cross-env-shell.js"
},
"engines": {
"node": ">=10.14",
"npm": ">=6",
"yarn": ">=1"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",

View File

@@ -34,7 +34,7 @@
"test:ui": "vitest --ui", "test:ui": "vitest --ui",
"extract-codebase": "ts-node scripts/extract-codebase.ts", "extract-codebase": "ts-node scripts/extract-codebase.ts",
"prepare": "husky install", "prepare": "husky install",
"pre:e2e": "E2E_TEST_BUILD=true npm run package", "pre:e2e": "cross-env E2E_TEST_BUILD=true npm run package",
"e2e": "playwright test" "e2e": "playwright test"
}, },
"keywords": [], "keywords": [],
@@ -64,6 +64,7 @@
"@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0", "@typescript-eslint/parser": "^5.62.0",
"@vitest/ui": "^3.1.1", "@vitest/ui": "^3.1.1",
"cross-env": "^7.0.3",
"drizzle-kit": "^0.30.6", "drizzle-kit": "^0.30.6",
"electron": "35.1.4", "electron": "35.1.4",
"eslint": "^8.57.1", "eslint": "^8.57.1",

View File

@@ -11,6 +11,7 @@ import { eq } from "drizzle-orm";
import git from "isomorphic-git"; import git from "isomorphic-git";
import { getGitAuthor } from "../utils/git_author"; import { getGitAuthor } from "../utils/git_author";
import { ImportAppParams, ImportAppResult } from "../ipc_types"; import { ImportAppParams, ImportAppResult } from "../ipc_types";
import { copyDirectoryRecursive } from "../utils/file_utils";
const logger = log.scope("import-handlers"); const logger = log.scope("import-handlers");
const handle = createLoggedHandler(logger); const handle = createLoggedHandler(logger);
@@ -88,11 +89,10 @@ export function registerImportHandlers() {
throw error; throw error;
} }
} }
// Copy the app folder to the Dyad apps directory, excluding node_modules // Copy the app folder to the Dyad apps directory.
await fs.cp(sourcePath, destPath, { // Why not use fs.cp? Because we want stable ordering for
recursive: true, // tests.
filter: (source) => !source.includes("node_modules"), await copyDirectoryRecursive(sourcePath, destPath);
});
const isGitRepo = await fs const isGitRepo = await fs
.access(path.join(destPath, ".git")) .access(path.join(destPath, ".git"))

View File

@@ -39,13 +39,19 @@ export async function copyDirectoryRecursive(
) { ) {
await fsPromises.mkdir(destination, { recursive: true }); await fsPromises.mkdir(destination, { recursive: true });
const entries = await fsPromises.readdir(source, { withFileTypes: true }); const entries = await fsPromises.readdir(source, { withFileTypes: true });
// Why do we sort? This ensures stable ordering of files across platforms
// which is helpful for tests (and has no practical downsides).
entries.sort();
for (const entry of entries) { for (const entry of entries) {
const srcPath = path.join(source, entry.name); const srcPath = path.join(source, entry.name);
const destPath = path.join(destination, entry.name); const destPath = path.join(destination, entry.name);
if (entry.isDirectory()) { if (entry.isDirectory()) {
await copyDirectoryRecursive(srcPath, destPath); // Exclude node_modules directories
if (entry.name !== "node_modules") {
await copyDirectoryRecursive(srcPath, destPath);
}
} else { } else {
await fsPromises.copyFile(srcPath, destPath); await fsPromises.copyFile(srcPath, destPath);
} }

View File

@@ -226,7 +226,9 @@ async function collectFiles(dir: string, baseDir: string): Promise<string[]> {
// Skip large configuration files or generated code (just include the path) // Skip large configuration files or generated code (just include the path)
function isOmittedFile(relativePath: string): boolean { function isOmittedFile(relativePath: string): boolean {
return ( return (
relativePath.includes(path.join("src", "components", "ui")) || // Why are we not using path.join here?
// Because we have already normalized the path to use /.
relativePath.includes("src/components/ui") ||
relativePath.includes("eslint.config") || relativePath.includes("eslint.config") ||
relativePath.includes("tsconfig.json") || relativePath.includes("tsconfig.json") ||
relativePath.includes("package-lock.json") || relativePath.includes("package-lock.json") ||
@@ -243,7 +245,11 @@ const OMITTED_FILE_CONTENT = "// Contents omitted for brevity";
*/ */
async function formatFile(filePath: string, baseDir: string): Promise<string> { async function formatFile(filePath: string, baseDir: string): Promise<string> {
try { try {
const relativePath = path.relative(baseDir, filePath); const relativePath = path
.relative(baseDir, filePath)
// Why? Normalize Windows-style paths which causes lots of weird issues (e.g. Git commit)
.split(path.sep)
.join("/");
if (isOmittedFile(relativePath)) { if (isOmittedFile(relativePath)) {
return `<dyad-file path="${relativePath}"> return `<dyad-file path="${relativePath}">
@@ -310,7 +316,11 @@ export async function extractCodebase(appPath: string): Promise<{
const formattedContent = await formatFile(file, appPath); const formattedContent = await formatFile(file, appPath);
// Get raw content for the files array // Get raw content for the files array
const relativePath = path.relative(appPath, file); const relativePath = path
.relative(appPath, file)
// Why? Normalize Windows-style paths which causes lots of weird issues (e.g. Git commit)
.split(path.sep)
.join("/");
const fileContent = isOmittedFile(relativePath) const fileContent = isOmittedFile(relativePath)
? OMITTED_FILE_CONTENT ? OMITTED_FILE_CONTENT
: await readFileWithCache(file); : await readFileWithCache(file);