Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg

Python: `uv run pytest tests/ -v` (1119) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (89: 45 daemon_client + 20 conn-manager + 8 integration + 6 build-config + 7 gui-state + 3 preload-api) — syntax: `node --check main.js preload.js daemon_client.js`
Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (426: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 11 markdown + 15 transcript + 7 TranscriptView + 15 history + 22 composer + 14 Composer + 12 sidebar + 17 Sidebar + 9 fileTree + 9 FileTree + 16 resultPanel + 8 ResultPanel + 29 workspaceView + 8 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 9 openSession + 6 WelcomeDialog + 8 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 12 daemonBridge + 6 DaemonBridgeProvider + 19 Shell + 15 DialogHost + 19 SettingsPanel + 4 vendorMarkdown) + `npm run build` → `renderer/dist/`
Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (441: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 11 markdown + 15 transcript + 7 TranscriptView + 15 history + 22 composer + 14 Composer + 12 sidebar + 17 Sidebar + 9 fileTree + 9 FileTree + 16 resultPanel + 8 ResultPanel + 29 workspaceView + 8 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 9 openSession + 6 WelcomeDialog + 8 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 12 daemonBridge + 6 DaemonBridgeProvider + 23 Shell + 15 DialogHost + 19 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown) + `npm run build` → `renderer/dist/`
CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文)
Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响)
Git-over-https 兜底: `python scripts/sync-master-from-api.py [--repo owner/name] [--ref master]` — 受限网络下 github.com:443 不可达而 api.github.com 可达时,用 Git Data API 的 verification payload + signature 字节级重建上游 commit(含 web-flow GPG 签名 squash merge,reconstruct_commit 经 hermetic 测试验证 sha 一致)并推进本地 refs;内容对象缺失时 fail-loud 提示改用 git fetch(10+ 周期实证的恢复路径)
Expand Down
4 changes: 2 additions & 2 deletions emrg/gui/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -712,13 +712,13 @@ vision = false

// ── Task/template CRUD IPC (rant 2026-08-12T18:23:15 P3) ──────────
ipcMain.handle("emrg:taskCreate", async (_e, payload) => {
const { name, type, project, interval, enabled, repo, description } = payload || {};
const { name, type, project, interval, enabled, repo, description, sandbox } = payload || {};
if (typeof name !== "string" || !name.trim()) throw new Error("invalid task name");
if (typeof type !== "string" || !type.trim()) throw new Error("invalid task type");
if (typeof project !== "string" || !project.trim()) throw new Error("invalid project");
const frame = await requireConn().sendCommandAndWait("task_create", {
name: name.trim(), task_type: type.trim(), project: project.trim(),
interval, enabled, repo, description,
interval, enabled, repo, description, sandbox,
}, 8000);
if (!frame.ok && frame.error) throw new Error(frame.error);
return frame;
Expand Down
67 changes: 67 additions & 0 deletions emrg/gui/renderer/src/components/RantDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { I18nProvider } from "../lib/i18n";
import { RantDialog } from "./RantDialog";
import type { ProjectRec } from "../lib/workspaceView";

/**
* RantDialog.test.tsx — Batch 5 slice 8:Rant 提交对话框。
* 镜像 vanilla openRantForm/submitRantForm 语义:打开清空 + 聚焦、消息必填、
* 项目下拉(已注册项目)、提交 payload {message, project}。
*/

function wrapper(node: React.ReactNode) {
return <I18nProvider lang="en">{node}</I18nProvider>;
}

const PROJECTS: ProjectRec[] = [
{ name: "emrg", path: "/p/emrg" },
{ name: "sci", path: "/p/sci" },
];

afterEach(() => {
vi.restoreAllMocks();
});

describe("RantDialog", () => {
it("open:渲染消息文本域 + 项目下拉(已注册项目)", () => {
render(wrapper(<RantDialog open projects={PROJECTS} onDismiss={vi.fn()} />));
expect(screen.getByTestId("rant-dialog")).toBeInTheDocument();
expect((screen.getByTestId("rant-message") as HTMLTextAreaElement).value).toBe("");
const sel = screen.getByTestId("rant-project") as HTMLSelectElement;
expect(sel.options.length).toBe(PROJECTS.length + 1); // + 空选项
expect(sel.options[1].value).toBe("emrg");
});

it("空消息 → 内联错误,不提交", () => {
const onSubmit = vi.fn();
render(wrapper(<RantDialog open projects={PROJECTS} onSubmit={onSubmit} onDismiss={vi.fn()} />));
fireEvent.click(screen.getByTestId("rant-submit"));
expect(screen.getByTestId("rant-error")).toHaveTextContent("required");
expect(onSubmit).not.toHaveBeenCalled();
});

it("提交 payload {message, project}(选项目)", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(wrapper(<RantDialog open projects={PROJECTS} onSubmit={onSubmit} onDismiss={vi.fn()} />));
fireEvent.change(screen.getByTestId("rant-message"), { target: { value: "侧栏太宽了" } });
fireEvent.change(screen.getByTestId("rant-project"), { target: { value: "emrg" } });
fireEvent.click(screen.getByTestId("rant-submit"));
await vi.waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
expect(onSubmit.mock.calls[0][0]).toEqual({ message: "侧栏太宽了", project: "emrg" });
});

it("项目选填:留空提交 project=''", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(wrapper(<RantDialog open projects={PROJECTS} onSubmit={onSubmit} onDismiss={vi.fn()} />));
fireEvent.change(screen.getByTestId("rant-message"), { target: { value: "hello" } });
fireEvent.click(screen.getByTestId("rant-submit"));
await vi.waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
expect(onSubmit.mock.calls[0][0]).toEqual({ message: "hello", project: "" });
});

it("closed (open=false) → 不渲染", () => {
render(wrapper(<RantDialog open={false} onDismiss={vi.fn()} />));
expect(screen.queryByTestId("rant-dialog")).not.toBeInTheDocument();
});
});
95 changes: 95 additions & 0 deletions emrg/gui/renderer/src/components/RantDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { useEffect, useRef, useState } from "react";
import { useI18n } from "../lib/i18n";
import type { ProjectRec } from "../lib/workspaceView";
import { Dialog } from "./Dialog";

/**
* RantDialog — Rant 提交对话框(Batch 5 slice 8:工作区 Rant 面板"新建 Rant")。
* 镜像 vanilla `openRantForm()` / `submitRantForm()`(renderer/js/dialogs.js):
*
* - 打开即清空消息、聚焦输入;
* - 项目下拉 = 已注册项目(选填,value=name||path,与 daemon rant 协议 project 字段一致);
* - 消息必填(空 → 内联错误;vanilla 用 showConfirm 提示);
* - 提交 → onSubmit({message, project})(调用方接线 sendRant + 刷新列表 + 反馈)。
*/
export interface RantDialogProps {
open: boolean;
/** 项目下拉选项(仅已注册项目;选填) */
projects?: ProjectRec[];
onSubmit?: (payload: { message: string; project: string }) => void | Promise<void>;
onDismiss?: () => void;
}

export function RantDialog({ open, projects = [], onSubmit, onDismiss }: RantDialogProps) {
const { t } = useI18n();
const [err, setErr] = useState<string | null>(null);
const msgRef = useRef<HTMLTextAreaElement>(null);
const projectRef = useRef<HTMLSelectElement>(null);

useEffect(() => {
if (!open) return;
setErr(null);
if (msgRef.current) msgRef.current.value = "";
if (projectRef.current) projectRef.current.value = "";
msgRef.current?.focus();
}, [open]);

const submit = async () => {
const msg = msgRef.current?.value.trim() ?? "";
if (!msg) {
setErr(t("rants.messageRequired"));
return;
}
try {
await onSubmit?.({ message: msg, project: projectRef.current?.value ?? "" });
} finally {
onDismiss?.();
}
};

if (!open) return null;
return (
<Dialog
open
title={t("rants.new")}
onClose={onDismiss}
testId="rant-dialog"
actions={
<>
<button type="button" className="btn btn-ghost" data-testid="rant-cancel" onClick={onDismiss}>
{t("settings.cancel")}
</button>
<button type="button" className="btn btn-primary" data-testid="rant-submit" onClick={() => void submit()}>
{t("rants.submit")}
</button>
</>
}
>
<div className="dialog-form">
{err && (
<div className="hint" style={{ color: "var(--danger, #c0392b)", marginBottom: 8 }} data-testid="rant-error">
{err}
</div>
)}
<label className="form-row">
<span>{t("rants.message")}</span>
<textarea ref={msgRef} data-testid="rant-message" rows={5} placeholder={t("rants.placeholder")} />
</label>
<label className="form-row">
<span>{t("rants.project")}</span>
<select ref={projectRef} data-testid="rant-project">
<option value="">—</option>
{projects.map((p) => {
const v = p.name || p.path || "";
return (
<option key={v} value={v}>
{v}
</option>
);
})}
</select>
</label>
</div>
</Dialog>
);
}
92 changes: 92 additions & 0 deletions emrg/gui/renderer/src/components/Shell.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ function mockEmrg() {
const listMemories = vi.fn().mockResolvedValue([]);
const listSkills = vi.fn().mockResolvedValue([]);
const listHistory = vi.fn().mockResolvedValue({ messages: [] });
// Batch 5 slice 8:任务 CRUD + Rant 提交(window.emrg 桥已暴露,main.js IPC 在列)
const taskCreate = vi.fn().mockResolvedValue({ ok: true });
const taskUpdate = vi.fn().mockResolvedValue({ ok: true });
const taskDelete = vi.fn().mockResolvedValue({ ok: true });
const taskTemplateList = vi.fn().mockResolvedValue([{ name: "journal" }]);
const sendRant = vi.fn().mockResolvedValue({ ok: true, count: 11 });
(window as unknown as { emrg?: unknown }).emrg = {
onEvent,
sendMessage,
Expand All @@ -41,6 +47,11 @@ function mockEmrg() {
listMemories,
listSkills,
listHistory,
taskCreate,
taskUpdate,
taskDelete,
taskTemplateList,
sendRant,
};
return {
listeners,
Expand All @@ -50,6 +61,11 @@ function mockEmrg() {
listProjects,
listTasks,
listRants,
taskCreate,
taskUpdate,
taskDelete,
taskTemplateList,
sendRant,
emit: (evt: DaemonEventFrame) => listeners.forEach((cb) => cb(evt)),
};
}
Expand Down Expand Up @@ -303,4 +319,80 @@ describe("Shell (Batch 5 slice 3 chat wiring)", () => {
await waitFor(() => expect(screen.getByTestId("project-session-row")).toBeInTheDocument());
expect(screen.getByTestId("project-session-row")).toHaveTextContent("会话一");
});

// ── Batch 5 slice 8:任务表单 + Rant 对话框接线 ──

it("任务面板「+ 添加任务」→ 打开表单 → 保存 → taskCreate + 刷新列表", async () => {
const m = mockEmrg();
render(wrapper(<Shell />));
await waitFor(() => expect(screen.getByTestId("composer")).toBeInTheDocument());
fireEvent.click(screen.getByTestId("nav-tasks"));
await waitFor(() => expect(screen.getByTestId("task-row")).toBeInTheDocument());
// 打开表单(任务行按钮 + 底部添加按钮均触发 openTaskForm)
fireEvent.click(screen.getByText("+ Add task"));
await waitFor(() => expect(screen.getByTestId("task-form-dialog")).toBeInTheDocument());
// 类型下拉来自 taskTemplateList(journal)+ evolution 兜底
const typeSel = screen.getByTestId("task-form-type") as HTMLSelectElement;
await waitFor(() => expect(m.taskTemplateList).toHaveBeenCalled());
expect(typeSel.options.length).toBeGreaterThanOrEqual(2);
// 填表保存
fireEvent.change(screen.getByTestId("task-form-name"), { target: { value: "t2" } });
fireEvent.click(screen.getByTestId("task-form-save"));
await waitFor(() => expect(m.taskCreate).toHaveBeenCalledTimes(1));
const payload = m.taskCreate.mock.calls[0][0];
expect(payload.name).toBe("t2");
expect(payload.interval).toBe(1800);
await waitFor(() => expect(m.listTasks).toHaveBeenCalledTimes(2)); // 初始 + 保存后刷新
expect(screen.queryByTestId("task-form-dialog")).not.toBeInTheDocument();
});

it("任务行「Edit」→ 表单预填 + name 只读 → 保存 → taskUpdate(不改名)", async () => {
const m = mockEmrg();
render(wrapper(<Shell />));
await waitFor(() => expect(screen.getByTestId("composer")).toBeInTheDocument());
fireEvent.click(screen.getByTestId("nav-tasks"));
await waitFor(() => expect(screen.getByTestId("task-row")).toBeInTheDocument());
fireEvent.click(screen.getByTitle("Edit"));
await waitFor(() => expect(screen.getByTestId("task-form-dialog")).toBeInTheDocument());
const name = screen.getByTestId("task-form-name") as HTMLInputElement;
expect(name.value).toBe("evo");
expect(name.disabled).toBe(true);
fireEvent.change(screen.getByTestId("task-form-interval"), { target: { value: "7200" } });
fireEvent.click(screen.getByTestId("task-form-save"));
await waitFor(() => expect(m.taskUpdate).toHaveBeenCalledTimes(1));
expect(m.taskUpdate.mock.calls[0][0]).toMatchObject({ name: "evo", interval: 7200 });
expect(m.taskCreate).not.toHaveBeenCalled();
});

it("任务行「Delete」→ 确认弹窗 → taskDelete + 刷新", async () => {
const m = mockEmrg();
render(wrapper(<Shell />));
await waitFor(() => expect(screen.getByTestId("composer")).toBeInTheDocument());
fireEvent.click(screen.getByTestId("nav-tasks"));
await waitFor(() => expect(screen.getByTestId("task-row")).toBeInTheDocument());
fireEvent.click(screen.getByTitle("Delete"));
await waitFor(() => expect(screen.getByTestId("confirm-dialog")).toBeInTheDocument());
fireEvent.click(screen.getByTestId("confirm-ok"));
await waitFor(() => expect(m.taskDelete).toHaveBeenCalledWith({ name: "evo" }));
await waitFor(() => expect(m.listTasks).toHaveBeenCalledTimes(2));
});

it("Rant 面板「+ New rant」→ 对话框 → 提交 → sendRant + 刷新列表", async () => {
const m = mockEmrg();
render(wrapper(<Shell />));
await waitFor(() => expect(screen.getByTestId("composer")).toBeInTheDocument());
fireEvent.click(screen.getByTestId("nav-rants"));
await waitFor(() => expect(screen.getByTestId("rant-row")).toBeInTheDocument());
fireEvent.click(screen.getByText("+ New rant"));
await waitFor(() => expect(screen.getByTestId("rant-dialog")).toBeInTheDocument());
// 项目下拉含已注册项目 emrg
const sel = screen.getByTestId("rant-project") as HTMLSelectElement;
expect(Array.from(sel.options).some((o) => o.value === "emrg")).toBe(true);
fireEvent.change(screen.getByTestId("rant-message"), { target: { value: "测试 rant" } });
fireEvent.change(screen.getByTestId("rant-project"), { target: { value: "emrg" } });
fireEvent.click(screen.getByTestId("rant-submit"));
await waitFor(() => expect(m.sendRant).toHaveBeenCalledWith({ message: "测试 rant", project: "emrg" }));
await waitFor(() => expect(m.listRants).toHaveBeenCalledTimes(2));
expect(screen.queryByTestId("rant-dialog")).not.toBeInTheDocument();
});
});
Loading
Loading