1 parent
0295bde6d5
commit
c00e21fbde
12 files changed
+269
-11
No files matched your search
@@ -90,6 +90,7 @@ let sidecarRestartAttempts = 0;
|
||||
let sidecarRestartInFlight = false;
|
||||
let localDataResetScheduled = false;
|
||||
let rendererCrashDialogOpen = false;
|
||||
let recoveredAfterAbnormalExit = false;
|
||||
const updateCheckIntervalMs = 60 * 60 * 1_000;
|
||||
const updateCheckFocusThrottleMs = 15 * 60 * 1_000;
|
||||
const hasSingleInstanceLock = app.requestSingleInstanceLock();
|
||||
@@ -886,8 +887,8 @@ app.whenReady().then(async () => {
|
||||
await loadConfiguredApiBaseUrl();
|
||||
await loadDesktopSessionToken();
|
||||
app.setAsDefaultProtocolClient("edgeever");
|
||||
const previousSessionWasActive = existsSync(crashMarkerPath());
|
||||
void writeDiagnostic(previousSessionWasActive ? "session.recovered-after-abnormal-exit" : "session.started");
|
||||
recoveredAfterAbnormalExit = existsSync(crashMarkerPath());
|
||||
void writeDiagnostic(recoveredAfterAbnormalExit ? "session.recovered-after-abnormal-exit" : "session.started");
|
||||
await writeFile(crashMarkerPath(), new Date().toISOString());
|
||||
session.defaultSession.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false));
|
||||
registerResourceProtocol();
|
||||
@@ -931,6 +932,7 @@ app.whenReady().then(async () => {
|
||||
});
|
||||
ipcMain.on("desktop:api-base-url-sync", (event) => { event.returnValue = configuredApiBaseUrl; });
|
||||
ipcMain.on("desktop:session-token-sync", (event) => { event.returnValue = desktopSessionToken; });
|
||||
ipcMain.on("desktop:recovered-after-abnormal-exit-sync", (event) => { event.returnValue = recoveredAfterAbnormalExit; });
|
||||
ipcMain.handle("desktop:copy-text", (_event, value) => {
|
||||
if (typeof value !== "string") throw new Error("Clipboard value must be a string");
|
||||
clipboard.writeText(value);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
describe("desktop abnormal-exit recovery", () => {
|
||||
test("exposes the previous abnormal session to the renderer before workspace startup", () => {
|
||||
const mainSource = readFileSync(new URL("./index.mjs", import.meta.url), "utf8");
|
||||
const preloadSource = readFileSync(new URL("../preload/index.cjs", import.meta.url), "utf8");
|
||||
|
||||
expect(mainSource).toContain("recoveredAfterAbnormalExit = existsSync(crashMarkerPath())");
|
||||
expect(mainSource).toContain('ipcMain.on("desktop:recovered-after-abnormal-exit-sync"');
|
||||
expect(preloadSource).toContain('recoveredAfterAbnormalExit: ipcRenderer.sendSync("desktop:recovered-after-abnormal-exit-sync")');
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ const { contextBridge, ipcRenderer } = require("electron");
|
||||
contextBridge.exposeInMainWorld("edgeeverDesktop", Object.freeze({
|
||||
isAvailable: true,
|
||||
canClearLocalData: ipcRenderer.sendSync("desktop:local-data-reset-available-sync"),
|
||||
recoveredAfterAbnormalExit: ipcRenderer.sendSync("desktop:recovered-after-abnormal-exit-sync"),
|
||||
sidecarStatus: () => ipcRenderer.invoke("desktop:sidecar-status"),
|
||||
setAccountScope: (accountId) => ipcRenderer.invoke("desktop:set-account-scope", accountId),
|
||||
apiBaseUrl: ipcRenderer.sendSync("desktop:api-base-url-sync"),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { type ErrorInfo, type ReactNode } from "react";
|
||||
import { markRendererRecoveryRequired } from "@/lib/renderer-recovery";
|
||||
|
||||
type RendererErrorDetails = {
|
||||
kind: "react-error";
|
||||
@@ -30,6 +31,7 @@ export class DesktopRendererErrorBoundary extends React.Component<{ children: Re
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
markRendererRecoveryRequired();
|
||||
const details: RendererErrorDetails = {
|
||||
kind: "react-error",
|
||||
message: error.message,
|
||||
@@ -65,11 +67,11 @@ export class DesktopRendererErrorBoundary extends React.Component<{ children: Re
|
||||
<p className="mt-2 text-sm leading-6 text-slate-600">
|
||||
{desktop
|
||||
? (zh
|
||||
? "问题已经记录到这台设备。你可以重新加载继续使用,并在提交前检查脱敏后的 GitHub Issue。"
|
||||
: "The problem was recorded on this device. You can reload to continue and review the redacted GitHub Issue before submitting it.")
|
||||
? "问题已经记录到这台设备。重新加载后会进入安全启动模式,不再自动打开刚才的笔记。"
|
||||
: "The problem was recorded on this device. Reloading will enter safe startup mode instead of reopening the previous note.")
|
||||
: (zh
|
||||
? "你可以重新加载继续使用。如果问题仍然出现,请检查浏览器控制台,并在提交 GitHub Issue 前移除敏感信息。"
|
||||
: "Reload to continue. If the problem persists, check the browser console and remove sensitive information before submitting a GitHub Issue.")}
|
||||
? "安全重新加载后不会自动打开刚才的笔记。如果问题仍然出现,请检查浏览器控制台。"
|
||||
: "A safe reload will avoid reopening the previous note. If the problem persists, check the browser console.")}
|
||||
</p>
|
||||
<div className="mt-5 flex flex-wrap gap-3">
|
||||
<button
|
||||
@@ -77,7 +79,7 @@ export class DesktopRendererErrorBoundary extends React.Component<{ children: Re
|
||||
type="button"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
{zh ? "重新加载" : "Reload"}
|
||||
{zh ? "安全重新加载" : "Reload safely"}
|
||||
</button>
|
||||
{desktop ? (
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
describe("note editor failure containment", () => {
|
||||
test("isolates editor failures below the workspace shell", () => {
|
||||
const workspaceSource = readFileSync(new URL("./WorkspaceApp.tsx", import.meta.url), "utf8");
|
||||
|
||||
expect(workspaceSource).toContain("<EditorPaneErrorBoundary");
|
||||
expect(workspaceSource).toContain("<EditorPane\n");
|
||||
expect(workspaceSource.indexOf("<EditorPaneErrorBoundary")).toBeLessThan(workspaceSource.indexOf("<EditorPane\n"));
|
||||
});
|
||||
|
||||
test("does not auto-open a note while renderer recovery is active", () => {
|
||||
const workspaceSource = readFileSync(new URL("./WorkspaceApp.tsx", import.meta.url), "utf8");
|
||||
const rootBoundarySource = readFileSync(new URL("./DesktopRendererErrorBoundary.tsx", import.meta.url), "utf8");
|
||||
|
||||
expect(rootBoundarySource).toContain("markRendererRecoveryRequired()");
|
||||
expect(workspaceSource).toContain("rendererRecoveryMode ? null : selectedMemoId ?? memos[0]?.id ?? null");
|
||||
expect(workspaceSource).toContain("if (rendererRecoveryMode) {");
|
||||
expect(workspaceSource).toContain("<EditorRecoveryPane />");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import React, { type ErrorInfo, type ReactNode } from "react";
|
||||
import { clearRendererRecoveryRequired, markRendererRecoveryRequired } from "@/lib/renderer-recovery";
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
resetKey: string | null;
|
||||
onBackToList: () => void;
|
||||
};
|
||||
|
||||
type State = {
|
||||
error: Error | null;
|
||||
};
|
||||
|
||||
const isChineseInterface = () => navigator.language.toLowerCase().startsWith("zh");
|
||||
|
||||
export class EditorPaneErrorBoundary extends React.Component<Props, State> {
|
||||
state: State = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: unknown): State {
|
||||
return { error: error instanceof Error ? error : new Error(String(error)) };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
markRendererRecoveryRequired();
|
||||
const details = {
|
||||
kind: "react-error" as const,
|
||||
message: error.message,
|
||||
stack: error.stack ?? "",
|
||||
componentStack: info.componentStack ?? "",
|
||||
};
|
||||
console.error("Caught note editor error", error, info.componentStack);
|
||||
void window.edgeeverDesktop?.recordRendererError(details).catch(() => undefined);
|
||||
}
|
||||
|
||||
componentDidUpdate(previousProps: Props) {
|
||||
if (this.state.error && previousProps.resetKey !== this.props.resetKey) {
|
||||
clearRendererRecoveryRequired();
|
||||
this.setState({ error: null });
|
||||
}
|
||||
}
|
||||
|
||||
private retry = () => {
|
||||
clearRendererRecoveryRequired();
|
||||
this.setState({ error: null });
|
||||
};
|
||||
|
||||
render() {
|
||||
if (!this.state.error) return this.props.children;
|
||||
const zh = isChineseInterface();
|
||||
|
||||
return (
|
||||
<main className="flex h-full min-h-0 items-center justify-center bg-slate-50 p-6 text-slate-900">
|
||||
<section className="w-full max-w-lg rounded-2xl border border-slate-200 bg-white p-6 shadow-sm" role="alert">
|
||||
<div className="mb-4 flex h-11 w-11 items-center justify-center rounded-full bg-amber-50 text-xl text-amber-700">!</div>
|
||||
<h1 className="text-lg font-semibold">{zh ? "这条笔记暂时无法显示" : "This note cannot be displayed"}</h1>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-600">
|
||||
{zh
|
||||
? "异常已被限制在当前笔记,笔记列表和应用其他功能仍可继续使用。请选择另一条笔记,或重试当前笔记。"
|
||||
: "The failure was isolated to this note. The note list and the rest of the app remain available. Choose another note or retry this one."}
|
||||
</p>
|
||||
<div className="mt-5 flex flex-wrap gap-3">
|
||||
<button
|
||||
className="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500"
|
||||
type="button"
|
||||
onClick={this.props.onBackToList}
|
||||
>
|
||||
{zh ? "返回笔记列表" : "Back to note list"}
|
||||
</button>
|
||||
<button
|
||||
className="rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500"
|
||||
type="button"
|
||||
onClick={this.retry}
|
||||
>
|
||||
{zh ? "重试当前笔记" : "Retry this note"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const EditorRecoveryPane = () => {
|
||||
const zh = isChineseInterface();
|
||||
return (
|
||||
<main className="flex h-full min-h-0 items-center justify-center bg-slate-50 p-6 text-slate-900">
|
||||
<section className="w-full max-w-lg rounded-2xl border border-slate-200 bg-white p-6 shadow-sm" role="status">
|
||||
<div className="mb-4 flex h-11 w-11 items-center justify-center rounded-full bg-emerald-50 text-xl text-emerald-700">✓</div>
|
||||
<h1 className="text-lg font-semibold">{zh ? "已进入安全启动模式" : "Safe startup mode is active"}</h1>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-600">
|
||||
{zh
|
||||
? "EdgeEver 没有重新打开上次出错的笔记。请从左侧列表选择其他笔记继续使用;选择后会自动退出安全模式。"
|
||||
: "EdgeEver did not reopen the note that failed last time. Choose another note from the list to continue; safe mode will then turn off automatically."}
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
@@ -94,6 +94,8 @@ import { useWorkspacePreferences } from "@/hooks/useWorkspacePreferences";
|
||||
import { useWorkspaceSelection } from "@/hooks/useWorkspaceSelection";
|
||||
import { useWorkspaceQueuedSync } from "@/hooks/useWorkspaceQueuedSync";
|
||||
import { EdgeEverPluginHost } from "@/lib/plugins/plugin-host";
|
||||
import { clearRendererRecoveryRequired, isRendererRecoveryRequired } from "@/lib/renderer-recovery";
|
||||
import { EditorPaneErrorBoundary, EditorRecoveryPane } from "./EditorPaneErrorBoundary";
|
||||
|
||||
const isDesktopViewport = () => window.matchMedia("(min-width: 1024px)").matches;
|
||||
const PULL_TO_REFRESH_TRIGGER_PX = 72;
|
||||
@@ -685,6 +687,9 @@ export const WorkspaceApp = ({
|
||||
const isInitialAiPromptsRoute = route.isAiPrompts;
|
||||
const isInitialMobileEditorReturn = Boolean(route.mobileEditorReturnMemoId);
|
||||
const isTrashRoute = route.isTrash;
|
||||
const [rendererRecoveryMode, setRendererRecoveryMode] = useState(() =>
|
||||
Boolean(window.edgeeverDesktop?.recoveredAfterAbnormalExit) || isRendererRecoveryRequired()
|
||||
);
|
||||
const [activePane, setActivePane] = useState<Pane>(() => ((isInitialSettingsRoute || isInitialPluginsRoute || isInitialTemplatesRoute || isInitialAiPromptsRoute) && !isInitialMobileEditorReturn ? "editor" : "memos"));
|
||||
const [memoView, setMemoView] = useState<MemoView>(() => (isTrashRoute ? "trash" : "notebook"));
|
||||
const {
|
||||
@@ -1371,11 +1376,15 @@ export const WorkspaceApp = ({
|
||||
const previousMemoId = selectedMemoIndex > 0 ? memos[selectedMemoIndex - 1]?.id : null;
|
||||
const nextMemoId =
|
||||
selectedMemoIndex >= 0 && selectedMemoIndex < memos.length - 1 ? memos[selectedMemoIndex + 1]?.id : null;
|
||||
const detailMemoId = selectedMemoId ?? memos[0]?.id ?? null;
|
||||
const detailMemoId = rendererRecoveryMode ? null : selectedMemoId ?? memos[0]?.id ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
const selectedMemoInList = selectedMemoId ? memos.some((memo) => memo.id === selectedMemoId) : false;
|
||||
|
||||
if (rendererRecoveryMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (creatingMemoSelectionRef.current || pendingCreatedMemoIdRef.current) {
|
||||
return;
|
||||
}
|
||||
@@ -1402,7 +1411,13 @@ export const WorkspaceApp = ({
|
||||
if (!selectedMemoId || !selectedMemoInList) {
|
||||
setSelectedMemoId(memos[0].id);
|
||||
}
|
||||
}, [createdMemoEditId, memos, selectedMemoId]);
|
||||
}, [createdMemoEditId, memos, rendererRecoveryMode, selectedMemoId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!rendererRecoveryMode || !selectedMemoId) return;
|
||||
clearRendererRecoveryRequired();
|
||||
setRendererRecoveryMode(false);
|
||||
}, [rendererRecoveryMode, selectedMemoId]);
|
||||
|
||||
const memoQuery = useQuery({
|
||||
queryKey: detailMemoId ? memoDetailQueryKey(detailMemoId, memoView) : ["memo", detailMemoId, memoView],
|
||||
@@ -3128,8 +3143,18 @@ export const WorkspaceApp = ({
|
||||
<AiPromptsPane onClose={handleCloseAiPrompts} />
|
||||
) : rightView === "evernote-migration" ? (
|
||||
<EvernoteImportGuidePane onClose={() => setRightView("settings")} />
|
||||
) : rendererRecoveryMode ? (
|
||||
<EditorRecoveryPane />
|
||||
) : (
|
||||
<EditorPane
|
||||
<EditorPaneErrorBoundary
|
||||
resetKey={selectedMemo?.id ?? selectedMemoId}
|
||||
onBackToList={() => {
|
||||
setRendererRecoveryMode(true);
|
||||
setSelectedMemoId(null);
|
||||
setActivePane("memos");
|
||||
}}
|
||||
>
|
||||
<EditorPane
|
||||
memo={selectedMemo}
|
||||
repository={repository}
|
||||
pluginHost={pluginHost}
|
||||
@@ -3224,6 +3249,7 @@ export const WorkspaceApp = ({
|
||||
onMobileDefaultEditConsumed={handleMobileDefaultEditConsumed}
|
||||
onSaveAsTemplate={handleSaveAsTemplate}
|
||||
/>
|
||||
</EditorPaneErrorBoundary>
|
||||
)}
|
||||
</m.div>
|
||||
</Suspense>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
RENDERER_RECOVERY_STORAGE_KEY,
|
||||
clearRendererRecoveryRequired,
|
||||
isRendererRecoveryRequired,
|
||||
markRendererRecoveryRequired,
|
||||
} from "./renderer-recovery.ts";
|
||||
|
||||
const createStorage = () => {
|
||||
const values = new Map();
|
||||
return {
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
removeItem: (key) => values.delete(key),
|
||||
};
|
||||
};
|
||||
|
||||
describe("renderer recovery marker", () => {
|
||||
test("persists recovery until the user leaves safe mode", () => {
|
||||
const storage = createStorage();
|
||||
|
||||
expect(isRendererRecoveryRequired(storage)).toBe(false);
|
||||
markRendererRecoveryRequired(storage);
|
||||
expect(storage.getItem(RENDERER_RECOVERY_STORAGE_KEY)).toBe("1");
|
||||
expect(isRendererRecoveryRequired(storage)).toBe(true);
|
||||
|
||||
clearRendererRecoveryRequired(storage);
|
||||
expect(isRendererRecoveryRequired(storage)).toBe(false);
|
||||
});
|
||||
|
||||
test("never throws when browser storage is unavailable", () => {
|
||||
const unavailableStorage = {
|
||||
getItem() { throw new Error("blocked"); },
|
||||
setItem() { throw new Error("blocked"); },
|
||||
removeItem() { throw new Error("blocked"); },
|
||||
};
|
||||
|
||||
expect(isRendererRecoveryRequired(unavailableStorage)).toBe(false);
|
||||
expect(() => markRendererRecoveryRequired(unavailableStorage)).not.toThrow();
|
||||
expect(() => clearRendererRecoveryRequired(unavailableStorage)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
export const RENDERER_RECOVERY_STORAGE_KEY = "edgeever.renderer-recovery-required";
|
||||
|
||||
type RecoveryStorage = Pick<Storage, "getItem" | "setItem" | "removeItem">;
|
||||
|
||||
const getBrowserStorage = (): RecoveryStorage | null => {
|
||||
try {
|
||||
return typeof window === "undefined" ? null : window.localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const isRendererRecoveryRequired = (storage: RecoveryStorage | null = getBrowserStorage()) => {
|
||||
try {
|
||||
return storage?.getItem(RENDERER_RECOVERY_STORAGE_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const markRendererRecoveryRequired = (storage: RecoveryStorage | null = getBrowserStorage()) => {
|
||||
try {
|
||||
storage?.setItem(RENDERER_RECOVERY_STORAGE_KEY, "1");
|
||||
} catch {
|
||||
// Recovery must never turn a renderer failure into another failure.
|
||||
}
|
||||
};
|
||||
|
||||
export const clearRendererRecoveryRequired = (storage: RecoveryStorage | null = getBrowserStorage()) => {
|
||||
try {
|
||||
storage?.removeItem(RENDERER_RECOVERY_STORAGE_KEY);
|
||||
} catch {
|
||||
// Restricted storage should not prevent the user from opening another note.
|
||||
}
|
||||
};
|
||||
Vendored
+1
@@ -16,6 +16,7 @@ declare const __EDGEEVER_DEVELOPMENT_PROFILE__: "" | "local" | "demo";
|
||||
interface EdgeEverDesktopBridge {
|
||||
isAvailable: boolean;
|
||||
canClearLocalData: boolean;
|
||||
recoveredAfterAbnormalExit: boolean;
|
||||
apiBaseUrl: string;
|
||||
setApiBaseUrl(value: string): Promise<string>;
|
||||
getSessionToken(): string;
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@
|
||||
"release": "bun scripts/release.mjs",
|
||||
"publish:stores": "bun scripts/store-delivery.mjs",
|
||||
"test:desktop:sidecar": "bun scripts/test-desktop-sidecar.mjs",
|
||||
"test:desktop": "bun test apps/desktop/src/main/account-scope.test.mjs apps/desktop/src/main/clipboard-write.test.mjs apps/desktop/src/main/desktop-diagnostics.test.mjs apps/desktop/src/main/diagnostic-log.test.mjs apps/desktop/src/main/file-association.test.mjs apps/desktop/src/main/file-permissions.test.mjs apps/desktop/src/main/installation-location.test.mjs apps/desktop/src/main/local-data-reset.test.mjs apps/desktop/src/main/resource-request.test.mjs apps/desktop/src/main/resource-url.test.mjs apps/desktop/src/main/staged-resource.test.mjs apps/desktop/src/main/tray-icon.test.mjs apps/desktop/src/main/user-data-directory.test.mjs apps/desktop/src/main/window-open-policy.test.mjs apps/desktop/src/main/window-visibility.test.mjs apps/web/src/lib/api.test.mjs apps/web/src/lib/app-page-path.test.mjs apps/web/src/lib/clipboard.test.mjs apps/web/src/lib/desktop-repository.test.mjs apps/web/src/lib/desktop-resources.test.mjs apps/web/src/lib/desktop-sync.test.mjs apps/web/src/lib/local-mirror.test.mjs apps/web/src/lib/repository.test.mjs apps/web/src/lib/runtime.test.mjs apps/web/src/lib/sync-queue.test.mjs && bun run test:desktop:sidecar",
|
||||
"test:desktop": "bun test apps/desktop/src/main/account-scope.test.mjs apps/desktop/src/main/clipboard-write.test.mjs apps/desktop/src/main/desktop-diagnostics.test.mjs apps/desktop/src/main/diagnostic-log.test.mjs apps/desktop/src/main/file-association.test.mjs apps/desktop/src/main/file-permissions.test.mjs apps/desktop/src/main/installation-location.test.mjs apps/desktop/src/main/local-data-reset.test.mjs apps/desktop/src/main/renderer-recovery.test.mjs apps/desktop/src/main/resource-request.test.mjs apps/desktop/src/main/resource-url.test.mjs apps/desktop/src/main/staged-resource.test.mjs apps/desktop/src/main/tray-icon.test.mjs apps/desktop/src/main/user-data-directory.test.mjs apps/desktop/src/main/window-open-policy.test.mjs apps/desktop/src/main/window-visibility.test.mjs apps/web/src/lib/api.test.mjs apps/web/src/lib/app-page-path.test.mjs apps/web/src/lib/clipboard.test.mjs apps/web/src/lib/desktop-repository.test.mjs apps/web/src/lib/desktop-resources.test.mjs apps/web/src/lib/desktop-sync.test.mjs apps/web/src/lib/local-mirror.test.mjs apps/web/src/lib/repository.test.mjs apps/web/src/lib/runtime.test.mjs apps/web/src/lib/sync-queue.test.mjs && bun run test:desktop:sidecar",
|
||||
"verify:desktop-renderer": "bun scripts/verify-desktop-renderer.mjs",
|
||||
"verify:desktop-package": "bun scripts/verify-desktop-package.mjs",
|
||||
"build:worker": "bun scripts/build-cloudflare-worker.mjs",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("starts at the note list after a renderer failure instead of reopening the previous note", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem("edgeever.renderer-recovery-required", "1");
|
||||
});
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page.getByRole("status").getByText("已进入安全启动模式")).toBeVisible();
|
||||
const firstMemo = page.locator("[data-memo-id]").first();
|
||||
await expect(firstMemo).toBeVisible();
|
||||
await firstMemo.click();
|
||||
|
||||
await expect(page.getByText("已进入安全启动模式")).toHaveCount(0);
|
||||
await expect.poll(() => page.evaluate(() => window.localStorage.getItem("edgeever.renderer-recovery-required"))).toBeNull();
|
||||
});
|
||||
Reference in new issue
Block a user