feat: 持久化运行记录并补充分发验证
This commit is contained in:
parent
c03380168d
commit
aff2d5cd95
113
scripts/package.mjs
Normal file
113
scripts/package.mjs
Normal file
@ -0,0 +1,113 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import AdmZip from "adm-zip";
|
||||
import packager from "@electron/packager";
|
||||
|
||||
const root = process.cwd();
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
|
||||
const version = packageJson.version;
|
||||
const releaseRoot = path.resolve(root, "release");
|
||||
const productFolderName = `RPA-Studio-${version}-win-x64`;
|
||||
const productFolder = path.join(releaseRoot, productFolderName);
|
||||
const zipPath = path.join(releaseRoot, `${productFolderName}.zip`);
|
||||
|
||||
function assertInsideRelease(target) {
|
||||
const relative = path.relative(releaseRoot, path.resolve(target));
|
||||
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
throw new Error(`拒绝操作 release 目录外路径:${target}`);
|
||||
}
|
||||
}
|
||||
|
||||
function run(command, args) {
|
||||
const result = spawnSync(command, args, { cwd: root, stdio: "inherit", shell: process.platform === "win32" });
|
||||
if (result.status !== 0) throw new Error(`${command} ${args.join(" ")} 失败,退出码 ${result.status}`);
|
||||
}
|
||||
|
||||
fs.mkdirSync(releaseRoot, { recursive: true });
|
||||
for (const target of [productFolder, zipPath]) {
|
||||
assertInsideRelease(target);
|
||||
fs.rmSync(target, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log("[package] typecheck");
|
||||
run("npm", ["run", "typecheck"]);
|
||||
console.log("[package] tests");
|
||||
run("npm", ["test"]);
|
||||
console.log("[package] build");
|
||||
run("npm", ["run", "build"]);
|
||||
|
||||
console.log("[package] electron packager");
|
||||
const outputs = await packager({
|
||||
dir: root,
|
||||
name: "RPA Studio",
|
||||
platform: "win32",
|
||||
arch: "x64",
|
||||
out: releaseRoot,
|
||||
overwrite: true,
|
||||
prune: true,
|
||||
asar: true,
|
||||
electronVersion: packageJson.devDependencies.electron,
|
||||
appVersion: version,
|
||||
win32metadata: {
|
||||
CompanyName: "RPA Studio",
|
||||
FileDescription: "RPA Studio Desktop Automation",
|
||||
ProductName: "RPA Studio",
|
||||
InternalName: "RPA Studio"
|
||||
},
|
||||
ignore: [
|
||||
/^[/\\]\.git($|[/\\])/,
|
||||
/^[/\\]release($|[/\\])/,
|
||||
/^[/\\]_analysis($|[/\\])/,
|
||||
/^[/\\]docs($|[/\\])/,
|
||||
/^[/\\]scripts($|[/\\])/,
|
||||
/^[/\\]src($|[/\\])/,
|
||||
/\.test\.[cm]?[jt]sx?$/
|
||||
]
|
||||
});
|
||||
|
||||
const packaged = outputs[0];
|
||||
if (!packaged || !fs.existsSync(path.join(packaged, "RPA Studio.exe"))) {
|
||||
throw new Error(`打包产物无效:${packaged || "未返回目录"}`);
|
||||
}
|
||||
if (path.resolve(packaged) !== path.resolve(productFolder)) {
|
||||
assertInsideRelease(packaged);
|
||||
fs.renameSync(packaged, productFolder);
|
||||
}
|
||||
|
||||
const git = spawnSync("git", ["rev-parse", "--short", "HEAD"], { cwd: root, encoding: "utf8" });
|
||||
const revision = git.status === 0 ? git.stdout.trim() : "unknown";
|
||||
const builtAt = new Date().toISOString();
|
||||
fs.writeFileSync(path.join(productFolder, "version.json"), JSON.stringify({
|
||||
product: "RPA Studio",
|
||||
version,
|
||||
revision,
|
||||
builtAt,
|
||||
platform: "win32-x64",
|
||||
electron: packageJson.devDependencies.electron,
|
||||
distribution: "portable"
|
||||
}, null, 2));
|
||||
fs.writeFileSync(path.join(productFolder, "使用说明.txt"), [
|
||||
"RPA Studio Windows x64 免安装版",
|
||||
"",
|
||||
"1. 将整个文件夹解压到普通本地目录。",
|
||||
"2. 双击 RPA Studio.exe 启动。",
|
||||
"3. 任务、流程和运行记录默认保存在当前 Windows 用户的 AppData\\Roaming\\rpa-studio。",
|
||||
"4. Word 自动化需要本机安装 Microsoft Word;其他文件级能力无需安装 Office。",
|
||||
"5. 首次启动如被 Windows SmartScreen 提示,请核对同目录 SHA256SUMS.txt;当前构建未进行商业代码签名。",
|
||||
"",
|
||||
`版本:${version}`,
|
||||
`源码提交:${revision}`,
|
||||
`构建时间:${builtAt}`
|
||||
].join("\r\n"), "utf8");
|
||||
|
||||
console.log("[package] zip");
|
||||
const zip = new AdmZip();
|
||||
zip.addLocalFolder(productFolder, productFolderName);
|
||||
zip.writeZip(zipPath);
|
||||
const sha256 = createHash("sha256").update(fs.readFileSync(zipPath)).digest("hex");
|
||||
fs.writeFileSync(path.join(releaseRoot, "SHA256SUMS.txt"), `${sha256} ${path.basename(zipPath)}\r\n`, "utf8");
|
||||
|
||||
console.log(JSON.stringify({ productFolder, zipPath, sha256 }, null, 2));
|
||||
151
src/main/capabilities/browser.test.ts
Normal file
151
src/main/capabilities/browser.test.ts
Normal file
@ -0,0 +1,151 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CatalogComponent, FlowNode } from "../../shared/types";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import { RuntimeError, type BrowserLike, type RunContext } from "../engine/types";
|
||||
import "./browser";
|
||||
|
||||
const component = {} as CatalogComponent;
|
||||
|
||||
function node(componentId: string): FlowNode {
|
||||
return { id: `browser_${componentId}`, componentId, position: { x: 0, y: 0 }, values: {}, options: {} };
|
||||
}
|
||||
|
||||
function fakeBrowser(): BrowserLike {
|
||||
return {
|
||||
navigate: vi.fn(async (url: string) => ({ url, title: "Fake page" })),
|
||||
click: vi.fn(async (selector: unknown) => ({ selector })),
|
||||
type: vi.fn(async (selector: unknown, text: string, clearFirst?: boolean) => ({ selector, text, clearFirst })),
|
||||
getText: vi.fn(async () => "fake text"),
|
||||
exists: vi.fn(async () => true),
|
||||
select: vi.fn(async (selector: unknown, value: string) => ({ selector, value })),
|
||||
runJs: vi.fn(async (script: string) => ({ script })),
|
||||
download: vi.fn(async (_selector: unknown, folder: string, filename?: string) => `${folder}/${filename || "download.bin"}`),
|
||||
screenshot: vi.fn(async (target: string) => target),
|
||||
currentUrl: vi.fn(async () => "about:blank"),
|
||||
close: vi.fn(async () => undefined)
|
||||
};
|
||||
}
|
||||
|
||||
function context(browser: BrowserLike): RunContext {
|
||||
return {
|
||||
runId: "browser-test-run", flowId: "browser-test-flow", flow: {} as RunContext["flow"],
|
||||
vars: {}, secrets: {}, signal: new AbortController().signal, evidenceDir: "C:/temp/evidence",
|
||||
browser, emit: vi.fn(), log: vi.fn(), componentOf: vi.fn()
|
||||
};
|
||||
}
|
||||
|
||||
async function invoke(ctx: RunContext, componentId: string, args: Record<string, unknown>): Promise<unknown> {
|
||||
const handler = getHandler(componentId);
|
||||
expect(handler, `${componentId} should be registered`).toBeTypeOf("function");
|
||||
return handler!(ctx, node(componentId), args, component);
|
||||
}
|
||||
|
||||
async function expectValidationError(promise: Promise<unknown>): Promise<void> {
|
||||
await expect(promise).rejects.toSatisfy((error: unknown) =>
|
||||
error instanceof RuntimeError && error.code === "validation_error"
|
||||
);
|
||||
}
|
||||
|
||||
describe("browser capability parameter routing", () => {
|
||||
let browser: BrowserLike;
|
||||
let ctx: RunContext;
|
||||
|
||||
beforeEach(() => { browser = fakeBrowser(); ctx = context(browser); });
|
||||
|
||||
it.each(["WebBrowser_CreateV3", "WebBrowser_Create", "WebBrowser_CreateBackground"])(
|
||||
"%s routes its URL to browser.navigate",
|
||||
async (componentId) => {
|
||||
await expect(invoke(ctx, componentId, { url: "https://offline.test/create" })).resolves.toEqual({
|
||||
url: "https://offline.test/create", title: "Fake page"
|
||||
});
|
||||
expect(browser.navigate).toHaveBeenCalledOnce();
|
||||
expect(browser.navigate).toHaveBeenCalledWith("https://offline.test/create");
|
||||
}
|
||||
);
|
||||
|
||||
it("routes navigation URL", async () => {
|
||||
await invoke(ctx, "WebBrowser_GoUrl", { url: "https://offline.test/next" });
|
||||
expect(browser.navigate).toHaveBeenCalledWith("https://offline.test/next");
|
||||
});
|
||||
|
||||
it("routes click selector", async () => {
|
||||
const selector = { type: "css", value: "#submit" };
|
||||
await invoke(ctx, "Element_Click_V6", { uiSelector: selector });
|
||||
expect(browser.click).toHaveBeenCalledWith(selector);
|
||||
});
|
||||
|
||||
it("routes input selector and text and handles enter/blur", async () => {
|
||||
const selector = { type: "aria", value: "textbox=Name" };
|
||||
await invoke(ctx, "Element_SetValue_V6", { uiSelector: selector, text: "robot", isEnter: "1" });
|
||||
expect(browser.type).toHaveBeenCalledWith(selector, "robot", true);
|
||||
expect(browser.runJs).toHaveBeenCalledWith("document.activeElement && document.activeElement.blur()");
|
||||
});
|
||||
|
||||
it("routes text selector and requested attribute", async () => {
|
||||
const selector = { type: "css", value: ".result" };
|
||||
await expect(invoke(ctx, "Element_GetText_V5", {
|
||||
uiSelector: selector, attr_type: "attribute", attr_name: "data-result"
|
||||
})).resolves.toBe("fake text");
|
||||
expect(browser.getText).toHaveBeenCalledWith(selector, "data-result");
|
||||
});
|
||||
|
||||
it("routes element-exists selector", async () => {
|
||||
const selector = { type: "text", value: "Completed" };
|
||||
await expect(invoke(ctx, "Element_Exists", { uiSelector: selector })).resolves.toBe(true);
|
||||
expect(browser.exists).toHaveBeenCalledWith(selector);
|
||||
});
|
||||
|
||||
it("routes select selector and itemsToSelect value", async () => {
|
||||
const selector = { type: "css", value: "select[name=status]" };
|
||||
await invoke(ctx, "Element_SelectOperation_v1", { uiSelector: selector, itemsToSelect: "approved" });
|
||||
expect(browser.select).toHaveBeenCalledWith(selector, "approved");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"WebBrowser_RunJsInIeByTitle", "WebBrowser_RunJsInIeBySelector", "WebBrowser_RunJsInIeBySelector_v1",
|
||||
"WebBrowser_RunJsInIeBySelector_v2", "WebBrowser_RunJsInIeBySelector_v3", "WebBrowser_RunJsInIeBySelectorGround"
|
||||
])("%s routes jsCode to browser.runJs", async (componentId) => {
|
||||
await invoke(ctx, componentId, { jsCode: "return document.title" });
|
||||
expect(browser.runJs).toHaveBeenCalledWith("return document.title");
|
||||
});
|
||||
|
||||
it.each(["WebBrowser_SaveAs", "WebBrowser_SaveAs_V1"])(
|
||||
"%s routes selector and download options",
|
||||
async (componentId) => {
|
||||
const selector = { type: "css", value: "a.download" };
|
||||
await expect(invoke(ctx, componentId, {
|
||||
uiSelector: selector, path: "C:/offline/downloads", filename: "report.csv", timeout: "2500"
|
||||
})).resolves.toBe("C:/offline/downloads/report.csv");
|
||||
expect(browser.download).toHaveBeenCalledWith(selector, "C:/offline/downloads", "report.csv", 2500);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe("browser capability validation", () => {
|
||||
let browser: BrowserLike;
|
||||
let ctx: RunContext;
|
||||
|
||||
beforeEach(() => { browser = fakeBrowser(); ctx = context(browser); });
|
||||
|
||||
it("rejects a missing navigation URL", async () => {
|
||||
await expectValidationError(invoke(ctx, "WebBrowser_GoUrl", {}));
|
||||
expect(browser.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a missing element selector", async () => {
|
||||
await expectValidationError(invoke(ctx, "Element_Click_V6", {}));
|
||||
expect(browser.click).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a missing JavaScript script", async () => {
|
||||
await expectValidationError(invoke(ctx, "WebBrowser_RunJsInIeByTitle", {}));
|
||||
expect(browser.runJs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a missing download directory before starting a download", async () => {
|
||||
await expectValidationError(invoke(ctx, "WebBrowser_SaveAs", {
|
||||
uiSelector: { type: "css", value: "a.download" }
|
||||
}));
|
||||
expect(browser.download).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -16,6 +16,36 @@ function fileOf(id: string): string {
|
||||
return path.join(dir(), `${id}.json`);
|
||||
}
|
||||
|
||||
interface PersistedRunSummary {
|
||||
flowId: string;
|
||||
status: "succeeded" | "failed" | "cancelled";
|
||||
finishedAt?: string;
|
||||
startedAt?: string;
|
||||
durationMs?: number;
|
||||
error?: { reason?: string } | string;
|
||||
}
|
||||
|
||||
function latestRuns(): Map<string, PersistedRunSummary> {
|
||||
const result = new Map<string, PersistedRunSummary>();
|
||||
const runsDir = path.join(app.getPath("userData"), "runs");
|
||||
if (!fs.existsSync(runsDir)) return result;
|
||||
for (const entry of fs.readdirSync(runsDir)) {
|
||||
const file = path.join(runsDir, entry, "run.json");
|
||||
if (!fs.existsSync(file)) continue;
|
||||
try {
|
||||
const run = JSON.parse(fs.readFileSync(file, "utf8")) as PersistedRunSummary;
|
||||
if (!run.flowId || !run.status) continue;
|
||||
const current = result.get(run.flowId);
|
||||
const time = run.finishedAt || run.startedAt || "";
|
||||
const currentTime = current?.finishedAt || current?.startedAt || "";
|
||||
if (!current || time > currentTime) result.set(run.flowId, run);
|
||||
} catch {
|
||||
// 损坏的单次运行记录不应阻塞任务列表
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function createFlow(name = "未命名流程"): FlowDefinition {
|
||||
const now = new Date().toISOString();
|
||||
const id = `flow_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
@ -44,11 +74,13 @@ export function createFlow(name = "未命名流程"): FlowDefinition {
|
||||
export function listFlows(): FlowSummary[] {
|
||||
const base = dir();
|
||||
const rows: FlowSummary[] = [];
|
||||
const runs = latestRuns();
|
||||
for (const entry of fs.readdirSync(base)) {
|
||||
if (!entry.endsWith(".json")) continue;
|
||||
try {
|
||||
const flow = JSON.parse(fs.readFileSync(path.join(base, entry), "utf8")) as FlowDefinition;
|
||||
if (!flow?.id || !Array.isArray(flow.nodes)) continue;
|
||||
const lastRun = runs.get(flow.id);
|
||||
rows.push({
|
||||
id: flow.id,
|
||||
name: flow.name || "未命名流程",
|
||||
@ -56,7 +88,10 @@ export function listFlows(): FlowSummary[] {
|
||||
nodeCount: flow.nodes.length,
|
||||
enabled: flow.enabled !== false,
|
||||
triggerLabel: flow.trigger?.label || "手动运行",
|
||||
lastRunStatus: "never"
|
||||
lastRunAt: lastRun?.finishedAt || lastRun?.startedAt,
|
||||
lastRunStatus: lastRun?.status || "never",
|
||||
lastRunDurationMs: lastRun?.durationMs,
|
||||
lastRunError: typeof lastRun?.error === "string" ? lastRun.error : lastRun?.error?.reason
|
||||
});
|
||||
} catch {
|
||||
// 忽略损坏文件,避免一个坏流程阻塞列表
|
||||
|
||||
@ -47,13 +47,13 @@ export function startRun(flow: FlowDefinition, sender: WebContents): { runId: st
|
||||
log,
|
||||
componentOf: (node) => components.get(node.componentId)
|
||||
});
|
||||
fs.writeFileSync(path.join(root, "run.json"), JSON.stringify(result, null, 2), "utf8");
|
||||
fs.writeFileSync(path.join(root, "run.json"), JSON.stringify({ ...result, flowId: flow.id }, null, 2), "utf8");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
emit({ type: "run.finished", status: "failed", durationMs: 0, error: { code: "unknown", reason: message } });
|
||||
fs.writeFileSync(
|
||||
path.join(root, "run.json"),
|
||||
JSON.stringify({ runId, status: "failed", error: message }, null, 2),
|
||||
JSON.stringify({ runId, flowId: flow.id, status: "failed", error: { reason: message }, finishedAt: new Date().toISOString(), durationMs: 0 }, null, 2),
|
||||
"utf8"
|
||||
);
|
||||
} finally {
|
||||
|
||||
@ -240,7 +240,7 @@ export function App() {
|
||||
/>
|
||||
)}
|
||||
{page === "editor" && <EditorPage catalog={catalog} onToast={showToast} onBack={() => { setPage("tasks"); void refreshFlows(); }} />}
|
||||
{page === "runs" && <RunsPage onBack={() => setPage("tasks")} />}
|
||||
{page === "runs" && <RunsPage onBack={() => { setPage("tasks"); void refreshFlows(); }} />}
|
||||
{page === "settings" && <SettingsPage catalog={catalog} />}
|
||||
</main>
|
||||
|
||||
|
||||
@ -35,6 +35,14 @@ function formatTime(value: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
function formatDuration(value?: number): string {
|
||||
if (value === undefined) return "";
|
||||
if (value < 1000) return `${value} ms`;
|
||||
return `${(value / 1000).toFixed(value < 10000 ? 1 : 0)} s`;
|
||||
}
|
||||
|
||||
const RUN_LABEL = { succeeded: "成功", failed: "失败", cancelled: "已取消", never: "未运行" } as const;
|
||||
|
||||
export function TasksPage({ flows, onCreate, onOpenEditor, onRun, onDelete, onRefresh }: TasksPageProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [selectedId, setSelectedId] = useState<string | null>(flows[0]?.id || null);
|
||||
@ -99,7 +107,7 @@ export function TasksPage({ flows, onCreate, onOpenEditor, onRun, onDelete, onRe
|
||||
<span>任务</span>
|
||||
<span>状态</span>
|
||||
<span>触发方式</span>
|
||||
<span>最近修改</span>
|
||||
<span>最近运行</span>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
@ -123,7 +131,10 @@ export function TasksPage({ flows, onCreate, onOpenEditor, onRun, onDelete, onRe
|
||||
<span className="status-dot" />{flow.enabled === false ? "未启用" : "已启用"}
|
||||
</span>
|
||||
<span className="task-trigger"><CalendarClock className="icon" />{flow.triggerLabel || "手动运行"}</span>
|
||||
<span className="task-updated"><Clock3 className="icon" />{formatTime(flow.updatedAt)}</span>
|
||||
<span className={`task-updated run-result ${flow.lastRunStatus || "never"}`} title={flow.lastRunError || ""}>
|
||||
<Clock3 className="icon" />
|
||||
<span>{flow.lastRunAt ? formatTime(flow.lastRunAt) : "尚未运行"}{flow.lastRunDurationMs !== undefined ? ` · ${formatDuration(flow.lastRunDurationMs)}` : ""}</span>
|
||||
</span>
|
||||
<span className="task-row-actions">
|
||||
<button type="button" className="icon-btn" title="运行任务" onClick={(event) => { event.stopPropagation(); onRun(flow.id); }}>
|
||||
<CirclePlay className="icon" />
|
||||
@ -158,7 +169,9 @@ export function TasksPage({ flows, onCreate, onOpenEditor, onRun, onDelete, onRe
|
||||
<dl className="overview-meta">
|
||||
<div><dt>触发方式</dt><dd><CalendarClock className="icon" />{selected.triggerLabel || "手动运行"}</dd></div>
|
||||
<div><dt>流程节点</dt><dd>{selected.nodeCount} 个</dd></div>
|
||||
<div><dt>最近运行</dt><dd>{selected.lastRunAt ? formatTime(selected.lastRunAt) : "尚未运行"}</dd></div>
|
||||
<div><dt>最近运行</dt><dd className={`overview-run ${selected.lastRunStatus || "never"}`}>{RUN_LABEL[selected.lastRunStatus || "never"]} · {selected.lastRunAt ? formatTime(selected.lastRunAt) : "尚未运行"}</dd></div>
|
||||
<div><dt>运行耗时</dt><dd>{selected.lastRunDurationMs === undefined ? "—" : formatDuration(selected.lastRunDurationMs)}</dd></div>
|
||||
{selected.lastRunError && <div><dt>失败原因</dt><dd className="overview-error" title={selected.lastRunError}>{selected.lastRunError}</dd></div>}
|
||||
<div><dt>最近修改</dt><dd>{formatTime(selected.updatedAt)}</dd></div>
|
||||
</dl>
|
||||
<div className="overview-note"><Zap className="icon" /><span>任务打开后先查看概览;只有点击“编辑流程”才进入画布。</span></div>
|
||||
|
||||
@ -2378,6 +2378,29 @@ textarea:focus-visible {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.run-result.failed,
|
||||
.overview-run.failed,
|
||||
.overview-error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.run-result.succeeded,
|
||||
.overview-run.succeeded {
|
||||
color: var(--success-deep);
|
||||
}
|
||||
|
||||
.run-result.cancelled,
|
||||
.overview-run.cancelled {
|
||||
color: var(--warn-deep);
|
||||
}
|
||||
|
||||
.overview-error {
|
||||
max-width: 210px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-status {
|
||||
font-size: 10.5px;
|
||||
white-space: nowrap;
|
||||
|
||||
@ -154,6 +154,8 @@ export interface FlowSummary {
|
||||
triggerLabel?: string;
|
||||
lastRunAt?: string;
|
||||
lastRunStatus?: "succeeded" | "failed" | "cancelled" | "never";
|
||||
lastRunDurationMs?: number;
|
||||
lastRunError?: string;
|
||||
}
|
||||
|
||||
export interface AppInfo {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user