返回 DeepSeek-Reasonix
blank-project-dialog.test.tsx
根目录 / desktop / frontend / src / __tests__ / blank-project-dialog.test.tsx
1 // Run: tsx src/__tests__/blank-project-dialog.test.tsx
2
3 import { readFileSync } from "node:fs";
4 import { dirname, resolve } from "node:path";
5 import { fileURLToPath } from "node:url";
6 import { JSDOM } from "jsdom";
7 import React, { act } from "react";
8 import type { AppBindings } from "../lib/bridge";
9 import { installDesktopHostStub } from "./desktopHostStub";
10
11 let passed = 0;
12 let failed = 0;
13 function ok(value: boolean, label: string) {
14 if (value) {
15 passed += 1;
16 process.stdout.write(` PASS ${label}\n`);
17 } else {
18 failed += 1;
19 process.stdout.write(` FAIL ${label}\n`);
20 }
21 }
22
23 console.log("\nblank project creation");
24 const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
25 pretendToBeVisual: true,
26 url: "http://localhost/",
27 });
28 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
29 globalThis.window = dom.window as unknown as Window & typeof globalThis;
30 globalThis.document = dom.window.document;
31 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
32 globalThis.HTMLElement = dom.window.HTMLElement;
33 globalThis.Event = dom.window.Event;
34 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
35 Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
36 Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
37
38 const [{ createRoot }, { BlankProjectDialog, blankProjectNameProblem }, { BlankProjectFlow }, { LocaleProvider }] = await Promise.all([
39 import("react-dom/client"),
40 import("../components/BlankProjectDialog"),
41 import("../components/BlankProjectFlow"),
42 import("../lib/i18n"),
43 ]);
44
45 ok(blankProjectNameProblem(" ") === "required", "empty names are rejected before IPC");
46 ok(blankProjectNameProblem("nested/project") === "invalid", "nested paths are rejected before IPC");
47 ok(blankProjectNameProblem("project") === null, "a single folder name is accepted");
48
49 const submitted: string[] = [];
50 let cancelled = 0;
51 const rootElement = document.getElementById("root");
52 if (!rootElement) throw new Error("missing root");
53 const root = createRoot(rootElement);
54 await act(async () => {
55 root.render(
56 <LocaleProvider>
57 <BlankProjectDialog
58 parentDirectory="/Users/test/Projects"
59 busy={false}
60 onSubmit={(name) => submitted.push(name)}
61 onCancel={() => { cancelled += 1; }}
62 />
63 </LocaleProvider>,
64 );
65 });
66
67 const dialog = document.querySelector<HTMLElement>('[role="dialog"]');
68 const input = document.querySelector<HTMLInputElement>(".blank-project-dialog__input");
69 const submit = document.querySelector<HTMLButtonElement>('button[type="submit"]');
70 ok(Boolean(dialog) && dialog?.getAttribute("aria-modal") === "true", "creation uses an accessible modal dialog");
71 ok(document.body.textContent?.includes("/Users/test/Projects") === true, "the chosen parent folder is shown before creation");
72 ok(document.activeElement === input, "project name receives initial focus");
73 ok(submit?.disabled === true, "create stays disabled until the name is valid");
74
75 await act(async () => {
76 if (!input) return;
77 const setter = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")?.set;
78 setter?.call(input, " demo-project ");
79 input.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
80 input.dispatchEvent(new dom.window.Event("change", { bubbles: true }));
81 });
82 ok(submit?.disabled === false, "a valid project name enables creation");
83 await act(async () => {
84 dialog?.querySelector("form")?.dispatchEvent(new dom.window.Event("submit", { bubbles: true, cancelable: true }));
85 });
86 ok(submitted[0] === "demo-project", "submission trims and forwards the project name");
87
88 await act(async () => {
89 document.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
90 });
91 ok(cancelled === 1, "Escape cancels the dialog");
92
93 const here = dirname(fileURLToPath(import.meta.url));
94 const treeSource = readFileSync(resolve(here, "../components/ProjectTree.tsx"), "utf8");
95 const addControlsSource = readFileSync(resolve(here, "../components/ProjectTreeAddControls.tsx"), "utf8");
96 const flowSource = readFileSync(resolve(here, "../components/BlankProjectFlow.tsx"), "utf8");
97 const creationSource = readFileSync(resolve(here, "../components/useProjectCreation.tsx"), "utf8");
98 ok(
99 /onBlank: \(\) => \{ closeMenu\(\); openBlankProjectFlow\(\); \}/.test(treeSource) &&
100 /key: "blank-project"[\s\S]*onSelect: onBlank/.test(addControlsSource),
101 "workbench menu starts the blank-project flow",
102 );
103 ok(/CreateBlankProject\(draft\.parentDirectory, projectName\)/.test(flowSource), "the dialog delegates atomic directory creation to Go");
104 ok(/await onOpenProject\(createdPath\)/.test(flowSource), "the new directory reuses workspace navigation and registration");
105 ok(/lazy\(\(\) => import\("\.\/BlankProjectFlow"\)/.test(creationSource), "the creation flow stays outside the startup bundle");
106
107 await act(async () => root.unmount());
108
109 const bridgeCalls: string[] = [];
110 installDesktopHostStub(({ main: { App: {
111 async PickBlankProjectParent() {
112 bridgeCalls.push("pick");
113 return "/Users/test/Projects";
114 },
115 async CreateBlankProject(parentDirectory: string, projectName: string) {
116 bridgeCalls.push(`create:${parentDirectory}:${projectName}`);
117 return `${parentDirectory}/${projectName}`;
118 },
119 } as Partial<AppBindings> as AppBindings } }).main.App);
120 const flowRoot = createRoot(rootElement);
121 await act(async () => {
122 flowRoot.render(
123 <LocaleProvider>
124 <BlankProjectFlow
125 onOpenProject={async (path) => { bridgeCalls.push(`open:${path}`); }}
126 onRefresh={async () => { bridgeCalls.push("refresh"); }}
127 onClose={() => { bridgeCalls.push("close"); }}
128 />
129 </LocaleProvider>,
130 );
131 await Promise.resolve();
132 await Promise.resolve();
133 });
134 const flowInput = document.querySelector<HTMLInputElement>(".blank-project-dialog__input");
135 await act(async () => {
136 if (!flowInput) return;
137 const setter = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")?.set;
138 setter?.call(flowInput, "flow-project");
139 flowInput.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
140 flowInput.dispatchEvent(new dom.window.Event("change", { bubbles: true }));
141 });
142 await act(async () => {
143 flowInput?.closest("form")?.dispatchEvent(new dom.window.Event("submit", { bubbles: true, cancelable: true }));
144 await Promise.resolve();
145 await Promise.resolve();
146 });
147 ok(
148 bridgeCalls.join("|") === "pick|create:/Users/test/Projects:flow-project|open:/Users/test/Projects/flow-project|refresh|close",
149 "the live flow picks, creates, opens, refreshes, and closes in order",
150 );
151
152 await act(async () => flowRoot.unmount());
153 dom.window.close();
154 process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
155 if (failed > 0) process.exit(1);
156
156 lines Plain Text