@stringsync/splates 0.0.52 → 0.0.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/artifact.ts +120 -0
  2. package/budget.ts +117 -0
  3. package/checkout.ts +8 -0
  4. package/creds.test.ts +336 -0
  5. package/creds.ts +144 -0
  6. package/dev/dev.ts +86 -0
  7. package/dev/eval.ts +21 -0
  8. package/dev/fix.ts +63 -0
  9. package/dev/index.ts +1 -0
  10. package/dev/prove.ts +38 -0
  11. package/dev/root.ts +7 -0
  12. package/dev/ship.ts +46 -0
  13. package/dev/test.ts +154 -0
  14. package/e2e.test.ts +472 -0
  15. package/flags.ts +157 -0
  16. package/hook.ts +328 -0
  17. package/index.ts +18 -0
  18. package/infra.ts +107 -0
  19. package/lane.test.ts +29 -0
  20. package/lane.ts +172 -0
  21. package/notes.ts +19 -0
  22. package/package.json +14 -12
  23. package/projects.test.ts +191 -0
  24. package/projects.ts +37 -0
  25. package/queue.ts +82 -0
  26. package/screenshots/annotate.png +0 -0
  27. package/screenshots/demo.png +0 -0
  28. package/screenshots/parallel.png +0 -0
  29. package/screenshots/questions.png +0 -0
  30. package/server.ts +287 -0
  31. package/setup.ts +233 -0
  32. package/slots.ts +65 -0
  33. package/splates.test.ts +338 -0
  34. package/splates.ts +567 -0
  35. package/task.ts +354 -0
  36. package/tunnel.ts +106 -0
  37. package/wait-journal.test.ts +96 -0
  38. package/waiting.ts +59 -0
  39. package/worker-relay.test.ts +233 -0
  40. package/worker.ts +300 -0
  41. package/artifact.d.ts +0 -3
  42. package/budget.d.ts +0 -3
  43. package/creds.d.ts +0 -3
  44. package/flags.d.ts +0 -37
  45. package/hook.d.ts +0 -3
  46. package/index.d.ts +0 -2
  47. package/index.js +0 -1776
  48. package/infra.d.ts +0 -3
  49. package/lane.d.ts +0 -3
  50. package/notes.d.ts +0 -4
  51. package/projects.d.ts +0 -3
  52. package/queue.d.ts +0 -3
  53. package/server.d.ts +0 -7
  54. package/setup.d.ts +0 -22
  55. package/slots.d.ts +0 -3
  56. package/splates.d.ts +0 -10
  57. package/task.d.ts +0 -3
  58. package/tunnel.d.ts +0 -3
  59. package/waiting.d.ts +0 -3
  60. package/worker.d.ts +0 -3
package/artifact.ts ADDED
@@ -0,0 +1,120 @@
1
+ import { basename, resolve } from "node:path";
2
+ import {
3
+ addArtifact,
4
+ listArtifacts,
5
+ removeArtifact,
6
+ } from "@stringsync/splates-server/artifacts";
7
+ import { commaList } from "@stringsync/splates-server/comma-list";
8
+ import { conversationOf } from "@stringsync/splates-server/conversations";
9
+ import { fail } from "@stringsync/splates-server/exit";
10
+ import { repository } from "@stringsync/splates-server/records";
11
+ import type { SplatesDeps } from "@stringsync/splates-server/server";
12
+ import { cli } from "webappwiz/cmd";
13
+ import { z } from "zod";
14
+ import { here } from "./flags";
15
+
16
+ /** `splates artifact`, which the root cli mounts after `splates attach`. */
17
+ export const splatesArtifact = cli<SplatesDeps>("artifact").description(
18
+ "files your conversation made as its outputs, such as a screenshot, a recording or an HTML page: kept by name for the life of the conversation, whatever task it was made in, and listed on the `splates server` page beside its thread. A file that shows a question or a demo step is no artifact: `splates attach` it. The conversation is $SPLATES_CONVERSATION, or --conversation <id> from an agent outside `splates server`",
19
+ );
20
+
21
+ const conversation = [
22
+ "conversation",
23
+ z.string(),
24
+ {
25
+ default: "",
26
+ description:
27
+ "conversation id, when not run by an agent `splates server` started",
28
+ },
29
+ ] as const;
30
+
31
+ splatesArtifact
32
+ .command("add")
33
+ .use(repository({ needs: "own" }))
34
+ .description(
35
+ "keep files as artifacts of this conversation, each named for its basename, or by --name when there is one; adding a name the conversation holds makes its next version, earlier ones kept until cleanup takes them. Past the project's limit (artifactProjectCapacity), `splates server` first evicts the oldest bytes the project keeps: removed and replaced versions, then what archived conversations show, then what live ones do. Past the file or conversation limit (artifactFileCapacity, artifactConversationCapacity), or the project's when even that would not make room, it stops with `too_large`, changing nothing for that file",
36
+ )
37
+ .arg("files", z.string(), { description: "files to keep, comma separated" })
38
+ .option("name", z.string(), {
39
+ default: "",
40
+ description: "the artifact's name; for one file only",
41
+ })
42
+ .option(...conversation)
43
+ .action(async (opts, ctx) => {
44
+ const paths = commaList(opts.files);
45
+ if (paths.length === 0) {
46
+ fail("usage", "name the files to keep: nothing was changed");
47
+ }
48
+ if (opts.name !== "" && paths.length > 1) {
49
+ fail("usage", "--name names one file: nothing was changed");
50
+ }
51
+ const id = await conversationOf(ctx.service, opts.conversation);
52
+ const files = await Promise.all(
53
+ paths.map(async (given) => {
54
+ const path = resolve(ctx.ps.cwd(), given);
55
+ const bytes = await ctx.fs.readBytes(path).catch(() => null);
56
+ if (bytes === null) {
57
+ fail("usage", `cannot read ${path}: nothing was changed`, {
58
+ file: path,
59
+ });
60
+ }
61
+ return { name: opts.name || basename(path), bytes };
62
+ }),
63
+ );
64
+ const task = await here(ctx);
65
+ for (const file of files) {
66
+ await ctx.journal.record(
67
+ "artifact add",
68
+ task,
69
+ () => addArtifact(ctx, id, file),
70
+ (added) => ({
71
+ name: file.name,
72
+ version: added.version,
73
+ bytes: added.bytes,
74
+ }),
75
+ );
76
+ }
77
+ });
78
+
79
+ splatesArtifact
80
+ .command("remove")
81
+ .use(repository({ needs: "own" }))
82
+ .description(
83
+ "stop listing the artifact of that name, every version kept until cleanup takes them; adding the name again lists it again",
84
+ )
85
+ .arg("name", z.string(), { description: "the artifact's name" })
86
+ .option(...conversation)
87
+ .action(async (opts, ctx) => {
88
+ const id = await conversationOf(ctx.service, opts.conversation);
89
+ await ctx.journal.record(
90
+ "artifact remove",
91
+ await here(ctx),
92
+ () => removeArtifact(ctx, id, opts.name),
93
+ (version) => ({ name: opts.name, version }),
94
+ );
95
+ });
96
+
97
+ splatesArtifact
98
+ .command("list")
99
+ .use(repository({ needs: "own" }))
100
+ .description(
101
+ "the artifacts this conversation lists, and their current version",
102
+ )
103
+ .option(...conversation)
104
+ .action(async (opts, ctx) =>
105
+ listArtifacts(ctx, await conversationOf(ctx.service, opts.conversation)),
106
+ );
107
+
108
+ splatesArtifact
109
+ .command("show")
110
+ .use(repository({ needs: "own" }))
111
+ .description("every version of an artifact, and which is current")
112
+ .arg("name", z.string(), { description: "the artifact's name" })
113
+ .option(...conversation)
114
+ .action(async (opts, ctx) =>
115
+ listArtifacts(
116
+ ctx,
117
+ await conversationOf(ctx.service, opts.conversation),
118
+ opts.name,
119
+ ),
120
+ );
package/budget.ts ADDED
@@ -0,0 +1,117 @@
1
+ import type { SplatesDeps } from "@stringsync/splates-server/server";
2
+ import {
3
+ endBudgetRaise,
4
+ raiseBudget,
5
+ setBudget,
6
+ showBudget,
7
+ } from "@stringsync/splates-server/workers";
8
+ import { cli } from "webappwiz/cmd";
9
+ import { z } from "zod";
10
+
11
+ const DEV = {
12
+ default: "",
13
+ description:
14
+ "the `splates server` to ask, a URL such as http://localhost:4310; by default the one an agent runs under, from SPLATES_DEV",
15
+ };
16
+
17
+ const JSON_OPTION = { default: false, description: "emit JSON" };
18
+
19
+ const LIMIT = z.enum(["hour", "day", "month"]);
20
+
21
+ const LIMIT_ARG = {
22
+ description:
23
+ "hour, the rate cap on what made workers running at once cost an hour; day; or month",
24
+ };
25
+
26
+ /** `splates budget`, which the root cli mounts beside `splates worker`. */
27
+ export const splatesBudget = cli<SplatesDeps>("budget").description(
28
+ "what the workers a `workerProvider` makes may cost, in dollars: a rate cap per hour on those running at once ($0.25/h by default), and limits on a day ($5) and a month ($75). Past the rate cap, or once a day's or month's limit is spent, no worker is made and the queue says why; reaching the day's or month's stops made workers, whose agents stop at their checkpoint. Each warns at 80%, 90% and 100% by Web Push and on the page's header. Spend is an estimate at each type's list price. Agents' own API spend is not counted here",
29
+ );
30
+
31
+ splatesBudget
32
+ .command("show")
33
+ .description(
34
+ "show each limit: what made workers spent in its window, of how much, and what is left",
35
+ )
36
+ .option("dev", z.string(), DEV)
37
+ .option("json", z.boolean(), JSON_OPTION)
38
+ .action((opts, ctx) =>
39
+ showBudget({
40
+ dev: opts.dev,
41
+ json: opts.json,
42
+ log: ctx.log,
43
+ fs: ctx.fs,
44
+ ps: ctx.ps,
45
+ send: ctx.fetch,
46
+ }),
47
+ );
48
+
49
+ splatesBudget
50
+ .command("set")
51
+ .description(
52
+ "set a limit, in dollars (dollars an hour for hour), then show the budget",
53
+ )
54
+ .arg("limit", LIMIT, LIMIT_ARG)
55
+ .arg("dollars", z.string(), {
56
+ description: "like 5 or 0.25, or `default` to give it back to its default",
57
+ })
58
+ .option("dev", z.string(), DEV)
59
+ .option("json", z.boolean(), JSON_OPTION)
60
+ .action((opts, ctx) =>
61
+ setBudget({
62
+ limit: opts.limit,
63
+ dollars: opts.dollars,
64
+ dev: opts.dev,
65
+ json: opts.json,
66
+ log: ctx.log,
67
+ fs: ctx.fs,
68
+ ps: ctx.ps,
69
+ send: ctx.fetch,
70
+ }),
71
+ );
72
+
73
+ splatesBudget
74
+ .command("raise")
75
+ .description(
76
+ "add dollars to a limit for a while, on top of what it is set to, then show the budget",
77
+ )
78
+ .arg("limit", LIMIT, LIMIT_ARG)
79
+ .arg("dollars", z.string(), { description: "how much to add, like 2 or 0.1" })
80
+ .option("for", z.string(), {
81
+ default: "1d",
82
+ description:
83
+ "how long it lasts, in hours, days or weeks, like 24h, 1d or 2w",
84
+ })
85
+ .option("dev", z.string(), DEV)
86
+ .option("json", z.boolean(), JSON_OPTION)
87
+ .action((opts, ctx) =>
88
+ raiseBudget({
89
+ limit: opts.limit,
90
+ dollars: opts.dollars,
91
+ for: opts.for,
92
+ dev: opts.dev,
93
+ json: opts.json,
94
+ log: ctx.log,
95
+ fs: ctx.fs,
96
+ ps: ctx.ps,
97
+ send: ctx.fetch,
98
+ }),
99
+ );
100
+
101
+ splatesBudget
102
+ .command("end")
103
+ .description("end a raise now, by the number `show` gives it")
104
+ .arg("id", z.coerce.number().int(), { description: "the raise's number" })
105
+ .option("dev", z.string(), DEV)
106
+ .option("json", z.boolean(), JSON_OPTION)
107
+ .action((opts, ctx) =>
108
+ endBudgetRaise({
109
+ id: opts.id,
110
+ dev: opts.dev,
111
+ json: opts.json,
112
+ log: ctx.log,
113
+ fs: ctx.fs,
114
+ ps: ctx.ps,
115
+ send: ctx.fetch,
116
+ }),
117
+ );
package/checkout.ts ADDED
@@ -0,0 +1,8 @@
1
+ // What `bin/splates` runs from a checkout: the published program with `dev`
2
+ // mounted, the commands for working on this repository. No `bin` or `exports`
3
+ // entry names this file, so a release bundles none of it.
4
+ import { mountDev } from "./dev";
5
+ import { splates } from "./splates";
6
+
7
+ mountDev(splates);
8
+ await import("./index");
package/creds.test.ts ADDED
@@ -0,0 +1,336 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import { Testing } from "@stringsync/splates-server/testing";
3
+ import { Duration, sleep } from "webappwiz/time";
4
+ import { splates } from "./splates";
5
+
6
+ const WHY = "To read the build logs through the dashboard's API.";
7
+ const HOW = "Open the dashboard, create a token, and paste it here.";
8
+
9
+ /** Long enough that a test only reaches it when waiting is genuinely stuck. */
10
+ const PATIENT = { timeout: Duration.secs(5), poll: Duration.ms(5) };
11
+
12
+ describe("creds", () => {
13
+ let deps: Testing;
14
+
15
+ beforeEach(async () => {
16
+ deps = await Testing.open();
17
+ });
18
+
19
+ afterEach(() => deps.disposeAsync());
20
+
21
+ it("records a request, with what for and how to get it, for the page to show", async () => {
22
+ await deps.creds.request("API_TOKEN", {
23
+ scope: "device",
24
+ purpose: WHY,
25
+ instructions: HOW,
26
+ task: "alpha",
27
+ });
28
+
29
+ expect(await deps.creds.requests()).toMatchObject([
30
+ {
31
+ name: "API_TOKEN",
32
+ scope: "device",
33
+ purpose: WHY,
34
+ instructions: HOW,
35
+ task: "alpha",
36
+ status: "open",
37
+ },
38
+ ]);
39
+ });
40
+
41
+ it("asks nothing for a cred already kept, unless told the kept one fails", async () => {
42
+ await deps.projectStore.set("API_TOKEN", "old");
43
+ const ask = {
44
+ scope: "project" as const,
45
+ purpose: WHY,
46
+ instructions: HOW,
47
+ task: null,
48
+ };
49
+
50
+ expect(await deps.creds.request("API_TOKEN", ask)).toBeNull();
51
+ expect(
52
+ await deps.creds.request("API_TOKEN", { ...ask, replace: true }),
53
+ ).toMatchObject({ status: "open" });
54
+ });
55
+
56
+ it("replaces with the last request's purpose and instructions when left off", async () => {
57
+ const ask = {
58
+ scope: "project" as const,
59
+ purpose: WHY,
60
+ instructions: HOW,
61
+ task: null,
62
+ };
63
+ await deps.creds.request("API_TOKEN", ask);
64
+ await deps.projectStore.set("API_TOKEN", "old");
65
+
66
+ expect(
67
+ await deps.creds.request("API_TOKEN", {
68
+ scope: "project",
69
+ purpose: "",
70
+ instructions: "",
71
+ task: null,
72
+ replace: true,
73
+ }),
74
+ ).toMatchObject({ purpose: WHY, instructions: HOW, status: "open" });
75
+ });
76
+
77
+ it("refuses a request that says nothing of what it is for", async () => {
78
+ await expect(
79
+ deps.creds.request("API_TOKEN", {
80
+ scope: "project",
81
+ purpose: " ",
82
+ instructions: HOW,
83
+ task: null,
84
+ }),
85
+ ).toBail("usage");
86
+ });
87
+
88
+ it("refuses a request that says nothing of how to get it", async () => {
89
+ await expect(
90
+ deps.creds.request("API_TOKEN", {
91
+ scope: "project",
92
+ purpose: WHY,
93
+ instructions: " ",
94
+ task: null,
95
+ }),
96
+ ).toBail("usage");
97
+ });
98
+
99
+ it("refuses a name no environment variable could have", async () => {
100
+ await expect(
101
+ deps.creds.request("api-token", {
102
+ scope: "project",
103
+ purpose: WHY,
104
+ instructions: HOW,
105
+ task: null,
106
+ }),
107
+ ).toBail("usage");
108
+ });
109
+
110
+ it("keeps a value in the store its request asked for, and fills it", async () => {
111
+ await deps.creds.request("API_TOKEN", {
112
+ scope: "device",
113
+ purpose: WHY,
114
+ instructions: HOW,
115
+ task: null,
116
+ });
117
+
118
+ expect(await deps.creds.save("API_TOKEN", " secret \n")).toBe("device");
119
+
120
+ expect(await deps.deviceStore.get("API_TOKEN")).toBe("secret");
121
+ expect(await deps.projectStore.get("API_TOKEN")).toBeUndefined();
122
+ expect((await deps.creds.requests())[0]?.status).toBe("saved");
123
+ expect(await deps.creds.credentials.require("API_TOKEN")).toBe("secret");
124
+ });
125
+
126
+ it("never writes a value into its record", async () => {
127
+ await deps.creds.request("API_TOKEN", {
128
+ scope: "project",
129
+ purpose: WHY,
130
+ instructions: HOW,
131
+ task: null,
132
+ });
133
+ await deps.creds.save("API_TOKEN", "hunter2", { by: "a tool" });
134
+
135
+ expect(await deps.fs.read(`${deps.splatesDir}/creds.json`)).not.toContain(
136
+ "hunter2",
137
+ );
138
+ });
139
+
140
+ it("ends a wait once the value is saved", async () => {
141
+ await deps.creds.request("API_TOKEN", {
142
+ scope: "project",
143
+ purpose: WHY,
144
+ instructions: HOW,
145
+ task: null,
146
+ });
147
+
148
+ const waiting = deps.creds.wait("API_TOKEN", PATIENT);
149
+ await sleep(Duration.ms(20));
150
+ await deps.creds.save("API_TOKEN", "secret");
151
+
152
+ expect(await waiting).toBe("saved");
153
+ });
154
+
155
+ it("ends a wait once the request is dismissed", async () => {
156
+ await deps.creds.request("API_TOKEN", {
157
+ scope: "project",
158
+ purpose: WHY,
159
+ instructions: HOW,
160
+ task: null,
161
+ });
162
+ await deps.creds.dismiss("API_TOKEN");
163
+
164
+ expect(await deps.creds.wait("API_TOKEN", PATIENT)).toBe("dismissed");
165
+ });
166
+
167
+ it("gives up waiting on a request nobody fills", async () => {
168
+ await deps.creds.request("API_TOKEN", {
169
+ scope: "project",
170
+ purpose: WHY,
171
+ instructions: HOW,
172
+ task: null,
173
+ });
174
+
175
+ await expect(
176
+ deps.creds.wait("API_TOKEN", {
177
+ timeout: Duration.ms(20),
178
+ poll: Duration.ms(5),
179
+ }),
180
+ ).toBail("timeout");
181
+ });
182
+
183
+ it("refuses to wait on a cred nothing requested", async () => {
184
+ await expect(deps.creds.wait("API_TOKEN", PATIENT)).toBail("not_found");
185
+ });
186
+
187
+ it("lists what each store keeps, and what a tool saved itself", async () => {
188
+ await deps.projectStore.set("DATABASE_URL", "x");
189
+ await deps.creds.save("TUNNEL_TOKEN", "y", {
190
+ scope: "device",
191
+ by: "splates tunnel setup",
192
+ });
193
+
194
+ expect(await deps.creds.kept()).toEqual([
195
+ {
196
+ name: "DATABASE_URL",
197
+ scope: "project",
198
+ provider: "system",
199
+ savedBy: null,
200
+ },
201
+ {
202
+ name: "TUNNEL_TOKEN",
203
+ scope: "device",
204
+ provider: "system",
205
+ savedBy: "splates tunnel setup",
206
+ },
207
+ ]);
208
+ });
209
+
210
+ it("removes a kept cred from its store", async () => {
211
+ await deps.creds.save("TUNNEL_TOKEN", "y", { scope: "device", by: "t" });
212
+
213
+ expect(await deps.creds.remove("TUNNEL_TOKEN", "device")).toBe(true);
214
+ expect(await deps.creds.kept()).toEqual([]);
215
+ });
216
+
217
+ it("says the system keeps no secrets when its store cannot be reached", async () => {
218
+ deps.projectStore.names = () => Promise.reject(new Error("no daemon"));
219
+
220
+ await expect(deps.creds.kept()).toBail("no_secret_store", {
221
+ message: "secret service",
222
+ });
223
+ });
224
+ });
225
+
226
+ // scry-ignore simple-test-setup: one describe per command, as the file groups the CLI's cases
227
+ describe("splates creds", () => {
228
+ let env: Testing;
229
+ let deps: Parameters<typeof splates.run>[0];
230
+
231
+ beforeEach(async () => {
232
+ env = await Testing.open();
233
+ env.ps.cd(env.root);
234
+ deps = {
235
+ log: env.log,
236
+ fs: env.fs,
237
+ ps: env.ps,
238
+ assets: env.assets,
239
+ secrets: env.secrets,
240
+ };
241
+ });
242
+
243
+ afterEach(() => env.disposeAsync());
244
+
245
+ it("requests a cred, then waits until a person saves it", async () => {
246
+ await splates.run(deps, [
247
+ "creds",
248
+ "request",
249
+ "API_TOKEN",
250
+ "--purpose",
251
+ WHY,
252
+ "--instructions",
253
+ HOW,
254
+ "--device",
255
+ ]);
256
+ expect(env.out()).toContain(`requested API_TOKEN ${WHY}`);
257
+
258
+ const waiting = splates.run(deps, ["creds", "wait", "API_TOKEN"]);
259
+ await sleep(Duration.ms(20));
260
+ await env.creds.save("API_TOKEN", "secret");
261
+ await waiting;
262
+
263
+ expect(env.out()).toContain("saved API_TOKEN");
264
+ expect(await env.deviceStore.get("API_TOKEN")).toBe("secret");
265
+ });
266
+
267
+ it("exits dismissed when the person turns the request down", async () => {
268
+ await splates.run(deps, [
269
+ "creds",
270
+ "request",
271
+ "API_TOKEN",
272
+ "--purpose",
273
+ WHY,
274
+ "--instructions",
275
+ HOW,
276
+ ]);
277
+ await env.creds.dismiss("API_TOKEN");
278
+
279
+ await splates.run(deps, ["creds", "wait", "API_TOKEN"]);
280
+
281
+ expect(env.proc.exits).toEqual([22]);
282
+ });
283
+
284
+ it("lists requests and kept creds by name, never a value", async () => {
285
+ await env.projectStore.set("DATABASE_URL", "postgres://secret");
286
+ await splates.run(deps, [
287
+ "creds",
288
+ "request",
289
+ "API_TOKEN",
290
+ "--purpose",
291
+ WHY,
292
+ "--instructions",
293
+ HOW,
294
+ ]);
295
+
296
+ await splates.run(deps, ["creds", "list", "--json"]);
297
+
298
+ const listed = String(env.log.entries.at(-1)?.message);
299
+ expect(JSON.parse(listed)).toMatchObject({
300
+ requested: [{ name: "API_TOKEN", status: "open" }],
301
+ kept: [{ name: "DATABASE_URL", scope: "project" }],
302
+ });
303
+ expect(listed).not.toContain("postgres://secret");
304
+ });
305
+ });
306
+
307
+ // scry-ignore simple-test-setup: copyLegacy reads a store the other commands never touch
308
+ describe("copyLegacy", () => {
309
+ let deps: Testing;
310
+
311
+ beforeEach(async () => {
312
+ deps = await Testing.open();
313
+ });
314
+
315
+ afterEach(() => deps.disposeAsync());
316
+
317
+ it("keeps each of arbor's creds under splates's name too, in the same store", async () => {
318
+ await deps.deviceStore.set("ARBOR_CLOUDFLARE_API_TOKEN", "secret");
319
+
320
+ await deps.creds.copyLegacy();
321
+
322
+ expect(await deps.deviceStore.names()).toEqual([
323
+ "ARBOR_CLOUDFLARE_API_TOKEN",
324
+ "SPLATES_CLOUDFLARE_API_TOKEN",
325
+ ]);
326
+ });
327
+
328
+ it("leaves a cred splates already keeps", async () => {
329
+ await deps.deviceStore.set("ARBOR_TUNNEL_TOKEN", "old");
330
+ await deps.deviceStore.set("SPLATES_TUNNEL_TOKEN", "new");
331
+
332
+ await deps.creds.copyLegacy();
333
+
334
+ expect(await deps.deviceStore.get("SPLATES_TUNNEL_TOKEN")).toBe("new");
335
+ });
336
+ });