Fix stale app UI (supabase) & overall E2E test infra improvements (#337)

Fixes #269
This commit is contained in:
Will Chen
2025-06-04 23:07:59 -07:00
committed by GitHub
parent 7f410ce830
commit 16bf0828f5
21 changed files with 203 additions and 96 deletions

View File

@@ -0,0 +1,2 @@
Adding supabase...
<dyad-add-integration provider="supabase"></dyad-add-integration>

View File

@@ -258,6 +258,10 @@ export class PageObject {
.click(); .click();
} }
async clickBackButton() {
await this.page.getByRole("button", { name: "Back" }).click();
}
async sendPrompt(prompt: string) { async sendPrompt(prompt: string) {
await this.getChatInput().click(); await this.getChatInput().click();
await this.getChatInput().fill(prompt); await this.getChatInput().fill(prompt);
@@ -382,6 +386,10 @@ export class PageObject {
await this.page.getByTestId("app-details-more-options-button").click(); await this.page.getByTestId("app-details-more-options-button").click();
} }
async clickConnectSupabaseButton() {
await this.page.getByTestId("connect-supabase-button").click();
}
//////////////////////////////// ////////////////////////////////
// Settings related // Settings related
//////////////////////////////// ////////////////////////////////

View File

@@ -0,0 +1,6 @@
- paragraph: tc=add-supabase
- paragraph: Adding supabase...
- text: Integrate with supabase?
- button "Set up supabase"
- button "Retry":
- img

View File

@@ -0,0 +1,8 @@
- paragraph: tc=add-supabase
- paragraph: Adding supabase...
- img
- text: Supabase integration complete
- paragraph: "This app is connected to Supabase project: Fake Supabase Project"
- paragraph: Click the chat suggestion "Keep going" to continue.
- button "Retry":
- img

View File

@@ -0,0 +1,6 @@
- paragraph: tc=add-supabase
- paragraph: Adding supabase...
- text: Integrate with supabase?
- button "Set up supabase"
- button "Retry":
- img

View File

@@ -0,0 +1,25 @@
import { testSkipIfWindows } from "./helpers/test_helper";
// https://github.com/dyad-sh/dyad/issues/269
testSkipIfWindows("supabase - stale ui", async ({ po }) => {
await po.setUp();
await po.sendPrompt("tc=add-supabase");
await po.snapshotMessages();
await po.page.getByText("Set up supabase").click();
// On app details page:
await po.clickConnectSupabaseButton();
// TODO: for some reason on Windows this navigates to the main (apps) page,
// rather than the original chat page, so this test is skipped on Windows.
// However, the underlying issue is platform-agnostic, so it seems OK to test
// only on Mac.
await po.clickBackButton();
// On chat page:
await po.snapshotMessages();
// Create a second app; do NOT integrate it with Supabase, and make sure UI is correct.
await po.goToAppsTab();
await po.sendPrompt("tc=add-supabase");
await po.snapshotMessages();
});

View File

@@ -202,14 +202,22 @@ export function SupabaseConnector({ appId }: { appId: number }) {
<div className="flex flex-col md:flex-row items-center justify-between"> <div className="flex flex-col md:flex-row items-center justify-between">
<h2 className="text-lg font-medium">Integrations</h2> <h2 className="text-lg font-medium">Integrations</h2>
<img <img
onClick={() => { onClick={async () => {
IpcClient.getInstance().openExternalUrl( if (settings?.isTestMode) {
await IpcClient.getInstance().fakeHandleSupabaseConnect({
appId,
fakeProjectId: "fake-project-id",
});
} else {
await IpcClient.getInstance().openExternalUrl(
"https://supabase-oauth.dyad.sh/api/connect-supabase/login", "https://supabase-oauth.dyad.sh/api/connect-supabase/login",
); );
}
}} }}
src={isDarkMode ? connectSupabaseDark : connectSupabaseLight} src={isDarkMode ? connectSupabaseDark : connectSupabaseLight}
alt="Connect to Supabase" alt="Connect to Supabase"
className="w-full h-10 min-h-8 min-w-20 cursor-pointer" className="w-full h-10 min-h-8 min-w-20 cursor-pointer"
data-testid="connect-supabase-button"
// className="h-10" // className="h-10"
/> />
</div> </div>

View File

@@ -2,10 +2,8 @@ import React from "react";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { selectedAppIdAtom } from "@/atoms/appAtoms"; import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { useAtomValue, atom, useAtom } from "jotai"; import { useAtomValue } from "jotai";
import { showError } from "@/lib/toast"; import { showError } from "@/lib/toast";
import { useStreamChat } from "@/hooks/useStreamChat";
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
import { useLoadApp } from "@/hooks/useLoadApp"; import { useLoadApp } from "@/hooks/useLoadApp";
interface DyadAddIntegrationProps { interface DyadAddIntegrationProps {
@@ -17,20 +15,15 @@ interface DyadAddIntegrationProps {
children: React.ReactNode; children: React.ReactNode;
} }
const isSetupAtom = atom(false);
export const DyadAddIntegration: React.FC<DyadAddIntegrationProps> = ({ export const DyadAddIntegration: React.FC<DyadAddIntegrationProps> = ({
node, node,
children, children,
}) => { }) => {
const { streamMessage } = useStreamChat();
const [isSetup, setIsSetup] = useAtom(isSetupAtom);
const navigate = useNavigate(); const navigate = useNavigate();
const { provider } = node.properties; const { provider } = node.properties;
const appId = useAtomValue(selectedAppIdAtom); const appId = useAtomValue(selectedAppIdAtom);
const { app } = useLoadApp(appId); const { app } = useLoadApp(appId);
const selectedChatId = useAtomValue(selectedChatIdAtom);
const handleSetupClick = () => { const handleSetupClick = () => {
if (!appId) { if (!appId) {
@@ -38,7 +31,6 @@ export const DyadAddIntegration: React.FC<DyadAddIntegrationProps> = ({
return; return;
} }
navigate({ to: "/app-details", search: { appId } }); navigate({ to: "/app-details", search: { appId } });
setIsSetup(true);
}; };
if (app?.supabaseProjectName) { if (app?.supabaseProjectName) {
@@ -71,36 +63,18 @@ export const DyadAddIntegration: React.FC<DyadAddIntegrationProps> = ({
</span> </span>
</div> </div>
<div className="text-sm text-green-900"> <div className="text-sm text-green-900">
<p>
This app is connected to Supabase project:{" "} This app is connected to Supabase project:{" "}
<span className="font-mono font-medium bg-green-100 px-1 py-0.5 rounded"> <span className="font-mono font-medium bg-green-100 px-1 py-0.5 rounded">
{app.supabaseProjectName} {app.supabaseProjectName}
</span> </span>
</p>
<p>Click the chat suggestion "Keep going" to continue.</p>
</div> </div>
</div> </div>
); );
} }
if (isSetup) {
return (
<Button
onClick={() => {
setIsSetup(false);
if (!selectedChatId) {
showError("No chat ID found");
return;
}
streamMessage({
prompt: "OK, I've setup Supabase. Continue",
chatId: selectedChatId,
});
}}
className="my-1"
>
Continue | I've setup {provider}
</Button>
);
}
return ( return (
<div className="flex flex-col gap-2 my-2 p-3 border rounded-md bg-secondary/10"> <div className="flex flex-col gap-2 my-2 p-3 border rounded-md bg-secondary/10">
<div className="text-sm"> <div className="text-sm">

View File

@@ -36,7 +36,7 @@ export const CodeView = ({ loading, app }: CodeViewProps) => {
{/* Toolbar */} {/* Toolbar */}
<div className="flex items-center p-2 border-b space-x-2"> <div className="flex items-center p-2 border-b space-x-2">
<button <button
onClick={refreshApp} onClick={() => refreshApp()}
className="p-1 rounded hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed" className="p-1 rounded hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={loading || !app.id} disabled={loading || !app.id}
title="Refresh Files" title="Refresh Files"

View File

@@ -1,61 +1,46 @@
import { useState, useEffect } from "react"; import { useEffect } from "react";
import { useQuery, QueryClient } from "@tanstack/react-query";
import { IpcClient } from "@/ipc/ipc_client"; import { IpcClient } from "@/ipc/ipc_client";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { currentAppAtom } from "@/atoms/appAtoms"; import { currentAppAtom } from "@/atoms/appAtoms";
import { App } from "@/ipc/ipc_types";
export function useLoadApp(appId: number | null) { export function useLoadApp(appId: number | null) {
const [app, setApp] = useAtom(currentAppAtom); const [, setApp] = useAtom(currentAppAtom);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null); const {
data: appData,
isLoading: loading,
error,
refetch: refreshApp,
} = useQuery<App | null, Error>({
queryKey: ["app", appId],
queryFn: async () => {
if (appId === null) {
return null;
}
const ipcClient = IpcClient.getInstance();
return ipcClient.getApp(appId);
},
enabled: appId !== null,
meta: { showErrorToast: true },
});
useEffect(() => { useEffect(() => {
const loadApp = async () => {
if (appId === null) { if (appId === null) {
setApp(null); setApp(null);
setLoading(false); } else if (appData !== undefined) {
return;
}
setLoading(true);
try {
const ipcClient = IpcClient.getInstance();
const appData = await ipcClient.getApp(appId);
setApp(appData); setApp(appData);
setError(null);
} catch (error) {
console.error(`Error loading app ${appId}:`, error);
setError(error instanceof Error ? error : new Error(String(error)));
setApp(null);
} finally {
setLoading(false);
} }
}, [appId, appData, setApp]);
return { app: appData, loading, error, refreshApp };
}
// Function to invalidate the app query
export const invalidateAppQuery = (
queryClient: QueryClient,
{ appId }: { appId: number | null },
) => {
return queryClient.invalidateQueries({ queryKey: ["app", appId] });
}; };
loadApp();
}, [appId]);
const refreshApp = async () => {
if (appId === null) {
return;
}
setLoading(true);
try {
console.log("Refreshing app", appId);
const ipcClient = IpcClient.getInstance();
const appData = await ipcClient.getApp(appId);
console.log("App data", appData);
setApp(appData);
setError(null);
} catch (error) {
console.error(`Error refreshing app ${appId}:`, error);
setError(error instanceof Error ? error : new Error(String(error)));
} finally {
setLoading(false);
}
};
return { app, loading, error, refreshApp };
}

View File

@@ -3,6 +3,7 @@ import log from "electron-log";
import { createLoggedHandler } from "./safe_handle"; import { createLoggedHandler } from "./safe_handle";
import { readSettings } from "../../main/settings"; // Assuming settings are read this way import { readSettings } from "../../main/settings"; // Assuming settings are read this way
import { UserBudgetInfo, UserBudgetInfoSchema } from "../ipc_types"; import { UserBudgetInfo, UserBudgetInfoSchema } from "../ipc_types";
import { IS_TEST_BUILD } from "../utils/test_utils";
const logger = log.scope("pro_handlers"); const logger = log.scope("pro_handlers");
const handle = createLoggedHandler(logger); const handle = createLoggedHandler(logger);
@@ -13,6 +14,10 @@ export function registerProHandlers() {
// This method should try to avoid throwing errors because this is auxiliary // This method should try to avoid throwing errors because this is auxiliary
// information and isn't critical to using the app // information and isn't critical to using the app
handle("get-user-budget", async (): Promise<UserBudgetInfo | null> => { handle("get-user-budget", async (): Promise<UserBudgetInfo | null> => {
if (IS_TEST_BUILD) {
// Avoid spamming the API in E2E tests.
return null;
}
logger.info("Attempting to fetch user budget information."); logger.info("Attempting to fetch user budget information.");
const settings = readSettings(); const settings = readSettings();

View File

@@ -1,5 +1,6 @@
import { ipcMain, IpcMainInvokeEvent } from "electron"; import { ipcMain, IpcMainInvokeEvent } from "electron";
import log from "electron-log"; import log from "electron-log";
import { IS_TEST_BUILD } from "../utils/test_utils";
export function createLoggedHandler(logger: log.LogFunctions) { export function createLoggedHandler(logger: log.LogFunctions) {
return ( return (
@@ -27,3 +28,11 @@ export function createLoggedHandler(logger: log.LogFunctions) {
); );
}; };
} }
export function createTestOnlyLoggedHandler(logger: log.LogFunctions) {
if (!IS_TEST_BUILD) {
// Returns a no-op function for non-e2e test builds.
return () => {};
}
return createLoggedHandler(logger);
}

View File

@@ -3,10 +3,15 @@ import { db } from "../../db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { apps } from "../../db/schema"; import { apps } from "../../db/schema";
import { getSupabaseClient } from "../../supabase_admin/supabase_management_client"; import { getSupabaseClient } from "../../supabase_admin/supabase_management_client";
import { createLoggedHandler } from "./safe_handle"; import {
createLoggedHandler,
createTestOnlyLoggedHandler,
} from "./safe_handle";
import { handleSupabaseOAuthReturn } from "../../supabase_admin/supabase_return_handler";
const logger = log.scope("supabase_handlers"); const logger = log.scope("supabase_handlers");
const handle = createLoggedHandler(logger); const handle = createLoggedHandler(logger);
const testOnlyHandle = createTestOnlyLoggedHandler(logger);
export function registerSupabaseHandlers() { export function registerSupabaseHandlers() {
handle("supabase:list-projects", async () => { handle("supabase:list-projects", async () => {
@@ -36,4 +41,42 @@ export function registerSupabaseHandlers() {
logger.info(`Removed Supabase project association for app ${app}`); logger.info(`Removed Supabase project association for app ${app}`);
}); });
testOnlyHandle(
"supabase:fake-connect-and-set-project",
async (
event,
{ appId, fakeProjectId }: { appId: number; fakeProjectId: string },
) => {
// Call handleSupabaseOAuthReturn with fake data
handleSupabaseOAuthReturn({
token: "fake-access-token",
refreshToken: "fake-refresh-token",
expiresIn: 3600, // 1 hour
});
logger.info(
`Called handleSupabaseOAuthReturn with fake data for app ${appId} during testing.`,
);
// Set the supabase project for the currently selected app
await db
.update(apps)
.set({
supabaseProjectId: fakeProjectId,
})
.where(eq(apps.id, appId));
logger.info(
`Set fake Supabase project ${fakeProjectId} for app ${appId} during testing.`,
);
// Simulate the deep link event
event.sender.send("deep-link-received", {
type: "supabase-oauth-return",
url: "https://supabase-oauth.dyad.sh/api/connect-supabase/login",
});
logger.info(
`Sent fake deep-link-received event for app ${appId} during testing.`,
);
},
);
} }

View File

@@ -647,6 +647,17 @@ export class IpcClient {
app, app,
}); });
} }
public async fakeHandleSupabaseConnect(params: {
appId: number;
fakeProjectId: string;
}): Promise<void> {
await this.ipcRenderer.invoke(
"supabase:fake-connect-and-set-project",
params,
);
}
// --- End Supabase Management --- // --- End Supabase Management ---
public async getSystemDebugInfo(): Promise<SystemDebugInfo> { public async getSystemDebugInfo(): Promise<SystemDebugInfo> {

View File

@@ -0,0 +1 @@
export const IS_TEST_BUILD = process.env.E2E_TEST_BUILD === "true";

View File

@@ -9,6 +9,7 @@ import log from "electron-log";
import { readSettings, writeSettings } from "./main/settings"; import { readSettings, writeSettings } from "./main/settings";
import { handleSupabaseOAuthReturn } from "./supabase_admin/supabase_return_handler"; import { handleSupabaseOAuthReturn } from "./supabase_admin/supabase_return_handler";
import { handleDyadProReturn } from "./main/pro"; import { handleDyadProReturn } from "./main/pro";
import { IS_TEST_BUILD } from "./ipc/utils/test_utils";
log.errorHandler.startCatching(); log.errorHandler.startCatching();
log.eventLogger.startLogging(); log.eventLogger.startLogging();
@@ -58,7 +59,7 @@ export async function onFirstRunMaybe() {
hasRunBefore: true, hasRunBefore: true,
}); });
} }
if (process.env.E2E_TEST_BUILD) { if (IS_TEST_BUILD) {
writeSettings({ writeSettings({
isTestMode: true, isTestMode: true,
}); });
@@ -73,7 +74,7 @@ async function promptMoveToApplicationsFolder(): Promise<void> {
// Why not in e2e tests? // Why not in e2e tests?
// There's no way to stub this dialog in time, so we just skip it // There's no way to stub this dialog in time, so we just skip it
// in e2e testing mode. // in e2e testing mode.
if (process.env.E2E_TEST_BUILD) return; if (IS_TEST_BUILD) return;
if (process.platform !== "darwin") return; if (process.platform !== "darwin") return;
if (app.isInApplicationsFolder()) return; if (app.isInApplicationsFolder()) return;
logger.log("Prompting user to move to applications folder"); logger.log("Prompting user to move to applications folder");

View File

@@ -26,6 +26,8 @@ import { Button } from "@/components/ui/button";
import { ExternalLink } from "lucide-react"; import { ExternalLink } from "lucide-react";
import { ImportAppButton } from "@/components/ImportAppButton"; import { ImportAppButton } from "@/components/ImportAppButton";
import { showError } from "@/lib/toast"; import { showError } from "@/lib/toast";
import { invalidateAppQuery } from "@/hooks/useLoadApp";
import { useQueryClient } from "@tanstack/react-query";
// Adding an export for attachments // Adding an export for attachments
export interface HomeSubmitOptions { export interface HomeSubmitOptions {
@@ -47,7 +49,7 @@ export default function HomePage() {
const [releaseNotesOpen, setReleaseNotesOpen] = useState(false); const [releaseNotesOpen, setReleaseNotesOpen] = useState(false);
const [releaseUrl, setReleaseUrl] = useState(""); const [releaseUrl, setReleaseUrl] = useState("");
const { theme } = useTheme(); const { theme } = useTheme();
const queryClient = useQueryClient();
useEffect(() => { useEffect(() => {
const updateLastVersionLaunched = async () => { const updateLastVersionLaunched = async () => {
if ( if (
@@ -132,6 +134,7 @@ export default function HomePage() {
setSelectedAppId(result.app.id); setSelectedAppId(result.app.id);
setIsPreviewOpen(false); setIsPreviewOpen(false);
await refreshApps(); // Ensure refreshApps is awaited if it's async await refreshApps(); // Ensure refreshApps is awaited if it's async
await invalidateAppQuery(queryClient, { appId: result.app.id });
posthog.capture("home:chat-submit"); posthog.capture("home:chat-submit");
navigate({ to: "/chat", search: { id: result.chatId } }); navigate({ to: "/chat", search: { id: result.chatId } });
} catch (error) { } catch (error) {

View File

@@ -1,8 +1,9 @@
import path from "node:path"; import path from "node:path";
import os from "node:os"; import os from "node:os";
import { IS_TEST_BUILD } from "../ipc/utils/test_utils";
export function getDyadAppPath(appPath: string): string { export function getDyadAppPath(appPath: string): string {
if (process.env.E2E_TEST_BUILD) { if (IS_TEST_BUILD) {
const electron = getElectron(); const electron = getElectron();
return path.join(electron!.app.getPath("userData"), "dyad-apps", appPath); return path.join(electron!.app.getPath("userData"), "dyad-apps", appPath);
} }

View File

@@ -76,7 +76,12 @@ const validInvokeChannels = [
"rename-branch", "rename-branch",
"clear-session-data", "clear-session-data",
"get-user-budget", "get-user-budget",
] as const; // Test-only channels
// These should ALWAYS be guarded with IS_TEST_BUILD in the main process.
// We can't detect with IS_TEST_BUILD in the preload script because
// it's a separate process from the main process.
"supabase:fake-connect-and-set-project",
];
// Add valid receive channels // Add valid receive channels
const validReceiveChannels = [ const validReceiveChannels = [

View File

@@ -5,6 +5,7 @@ import {
SupabaseManagementAPIError, SupabaseManagementAPIError,
} from "@dyad-sh/supabase-management-js"; } from "@dyad-sh/supabase-management-js";
import log from "electron-log"; import log from "electron-log";
import { IS_TEST_BUILD } from "../ipc/utils/test_utils";
const logger = log.scope("supabase_management_client"); const logger = log.scope("supabase_management_client");
@@ -122,6 +123,10 @@ export async function getSupabaseClient(): Promise<SupabaseManagementAPI> {
export async function getSupabaseProjectName( export async function getSupabaseProjectName(
projectId: string, projectId: string,
): Promise<string> { ): Promise<string> {
if (IS_TEST_BUILD) {
return "Fake Supabase Project";
}
const supabase = await getSupabaseClient(); const supabase = await getSupabaseClient();
const projects = await supabase.getProjects(); const projects = await supabase.getProjects();
const project = projects?.find((p) => p.id === projectId); const project = projects?.find((p) => p.id === projectId);

View File

@@ -3,6 +3,7 @@ import fsAsync from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { isIgnored } from "isomorphic-git"; import { isIgnored } from "isomorphic-git";
import log from "electron-log"; import log from "electron-log";
import { IS_TEST_BUILD } from "../ipc/utils/test_utils";
const logger = log.scope("utils/codebase"); const logger = log.scope("utils/codebase");
@@ -369,7 +370,7 @@ export async function extractCodebase(appPath: string): Promise<{
const endTime = Date.now(); const endTime = Date.now();
logger.log("extractCodebase: time taken", endTime - startTime); logger.log("extractCodebase: time taken", endTime - startTime);
if (process.env.E2E_TEST_BUILD) { if (IS_TEST_BUILD) {
// Why? For some reason, file ordering is not stable on Windows. // Why? For some reason, file ordering is not stable on Windows.
// This is a workaround to ensure stable ordering, although // This is a workaround to ensure stable ordering, although
// ideally we'd like to sort it by modification time which is // ideally we'd like to sort it by modification time which is
@@ -400,7 +401,7 @@ async function sortFilesByModificationTime(files: string[]): Promise<string[]> {
}), }),
); );
if (process.env.E2E_TEST_BUILD) { if (IS_TEST_BUILD) {
// Why? For some reason, file ordering is not stable on Windows. // Why? For some reason, file ordering is not stable on Windows.
// This is a workaround to ensure stable ordering, although // This is a workaround to ensure stable ordering, although
// ideally we'd like to sort it by modification time which is // ideally we'd like to sort it by modification time which is