Make CI run cross-platform (#295)
This commit is contained in:
6
.gitattributes
vendored
Normal file
6
.gitattributes
vendored
Normal 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
|
||||
21
.github/workflows/ci.yml
vendored
21
.github/workflows/ci.yml
vendored
@@ -17,11 +17,19 @@ defaults:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
# Why Mac?
|
||||
# Why Mac and Windows?
|
||||
# I can't run electron playwright on ubuntu-latest and
|
||||
# Linux support for Dyad is experimental so not as important
|
||||
# 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:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@@ -34,8 +42,12 @@ jobs:
|
||||
- name: Install node modules
|
||||
run: npm install
|
||||
- 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
|
||||
- name: Type-checking
|
||||
# do not run this on windows (it's redunant)
|
||||
if: contains(matrix.os.name, 'macos')
|
||||
run: npm run ts
|
||||
- name: Install Chromium browser for Playwright
|
||||
run: npx playwright install chromium --with-deps
|
||||
@@ -44,8 +56,9 @@ jobs:
|
||||
- name: Prep test server
|
||||
run: cd testing/fake-llm-server && npm install && npm run build && cd -
|
||||
- name: E2E tests
|
||||
# Add debug logging to make it easier to see what's failing
|
||||
run: DEBUG=pw:browser npm run e2e
|
||||
# You can add debug logging to make it easier to see what's failing
|
||||
# by adding "DEBUG=pw:browser" in front.
|
||||
run: npm run e2e
|
||||
- uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
if: failure()
|
||||
with:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test } from "./helpers/test_helper";
|
||||
import { test, Timeout } from "./helpers/test_helper";
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
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();
|
||||
|
||||
// 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();
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test } from "./helpers/test_helper";
|
||||
import { test, Timeout } from "./helpers/test_helper";
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
test("auto-approve", async ({ po }) => {
|
||||
@@ -7,6 +7,8 @@ test("auto-approve", async ({ po }) => {
|
||||
await po.snapshotMessages();
|
||||
|
||||
// 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();
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
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.sendPrompt("[dump]");
|
||||
await po.snapshotServerDump();
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
[[beginning of AI_RULES.md]]
|
||||
There's already AI rules...
|
||||
[[end of AI_RULES.md]]
|
||||
|
||||
@@ -3,9 +3,16 @@ import { findLatestBuild, parseElectronApp } from "electron-playwright-helpers";
|
||||
import { ElectronApplication, _electron as electron } from "playwright";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
|
||||
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 {
|
||||
private userDataDir: string;
|
||||
|
||||
@@ -99,7 +106,7 @@ class PageObject {
|
||||
async snapshotPreview() {
|
||||
const iframe = this.getPreviewIframeElement();
|
||||
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 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 },
|
||||
],
|
||||
});
|
||||
|
||||
// Wrapper that skips tests on Windows platform
|
||||
export const testSkipIfWindows = os.platform() === "win32" ? test.skip : test;
|
||||
|
||||
function prettifyDump(
|
||||
dumpContent: string,
|
||||
{ onlyLastMessage = false }: { onlyLastMessage?: boolean } = {},
|
||||
) {
|
||||
const parsedDump = JSON.parse(dumpContent) as Array<{
|
||||
role: string;
|
||||
content: string;
|
||||
content: string | Array<{}>;
|
||||
}>;
|
||||
|
||||
const messages = onlyLastMessage ? parsedDump.slice(-1) : parsedDump;
|
||||
@@ -491,6 +510,8 @@ function prettifyDump(
|
||||
const content = Array.isArray(message.content)
|
||||
? JSON.stringify(message.content)
|
||||
: message.content
|
||||
// Normalize line endings to always use \n
|
||||
.replace(/\r\n/g, "\n")
|
||||
// We remove package.json because it's flaky.
|
||||
// Depending on whether pnpm install is run, it will be modified,
|
||||
// and the contents and timestamp (thus affecting order) will be affected.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test } from "./helpers/test_helper";
|
||||
import { test, Timeout } from "./helpers/test_helper";
|
||||
import { expect } from "@playwright/test";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
@@ -15,7 +15,7 @@ test("rebuild app", async ({ po }) => {
|
||||
await po.clickRebuild();
|
||||
await expect(po.locateLoadingAppPreview()).toBeVisible();
|
||||
await expect(po.locateLoadingAppPreview()).not.toBeVisible({
|
||||
timeout: 15_000,
|
||||
timeout: Timeout.LONG,
|
||||
});
|
||||
|
||||
// Check that the file is removed with the rebuild
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test } from "./helpers/test_helper";
|
||||
import { test, Timeout } from "./helpers/test_helper";
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
test("restart app", async ({ po }) => {
|
||||
@@ -8,7 +8,7 @@ test("restart app", async ({ po }) => {
|
||||
await po.clickRestart();
|
||||
await expect(po.locateLoadingAppPreview()).toBeVisible();
|
||||
await expect(po.locateLoadingAppPreview()).not.toBeVisible({
|
||||
timeout: 15_000,
|
||||
timeout: Timeout.LONG,
|
||||
});
|
||||
|
||||
await po.snapshotPreview();
|
||||
|
||||
@@ -325,7 +325,9 @@ This structured thinking ensures you:
|
||||
4. Maintain a consistent approach to problem-solving
|
||||
|
||||
|
||||
[[beginning of AI_RULES.md]]
|
||||
There's already AI rules...
|
||||
[[end of AI_RULES.md]]
|
||||
|
||||
|
||||
# REMEMBER
|
||||
@@ -377,7 +379,14 @@ You need to first add Supabase to your app and then we can add auth.
|
||||
|
||||
===
|
||||
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>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -394,31 +403,6 @@ message: This is my codebase. <dyad-file path="index.html">
|
||||
|
||||
</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">
|
||||
const App = () => <div>Minimal imported app</div>;
|
||||
|
||||
@@ -439,6 +423,26 @@ createRoot(document.getElementById("root")!).render(<App />);
|
||||
|
||||
</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>
|
||||
|
||||
|
||||
|
||||
===
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test } from "./helpers/test_helper";
|
||||
import { test, Timeout } from "./helpers/test_helper";
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
test("undo", async ({ po }) => {
|
||||
@@ -11,7 +11,7 @@ test("undo", async ({ po }) => {
|
||||
iframe.contentFrame().getByText("Testing:write-index(2)!"),
|
||||
).toBeVisible({
|
||||
// This can be pretty slow because it's waiting for the app to build.
|
||||
timeout: 15_000,
|
||||
timeout: Timeout.LONG,
|
||||
});
|
||||
|
||||
await po.clickUndo();
|
||||
@@ -20,7 +20,7 @@ test("undo", async ({ po }) => {
|
||||
iframe.contentFrame().getByText("Testing:write-index!"),
|
||||
).toBeVisible({
|
||||
// Also, could be slow.
|
||||
timeout: 15_000,
|
||||
timeout: Timeout.LONG,
|
||||
});
|
||||
|
||||
await po.clickUndo();
|
||||
@@ -29,6 +29,6 @@ test("undo", async ({ po }) => {
|
||||
iframe.contentFrame().getByText("Welcome to Your Blank App"),
|
||||
).toBeVisible({
|
||||
// Also, could be slow.
|
||||
timeout: 15_000,
|
||||
timeout: Timeout.LONG,
|
||||
});
|
||||
});
|
||||
|
||||
24
package-lock.json
generated
24
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "dyad",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dyad",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^1.2.8",
|
||||
@@ -96,6 +96,7 @@
|
||||
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
||||
"@typescript-eslint/parser": "^5.62.0",
|
||||
"@vitest/ui": "^3.1.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"drizzle-kit": "^0.30.6",
|
||||
"electron": "35.1.4",
|
||||
"eslint": "^8.57.1",
|
||||
@@ -7873,6 +7874,25 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"test:ui": "vitest --ui",
|
||||
"extract-codebase": "ts-node scripts/extract-codebase.ts",
|
||||
"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"
|
||||
},
|
||||
"keywords": [],
|
||||
@@ -64,6 +64,7 @@
|
||||
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
||||
"@typescript-eslint/parser": "^5.62.0",
|
||||
"@vitest/ui": "^3.1.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"drizzle-kit": "^0.30.6",
|
||||
"electron": "35.1.4",
|
||||
"eslint": "^8.57.1",
|
||||
|
||||
@@ -11,6 +11,7 @@ import { eq } from "drizzle-orm";
|
||||
import git from "isomorphic-git";
|
||||
import { getGitAuthor } from "../utils/git_author";
|
||||
import { ImportAppParams, ImportAppResult } from "../ipc_types";
|
||||
import { copyDirectoryRecursive } from "../utils/file_utils";
|
||||
|
||||
const logger = log.scope("import-handlers");
|
||||
const handle = createLoggedHandler(logger);
|
||||
@@ -88,11 +89,10 @@ export function registerImportHandlers() {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
// Copy the app folder to the Dyad apps directory, excluding node_modules
|
||||
await fs.cp(sourcePath, destPath, {
|
||||
recursive: true,
|
||||
filter: (source) => !source.includes("node_modules"),
|
||||
});
|
||||
// Copy the app folder to the Dyad apps directory.
|
||||
// Why not use fs.cp? Because we want stable ordering for
|
||||
// tests.
|
||||
await copyDirectoryRecursive(sourcePath, destPath);
|
||||
|
||||
const isGitRepo = await fs
|
||||
.access(path.join(destPath, ".git"))
|
||||
|
||||
@@ -39,13 +39,19 @@ export async function copyDirectoryRecursive(
|
||||
) {
|
||||
await fsPromises.mkdir(destination, { recursive: 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) {
|
||||
const srcPath = path.join(source, entry.name);
|
||||
const destPath = path.join(destination, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await copyDirectoryRecursive(srcPath, destPath);
|
||||
// Exclude node_modules directories
|
||||
if (entry.name !== "node_modules") {
|
||||
await copyDirectoryRecursive(srcPath, destPath);
|
||||
}
|
||||
} else {
|
||||
await fsPromises.copyFile(srcPath, destPath);
|
||||
}
|
||||
|
||||
@@ -226,7 +226,9 @@ async function collectFiles(dir: string, baseDir: string): Promise<string[]> {
|
||||
// Skip large configuration files or generated code (just include the path)
|
||||
function isOmittedFile(relativePath: string): boolean {
|
||||
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("tsconfig.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> {
|
||||
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)) {
|
||||
return `<dyad-file path="${relativePath}">
|
||||
@@ -310,7 +316,11 @@ export async function extractCodebase(appPath: string): Promise<{
|
||||
const formattedContent = await formatFile(file, appPath);
|
||||
|
||||
// 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)
|
||||
? OMITTED_FILE_CONTENT
|
||||
: await readFileWithCache(file);
|
||||
|
||||
Reference in New Issue
Block a user