返回 DeepSeek-Reasonix
downloads.test.ts
根目录 / desktop / electron / src / main / browser / downloads.test.ts
1 import assert from "node:assert/strict";
2 import { test } from "node:test";
3 import type { BrowserDownloadView } from "../../shared/ipc.js";
4 import { DownloadTracker, type DownloadItemLike } from "./downloads.js";
5 import { silentLog } from "./fakeGuestViews.js";
6
7 // A fake clock: scheduled callbacks fire only when advance() runs them, so
8 // the wait logic is tested without real time passing.
9 function fakeClock() {
10 let now = 0;
11 const pending = new Map<number, { at: number; run: () => void }>();
12 let nextId = 1;
13 const setTimeoutFake = ((run: () => void, ms: number) => {
14 const id = nextId++;
15 pending.set(id, { at: now + ms, run });
16 return id;
17 }) as unknown as typeof setTimeout;
18 const clearTimeoutFake = ((id: number) => {
19 pending.delete(id);
20 }) as unknown as typeof clearTimeout;
21 return {
22 setTimeout: setTimeoutFake,
23 clearTimeout: clearTimeoutFake,
24 advance(ms: number): void {
25 now += ms;
26 for (const [id, timer] of [...pending]) {
27 if (timer.at > now) continue;
28 pending.delete(id);
29 timer.run();
30 }
31 },
32 get pendingCount(): number {
33 return pending.size;
34 },
35 };
36 }
37
38 class FakeDownloadItem implements DownloadItemLike {
39 savePath = "";
40 received = 0;
41 state = "progressing";
42 private readonly listeners = new Map<string, Array<(event: unknown, state: string) => void>>();
43
44 constructor(
45 readonly url: string,
46 readonly filename: string,
47 readonly total: number,
48 ) {}
49
50 getURL(): string {
51 return this.url;
52 }
53
54 getFilename(): string {
55 return this.filename;
56 }
57
58 getSavePath(): string {
59 return this.savePath;
60 }
61
62 setSavePath(path: string): void {
63 this.savePath = path;
64 }
65
66 getState(): "progressing" {
67 return "progressing";
68 }
69
70 getReceivedBytes(): number {
71 return this.received;
72 }
73
74 getTotalBytes(): number {
75 return this.total;
76 }
77
78 on(event: "updated" | "done", listener: (event: unknown, state: string) => void): void {
79 const list = this.listeners.get(event) ?? [];
80 list.push(listener);
81 this.listeners.set(event, list);
82 }
83
84 fire(event: "updated" | "done", state: string, received = this.received): void {
85 this.received = received;
86 for (const listener of this.listeners.get(event) ?? []) listener({}, state);
87 }
88 }
89
90 function setup(options: { existing?: string[] } = {}) {
91 const clock = fakeClock();
92 const updates: BrowserDownloadView[] = [];
93 const made: string[] = [];
94 const tracker = new DownloadTracker({
95 tabForWebContents: (id) => (id === 7 ? { id: "tab-1", taskId: "task-1" } : id === 8 ? { id: "tab-2", taskId: "task-2" } : undefined),
96 defaultDirectory: (taskId) => `/downloads/${taskId}`,
97 onUpdate: (download) => updates.push(download),
98 log: silentLog,
99 exists: (path) => (options.existing ?? []).includes(path),
100 mkdir: (path) => made.push(path),
101 setTimeout: clock.setTimeout,
102 clearTimeout: clock.clearTimeout,
103 });
104 return { clock, updates, made, tracker };
105 }
106
107 test("will-download routes the file into the task directory with a unique name", () => {
108 const { tracker, updates, made } = setup({ existing: ["/scratch/report.pdf", "/scratch/report-1.pdf"] });
109 tracker.setTaskDirectory("task-1", "/scratch");
110 const item = new FakeDownloadItem("https://a.test/report.pdf", "../report.pdf", 100);
111 tracker.handleWillDownload(item, 7);
112 assert.deepEqual(made, ["/scratch"]);
113 assert.equal(item.savePath, "/scratch/report-2.pdf");
114 assert.deepEqual(tracker.list("tab-1"), [
115 { id: "dl-1", tabId: "tab-1", url: "https://a.test/report.pdf", filename: "report-2.pdf", path: "/scratch/report-2.pdf", state: "progressing", received: 0, total: 100 },
116 ]);
117 assert.deepEqual(updates.at(-1), tracker.list("tab-1")[0]);
118
119 const unknown = new FakeDownloadItem("https://a.test/x", "x", 1);
120 tracker.handleWillDownload(unknown, 99);
121 assert.equal(unknown.savePath, "", "downloads from unknown webContents are dropped");
122
123 const other = new FakeDownloadItem("https://b.test/f.bin", "f.bin", 5);
124 tracker.handleWillDownload(other, 8);
125 assert.equal(other.savePath, "/downloads/task-2/f.bin", "tasks without a scratch directory use the shell default");
126 });
127
128 test("concurrent downloads reserve the same filename before either reaches disk", () => {
129 const { tracker } = setup();
130 const first = new FakeDownloadItem("https://a.test/one", "report.txt", 10);
131 const second = new FakeDownloadItem("https://a.test/two", "report.txt", 10);
132 tracker.setTaskDirectory("task-1", "/scratch");
133 tracker.handleWillDownload(first, 7);
134 tracker.handleWillDownload(second, 7);
135 assert.equal(first.savePath, "/scratch/report.txt");
136 assert.equal(second.savePath, "/scratch/report-1.txt");
137 });
138
139 test("progress and terminal states are tracked and non-progressing rows are forgotten", () => {
140 const { tracker } = setup();
141 const item = new FakeDownloadItem("https://a.test/f.zip", "f.zip", 10);
142 tracker.handleWillDownload(item, 7);
143 item.fire("updated", "progressing", 4);
144 assert.equal(tracker.list("tab-1")[0].received, 4);
145 item.fire("done", "completed", 10);
146 const done = tracker.list("tab-1")[0];
147 assert.equal(done.state, "completed");
148 assert.deepEqual(tracker.hostList("tab-1"), [{ id: done.id, url: done.url, path: done.path, state: "completed", bytes: 10 }]);
149
150 const live = new FakeDownloadItem("https://a.test/g.zip", "g.zip", 10);
151 tracker.handleWillDownload(live, 7);
152 tracker.forgetTab("tab-1");
153 assert.deepEqual(tracker.list("tab-1").map((d) => d.id), [live.savePath ? "dl-2" : ""], "only the still-progressing download survives");
154 });
155
156 test("wait resolves early when nothing is in progress", async () => {
157 const { tracker, clock } = setup();
158 assert.deepEqual(await tracker.wait("tab-1", 5000), []);
159 assert.equal(clock.pendingCount, 0, "no timer was scheduled");
160 const item = new FakeDownloadItem("https://a.test/f", "f", 1);
161 tracker.handleWillDownload(item, 7);
162 assert.deepEqual((await tracker.wait("tab-1", 0)).map((d) => d.state), ["progressing"], "a zero wait never blocks");
163 });
164
165 test("wait holds until the last download settles, then resolves without the timer", async () => {
166 const { tracker, clock } = setup();
167 const first = new FakeDownloadItem("https://a.test/a", "a", 1);
168 const second = new FakeDownloadItem("https://a.test/b", "b", 1);
169 tracker.handleWillDownload(first, 7);
170 tracker.handleWillDownload(second, 7);
171 let settled = false;
172 const waiting = tracker.wait("tab-1", 5000).then((downloads) => {
173 settled = true;
174 return downloads;
175 });
176 assert.equal(clock.pendingCount, 1);
177 first.fire("done", "cancelled", 0);
178 await Promise.resolve();
179 assert.equal(settled, false, "another download is still in progress");
180 second.fire("done", "completed", 1);
181 const result = await waiting;
182 assert.deepEqual(result.map((d) => d.state), ["cancelled", "completed"]);
183 assert.equal(clock.pendingCount, 0, "the expiry timer was cancelled");
184 });
185
186 test("wait expires on the fake clock when downloads never finish", async () => {
187 const { tracker, clock } = setup();
188 const item = new FakeDownloadItem("https://a.test/slow", "slow", 1);
189 tracker.handleWillDownload(item, 7);
190 let settled = false;
191 const waiting = tracker.wait("tab-1", 2000).then((downloads) => {
192 settled = true;
193 return downloads;
194 });
195 clock.advance(1999);
196 await Promise.resolve();
197 assert.equal(settled, false);
198 clock.advance(1);
199 const result = await waiting;
200 assert.deepEqual(result.map((d) => d.state), ["progressing"], "the caller gets the live view at expiry");
201 });
202
202 lines TYPESCRIPT