osdy-pi 1.11.2 → 1.11.3
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.
- package/README.md +58 -7
- package/extensions/osdy-pi/account-profiles.test.ts +17 -0
- package/extensions/osdy-pi/account-profiles.ts +82 -22
- package/extensions/osdy-pi/control-center-account.test.ts +241 -0
- package/extensions/osdy-pi/control-center-account.ts +141 -0
- package/extensions/osdy-pi/control-center-agents.test.ts +107 -0
- package/extensions/osdy-pi/control-center-agents.ts +60 -0
- package/extensions/osdy-pi/control-center-git.test.ts +48 -0
- package/extensions/osdy-pi/control-center-git.ts +40 -0
- package/extensions/osdy-pi/control-center-preferences.test.ts +48 -0
- package/extensions/osdy-pi/control-center-preferences.ts +57 -0
- package/extensions/osdy-pi/control-center-sounds.test.ts +120 -0
- package/extensions/osdy-pi/control-center-sounds.ts +87 -0
- package/extensions/osdy-pi/control-center-todo.test.ts +68 -0
- package/extensions/osdy-pi/control-center-todo.ts +48 -0
- package/extensions/osdy-pi/control-center-usage.test.ts +90 -0
- package/extensions/osdy-pi/control-center-usage.ts +69 -0
- package/extensions/osdy-pi/control-center.test.ts +625 -0
- package/extensions/osdy-pi/control-center.ts +501 -0
- package/extensions/osdy-pi/profile-codex-usage.test.ts +218 -0
- package/extensions/osdy-pi/profile-codex-usage.ts +69 -0
- package/extensions/osdy-pi/runtime-helpers.ts +40 -13
- package/extensions/osdy-pi/runtime.test.ts +879 -3
- package/extensions/osdy-pi/runtime.ts +161 -26
- package/extensions/osdy-pi/types.ts +6 -0
- package/extensions/osdy-pi/ui.test.ts +182 -2
- package/extensions/osdy-pi/ui.ts +55 -51
- package/extensions/osdy-pi/usage-analytics.test.ts +12 -2
- package/extensions/osdy-pi/usage-analytics.ts +2 -1
- package/package.json +2 -2
- package/scripts/osdy-pi-account-profiles.mjs +40 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { createAccountManagementDependencies, isProfileName, readActiveProfileName, readStoredProfileCodexCredential, sharedAgentDir, switchAccountInPlace, type AccountContext, type AccountManagementDependencies } from "./account-profiles.js";
|
|
2
|
+
import type { ControlCenterService, ControlCenterRow, ControlCenterAccountAction } from "./control-center.js";
|
|
3
|
+
import { requestProfileCodexUsage, type ProfileCodexUsageResult } from "./profile-codex-usage.js";
|
|
4
|
+
|
|
5
|
+
export interface ControlCenterAccountService extends ControlCenterService<ControlCenterAccountAction> {
|
|
6
|
+
cancelUsage?(): void;
|
|
7
|
+
isCurrent?(): boolean;
|
|
8
|
+
}
|
|
9
|
+
function localTime(value: number | undefined): string {
|
|
10
|
+
return value !== undefined && Number.isFinite(value) && !Number.isNaN(new Date(value).getTime())
|
|
11
|
+
? new Date(value).toLocaleString() : "unknown";
|
|
12
|
+
}
|
|
13
|
+
function localResetTime(unixSeconds: number | undefined): string {
|
|
14
|
+
return localTime(unixSeconds === undefined ? undefined : unixSeconds * 1000);
|
|
15
|
+
}
|
|
16
|
+
function usageDetails(profile: string, result: ProfileCodexUsageResult): string[] {
|
|
17
|
+
const lines = [`Profile: ${profile}`, `Checked: ${localTime(result.checkedAt)} (local time)`];
|
|
18
|
+
if (result.status !== "ready") return [...lines, result.status === "cancelled" ? "Query cancelled." :
|
|
19
|
+
result.reason === "remote-usage-unavailable" ? "Remote Codex usage unavailable (credentials may be rejected)." : "Stored credentials unavailable (missing, expired or unsupported)."];
|
|
20
|
+
for (const bucket of result.quotaSnapshot.buckets) {
|
|
21
|
+
if (!bucket.primary && !bucket.secondary) lines.push(`${bucket.label ?? bucket.id}: quota and next reset unknown.`);
|
|
22
|
+
for (const [label, window] of [["Session", bucket.primary], ["Weekly", bucket.secondary]] as const) {
|
|
23
|
+
if (window) lines.push(`${bucket.label ?? bucket.id} / ${label}: ${100 - window.usedPercent}% remaining · Next reset: ${localResetTime(window.resetsAt)}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (lines.length === 2) lines.push("Quota and next reset: unknown (no quota windows returned).");
|
|
27
|
+
return lines;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ControlCenterAccountBackend {
|
|
31
|
+
profiles(): Promise<unknown>;
|
|
32
|
+
active(): string | undefined;
|
|
33
|
+
defaultProfile(): Promise<unknown>;
|
|
34
|
+
switch(profile: string): Promise<boolean>;
|
|
35
|
+
setDefault(profile: string | undefined): Promise<boolean>;
|
|
36
|
+
usage?(profile: string, signal: AbortSignal): Promise<ProfileCodexUsageResult>;
|
|
37
|
+
isCurrent?(): boolean;
|
|
38
|
+
}
|
|
39
|
+
function names(value: unknown): string[] {
|
|
40
|
+
return Array.isArray(value) ? value.filter((name: unknown): name is string => typeof name === "string" && isProfileName(name)) : [];
|
|
41
|
+
}
|
|
42
|
+
function safeName(value: unknown): string | undefined {
|
|
43
|
+
return typeof value === "string" && isProfileName(value) ? value : undefined;
|
|
44
|
+
}
|
|
45
|
+
type DefaultMetadata = { kind: "profile"; name: string } | { kind: "unset" | "unavailable" };
|
|
46
|
+
function defaultMetadata(value: unknown): DefaultMetadata {
|
|
47
|
+
if (typeof value !== "object" || value === null || !("code" in value) || value.code !== 0 || !("stdout" in value) || typeof value.stdout !== "string") return { kind: "unavailable" };
|
|
48
|
+
const output = value.stdout.trim();
|
|
49
|
+
if (output === "No default account.") return { kind: "unset" };
|
|
50
|
+
const name = safeName(output);
|
|
51
|
+
return name ? { kind: "profile", name } : { kind: "unavailable" };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Metadata-only projection. Never interpolate backend stdout/stderr/errors. */
|
|
55
|
+
export function createControlCenterAccount(backend: ControlCenterAccountBackend): ControlCenterAccountService {
|
|
56
|
+
let busy = false;
|
|
57
|
+
let controller: AbortController | undefined;
|
|
58
|
+
let preview: { profile: string; details: string[] } | undefined;
|
|
59
|
+
const current = () => backend.isCurrent?.() ?? true;
|
|
60
|
+
const cancelUsage = () => { controller?.abort(); controller = undefined; preview = undefined; };
|
|
61
|
+
return {
|
|
62
|
+
cancelUsage,
|
|
63
|
+
isCurrent: current,
|
|
64
|
+
async read() {
|
|
65
|
+
let profiles: string[];
|
|
66
|
+
try { profiles = names(await backend.profiles()); }
|
|
67
|
+
catch { return { summary: "Account profiles unavailable", note: "Reopen to retry. Login remains Pi-owned (/login).", rows: [] }; }
|
|
68
|
+
const active = safeName(backend.active());
|
|
69
|
+
let metadata: DefaultMetadata = { kind: "unavailable" };
|
|
70
|
+
try { metadata = defaultMetadata(await backend.defaultProfile()); } catch { /* Fixed metadata state only. */ }
|
|
71
|
+
const defaultProfile = metadata.kind === "profile" ? metadata.name : undefined;
|
|
72
|
+
const rows: ControlCenterRow[] = [];
|
|
73
|
+
for (const profile of profiles) {
|
|
74
|
+
rows.push({ label: `View usage: ${profile}`, current: false, action: { kind: "account-usage", profile },
|
|
75
|
+
details: preview?.profile === profile && (backend.isCurrent?.() ?? true) ? preview.details : [`Profile: ${profile}`, "Explicit live Codex query using this profile's stored credentials snapshot; no automatic refresh.", "Missing or expired snapshots are unavailable. Local token history is not quota."] });
|
|
76
|
+
const current = profile.toLowerCase() === active?.toLowerCase();
|
|
77
|
+
if (!current) rows.push({ label: `Switch to ${profile}`, current: false, action: { kind: "account-switch", profile }, details: ["Switch in place after Pi is idle; next request uses this account."] });
|
|
78
|
+
rows.push({ label: `Default: ${profile}`, current: profile.toLowerCase() === defaultProfile?.toLowerCase(), action: { kind: "account-default", profile }, details: [`Profile: ${profile}${current ? " (active)" : ""}`, "Default selects the account for future launcher startup."] });
|
|
79
|
+
}
|
|
80
|
+
rows.push({ label: "Clear default", current: false, action: { kind: "account-default", profile: undefined } });
|
|
81
|
+
return { summary: `Current: ${active ?? "unmanaged"} | Default: ${defaultProfile ?? metadata.kind} | Profiles: ${profiles.length}`,
|
|
82
|
+
note: "View usage queries Codex only; other actions require confirmation. Login remains Pi-owned (/login).", rows };
|
|
83
|
+
},
|
|
84
|
+
async apply(action) {
|
|
85
|
+
if (action.kind === "account-usage") {
|
|
86
|
+
if (busy || !current() || !backend.usage || !isProfileName(action.profile)) return { failed: true, message: "Profile usage unavailable." };
|
|
87
|
+
cancelUsage();
|
|
88
|
+
const request = new AbortController();
|
|
89
|
+
controller = request;
|
|
90
|
+
try {
|
|
91
|
+
if (!names(await backend.profiles()).includes(action.profile) || request.signal.aborted || !current()) return { failed: true, message: "Profile usage unavailable." };
|
|
92
|
+
const result = await backend.usage(action.profile, request.signal);
|
|
93
|
+
if (controller !== request || request.signal.aborted || !current()) return { failed: true, message: "Query cancelled." };
|
|
94
|
+
preview = { profile: action.profile, details: usageDetails(action.profile, result) };
|
|
95
|
+
return { failed: result.status !== "ready", message: result.status === "ready" ? "Stored-profile Codex usage checked." : "Profile usage unavailable." };
|
|
96
|
+
} catch {
|
|
97
|
+
if (controller === request && !request.signal.aborted && current()) preview = { profile: action.profile, details: [`Profile: ${action.profile}`, "Checked: unknown", "Profile usage unavailable."] };
|
|
98
|
+
return { failed: true, message: "Profile usage unavailable." };
|
|
99
|
+
} finally { if (controller === request) controller = undefined; }
|
|
100
|
+
}
|
|
101
|
+
if (busy || controller || !current() || (action.kind !== "account-switch" && action.kind !== "account-default")) return { failed: true, message: "Account action unavailable." };
|
|
102
|
+
busy = true;
|
|
103
|
+
try {
|
|
104
|
+
const profile = action.profile;
|
|
105
|
+
if (profile !== undefined && (!isProfileName(profile) || !names(await backend.profiles()).includes(profile))) return { failed: true, message: "Profile no longer available. Reopen Account." };
|
|
106
|
+
if (!current()) return { failed: true, message: "Account action unavailable." };
|
|
107
|
+
if (action.kind === "account-switch" && action.profile.toLowerCase() === safeName(backend.active())?.toLowerCase()) return { failed: false, message: "Account already active." };
|
|
108
|
+
const success = action.kind === "account-switch" ? await backend.switch(action.profile) : await backend.setDefault(profile);
|
|
109
|
+
if (!success) return { failed: true, message: "Account operation failed; change not confirmed." };
|
|
110
|
+
const message = action.kind === "account-switch"
|
|
111
|
+
? "Account switched; next request uses it."
|
|
112
|
+
: "Default account saved.";
|
|
113
|
+
return { failed: false, message };
|
|
114
|
+
} catch { return { failed: true, message: "Account operation failed; change not confirmed." }; }
|
|
115
|
+
finally { busy = false; }
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function bindControlCenterAccount(ctx: AccountContext, refreshUsage: () => Promise<void>, requestRender: () => void,
|
|
121
|
+
backend: AccountManagementDependencies = createAccountManagementDependencies({ refreshUsage, requestRender }),
|
|
122
|
+
options: { isCurrent?: () => boolean; usage?: ControlCenterAccountBackend["usage"] } = {},
|
|
123
|
+
): ControlCenterAccountService {
|
|
124
|
+
return createControlCenterAccount({
|
|
125
|
+
isCurrent: options.isCurrent ?? (() => true),
|
|
126
|
+
usage: options.usage ?? ((profile, signal) => requestProfileCodexUsage(profile, {
|
|
127
|
+
readCredentials: async name => readStoredProfileCodexCredential(await sharedAgentDir(process.env), name), signal,
|
|
128
|
+
})),
|
|
129
|
+
profiles: () => backend.profiles(), active: readActiveProfileName,
|
|
130
|
+
defaultProfile: () => backend.run(["account", "default"]),
|
|
131
|
+
switch: profile => backend.activate ? switchAccountInPlace(ctx, profile, async name => {
|
|
132
|
+
if (!(options.isCurrent?.() ?? true) || !ctx.isIdle()) throw new Error("Account action unavailable.");
|
|
133
|
+
await backend.activate!(name);
|
|
134
|
+
}, refreshUsage, requestRender) : Promise.resolve(false),
|
|
135
|
+
setDefault: async profile => {
|
|
136
|
+
if (!ctx.isIdle()) await ctx.waitForIdle();
|
|
137
|
+
if (!(options.isCurrent?.() ?? true) || !ctx.isIdle()) return false;
|
|
138
|
+
return (await backend.run(["account", "default", profile ?? "--clear"])).code === 0;
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { registerHooks } from "node:module";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
registerHooks({ resolve(specifier, context, next) {
|
|
9
|
+
if (specifier.startsWith(".") && specifier.endsWith(".js") && context.parentURL?.endsWith(".ts")) {
|
|
10
|
+
const url = new URL(`${specifier.slice(0, -3)}.ts`, context.parentURL);
|
|
11
|
+
if (existsSync(fileURLToPath(url))) return { shortCircuit: true, url: url.href };
|
|
12
|
+
}
|
|
13
|
+
return next(specifier, context);
|
|
14
|
+
} });
|
|
15
|
+
const { createControlCenterAgents } = await import("./control-center-agents.js");
|
|
16
|
+
|
|
17
|
+
function fixture() {
|
|
18
|
+
const home = mkdtempSync(join(tmpdir(), "osdy-cc08-"));
|
|
19
|
+
const agentDir = join(home, ".pi", "agent");
|
|
20
|
+
mkdirSync(agentDir, { recursive: true });
|
|
21
|
+
const path = join(agentDir, "settings.json");
|
|
22
|
+
writeFileSync(path, JSON.stringify({ packages: [{ source: "npm:gentle-pi", extensions: ["!unrelated.ts"], themes: [] }, "npm:unrelated"], unrelated: true }));
|
|
23
|
+
let installs = 0;
|
|
24
|
+
const options = { home, agentDir, cwd: home, env: {}, isIdle: () => true, install: () => {
|
|
25
|
+
installs++;
|
|
26
|
+
const settings = JSON.parse(readFileSync(path, "utf8")) as { packages: unknown[] };
|
|
27
|
+
settings.packages.push("npm:pi-subagents-j0k3r");
|
|
28
|
+
writeFileSync(path, JSON.stringify(settings));
|
|
29
|
+
return Promise.resolve();
|
|
30
|
+
} };
|
|
31
|
+
return { options, path, installs: () => installs };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
void test("Agents read is inspection-only and describes the confirmed installation and real target", async () => {
|
|
35
|
+
const f = fixture(); const before = readFileSync(f.path, "utf8");
|
|
36
|
+
const service = createControlCenterAgents(f.options);
|
|
37
|
+
assert.equal(f.installs(), 0);
|
|
38
|
+
const view = await service.read(); await service.read();
|
|
39
|
+
assert.match(view.summary, /gentle.*Joker.*not installed/i);
|
|
40
|
+
assert.equal(view.rows.length, 2);
|
|
41
|
+
const details = view.rows[0]!.details!.join(" ");
|
|
42
|
+
assert.ok(details.includes(f.path));
|
|
43
|
+
assert.match(details, /Install npm:pi-subagents-j0k3r if absent/);
|
|
44
|
+
assert.match(details, /-extensions\/gentle-agents.ts/);
|
|
45
|
+
assert.match(details, /unrelated.*preserved/i);
|
|
46
|
+
assert.match(details, /reload.*restart/i);
|
|
47
|
+
assert.equal(f.installs(), 0); assert.equal(readFileSync(f.path, "utf8"), before);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
void test("confirmed Agents selection delegates Joker installation and owned Gentle/Joker filters", async () => {
|
|
51
|
+
const f = fixture(); const service = createControlCenterAgents(f.options);
|
|
52
|
+
assert.equal((await service.apply({ kind: "agents-provider", mode: "joker" })).kind, "reload");
|
|
53
|
+
assert.equal(f.installs(), 1);
|
|
54
|
+
assert.match(readFileSync(f.path, "utf8"), /-extensions\/gentle-agents.ts/);
|
|
55
|
+
assert.match((await service.read()).summary, /joker/i);
|
|
56
|
+
assert.equal((await service.apply({ kind: "agents-provider", mode: "gentle" })).kind, "reload");
|
|
57
|
+
const saved = JSON.parse(readFileSync(f.path, "utf8")) as { packages: unknown[]; unrelated: boolean; osdyPiJokerExclusionOwned: boolean };
|
|
58
|
+
assert.deepEqual(saved.packages[0], { source: "npm:gentle-pi", extensions: ["!unrelated.ts"], themes: [] });
|
|
59
|
+
assert.equal(saved.packages[1], "npm:unrelated");
|
|
60
|
+
assert.deepEqual(saved.packages[2], { source: "npm:pi-subagents-j0k3r", extensions: ["-./index.ts"] });
|
|
61
|
+
assert.equal(saved.unrelated, true); assert.equal(saved.osdyPiJokerExclusionOwned, true);
|
|
62
|
+
assert.equal(f.installs(), 1);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
void test("isolated Agents owner guard blocks rows, installs and writes even for a personal target", async () => {
|
|
66
|
+
for (const override of [true, false]) {
|
|
67
|
+
const f = fixture(); const before = readFileSync(f.path, "utf8");
|
|
68
|
+
const service = createControlCenterAgents({ ...f.options,
|
|
69
|
+
...(override ? { env: { PI_CODING_AGENT_DIR: f.options.agentDir } } : { agentDir: join(f.options.home, "isolated") }) });
|
|
70
|
+
const view = await service.read();
|
|
71
|
+
assert.deepEqual(view.rows, []); assert.match(view.note, /normal personal Pi.*isolated|override/i);
|
|
72
|
+
const result = await service.apply({ kind: "agents-provider", mode: "joker" });
|
|
73
|
+
assert.equal(result.kind, "rejected"); assert.equal(f.installs(), 0);
|
|
74
|
+
assert.equal(readFileSync(f.path, "utf8"), before);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
void test("Agents service guards idle, reentrancy and stale owner responses without reload", async () => {
|
|
79
|
+
const f = fixture(); let idle = false; let current = true; let writes = 0; let finish = () => {};
|
|
80
|
+
const service = createControlCenterAgents({ ...f.options, isIdle: () => idle, isCurrent: () => current,
|
|
81
|
+
setup: () => { writes++; return new Promise(resolve => { finish = () => resolve({ installed: false, changed: true, gentleCount: 1 }); }); } });
|
|
82
|
+
const action = { kind: "agents-provider", mode: "joker" } as const;
|
|
83
|
+
assert.equal((await service.apply(action)).kind, "rejected"); assert.equal(writes, 0);
|
|
84
|
+
idle = true; const pending = service.apply(action);
|
|
85
|
+
assert.equal((await service.apply(action)).kind, "rejected"); assert.equal(writes, 1);
|
|
86
|
+
current = false; finish(); assert.equal((await pending).kind, "rejected");
|
|
87
|
+
assert.equal((await service.apply(action)).kind, "rejected"); assert.equal(writes, 1);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
void test("Agents omits ineligible Gentle and ignores status from an old session", async () => {
|
|
91
|
+
const f = fixture(); writeFileSync(f.path, JSON.stringify({ packages: ["npm:pi-subagents-j0k3r"] }));
|
|
92
|
+
const service = createControlCenterAgents(f.options);
|
|
93
|
+
assert.deepEqual((await service.read()).rows.map(row => row.label), ["Joker agents"]);
|
|
94
|
+
const stale = createControlCenterAgents({ ...f.options, isCurrent: () => false });
|
|
95
|
+
assert.deepEqual((await stale.read()).rows, []);
|
|
96
|
+
const result = await service.apply({ kind: "agents-provider", mode: "gentle" });
|
|
97
|
+
assert.equal(result.kind, "rejected"); assert.match(result.message, /eligible personal Gentle/);
|
|
98
|
+
assert.equal(f.installs(), 0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
void test("Agents owner errors retain honest failure and never request reload", async () => {
|
|
102
|
+
const f = fixture();
|
|
103
|
+
const service = createControlCenterAgents({ ...f.options, setup: () => Promise.reject(new Error("synthetic failure")) });
|
|
104
|
+
const result = await service.apply({ kind: "agents-provider", mode: "joker" });
|
|
105
|
+
assert.equal(result.kind, "rejected"); assert.match(result.message, /synthetic failure.*Reload not requested/);
|
|
106
|
+
assert.equal(f.installs(), 0);
|
|
107
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { inspectJokerAgents, setupJokerAgents, switchAgentMode } from "./agent-coexistence-setup.js";
|
|
3
|
+
import type { ControlCenterDetail, ProviderApplyResult } from "./control-center.js";
|
|
4
|
+
|
|
5
|
+
export type ControlCenterAgentsAction = { kind: "agents-provider"; mode: "joker" | "gentle" };
|
|
6
|
+
export interface ControlCenterAgentsService {
|
|
7
|
+
read(): Promise<ControlCenterDetail>;
|
|
8
|
+
apply(action: ControlCenterAgentsAction): Promise<ProviderApplyResult>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** The coexistence owner validates normal personal Pi and owns every install/filter write. */
|
|
12
|
+
export function createControlCenterAgents(options: Parameters<typeof setupJokerAgents>[0] & {
|
|
13
|
+
isIdle: () => boolean;
|
|
14
|
+
isCurrent?: () => boolean;
|
|
15
|
+
inspect?: typeof inspectJokerAgents;
|
|
16
|
+
setup?: typeof setupJokerAgents;
|
|
17
|
+
switchMode?: typeof switchAgentMode;
|
|
18
|
+
}): ControlCenterAgentsService {
|
|
19
|
+
let busy = false;
|
|
20
|
+
const current = () => options.isCurrent?.() !== false;
|
|
21
|
+
const target = join(options.agentDir, "settings.json");
|
|
22
|
+
return {
|
|
23
|
+
async read() {
|
|
24
|
+
try {
|
|
25
|
+
const status = await (options.inspect ?? inspectJokerAgents)(options);
|
|
26
|
+
if (!current()) return { summary: "Agents status unavailable", note: "Session changed; reopen the panel.", rows: [] };
|
|
27
|
+
return {
|
|
28
|
+
summary: `Agent mode: ${status.mode} | Joker: ${status.jokerInstalled ? "installed" : "not installed"} | Gentle entries: ${status.gentleCount}`,
|
|
29
|
+
note: "Configured provider, not proof of loaded tools. Opening this category never installs or switches agents.",
|
|
30
|
+
rows: (["joker", "gentle"] as const).filter(mode => mode === "joker" || status.gentleCount > 0).map(mode => ({
|
|
31
|
+
label: mode === "joker" ? "Joker agents" : "Gentle agents", current: status.mode === mode,
|
|
32
|
+
action: { kind: "agents-provider", mode },
|
|
33
|
+
details: [`Target: ${target}`, mode === "joker"
|
|
34
|
+
? "Select Joker: Install npm:pi-subagents-j0k3r if absent; enable ./index.ts and exclude only -extensions/gentle-agents.ts from eligible Gentle entries."
|
|
35
|
+
: "Select Gentle: enable extensions/gentle-agents.ts; exclude Joker ./index.ts with the owner's -./index.ts filter. No installation.",
|
|
36
|
+
"Only owner-managed agent filters change; unrelated resources and installed packages are preserved.",
|
|
37
|
+
"Successful save closes this panel before resource reload; restart Pi if reload fails."],
|
|
38
|
+
})),
|
|
39
|
+
};
|
|
40
|
+
} catch (error) {
|
|
41
|
+
return { summary: "Agents status unavailable", note: error instanceof Error ? error.message : "Inspection failed; reopen to retry.", rows: [] };
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
async apply(action) {
|
|
45
|
+
if (busy || !current() || !options.isIdle())
|
|
46
|
+
return { kind: "rejected", message: "Agents selection requires the current idle session; nothing changed." };
|
|
47
|
+
busy = true;
|
|
48
|
+
try {
|
|
49
|
+
if (action.mode === "joker") await (options.setup ?? setupJokerAgents)(options);
|
|
50
|
+
else await (options.switchMode ?? switchAgentMode)({ ...options, mode: "off" });
|
|
51
|
+
if (!current()) return { kind: "rejected", message: "Session changed after the owner operation; reload not requested. Reopen status in the current session." };
|
|
52
|
+
return { kind: "reload", message: "Agents selection saved. Reloading resources; restart Pi if reload fails." };
|
|
53
|
+
} catch (error) {
|
|
54
|
+
return { kind: "rejected", message: `Agents selection failed: ${error instanceof Error ? error.message : "unknown error"}. Reload not requested.` };
|
|
55
|
+
} finally {
|
|
56
|
+
busy = false;
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { registerHooks } from "node:module";
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
registerHooks({ resolve(specifier, context, nextResolve) {
|
|
7
|
+
if (specifier.startsWith(".") && specifier.endsWith(".js") && context.parentURL?.endsWith(".ts")) {
|
|
8
|
+
const url = new URL(`${specifier.slice(0, -3)}.ts`, context.parentURL);
|
|
9
|
+
if (existsSync(fileURLToPath(url))) return { shortCircuit: true, url: url.href };
|
|
10
|
+
}
|
|
11
|
+
return nextResolve(specifier, context);
|
|
12
|
+
} });
|
|
13
|
+
const { createControlCenterGit } = await import("./control-center-git.js");
|
|
14
|
+
void test("Git reads branch/status only and delegates persisted enabled changes to its shared owner", async () => {
|
|
15
|
+
let enabled = true;
|
|
16
|
+
const calls: string[][] = [];
|
|
17
|
+
const applied: boolean[] = [];
|
|
18
|
+
const service = createControlCenterGit({
|
|
19
|
+
snapshot: () => ({ enabled, placement: "aboveEditor" }),
|
|
20
|
+
exec: (args) => {
|
|
21
|
+
calls.push(args);
|
|
22
|
+
if (args.includes("branch")) return Promise.resolve("feature\n");
|
|
23
|
+
if (args.includes("status")) return Promise.resolve(" M file.ts\n?? new.ts\n");
|
|
24
|
+
throw new Error("Unexpected Git inspection command");
|
|
25
|
+
},
|
|
26
|
+
applyEnabled: (value) => { enabled = value; applied.push(value); return Promise.resolve(true); },
|
|
27
|
+
});
|
|
28
|
+
const view = await service.read();
|
|
29
|
+
assert.match(view.summary, /Branch: feature.*2 changed/);
|
|
30
|
+
assert.match(view.note, /aboveEditor.*session.*read-only/i);
|
|
31
|
+
assert.deepEqual(calls, [
|
|
32
|
+
["--no-optional-locks", "branch", "--show-current"],
|
|
33
|
+
["--no-optional-locks", "status", "--short", "--untracked-files=normal"],
|
|
34
|
+
]);
|
|
35
|
+
assert.deepEqual(applied, []);
|
|
36
|
+
assert.equal((await service.apply({ kind: "git-enabled", value: false })).failed, false);
|
|
37
|
+
assert.deepEqual(applied, [false]);
|
|
38
|
+
assert.equal((await service.read()).rows[1]?.current, true);
|
|
39
|
+
});
|
|
40
|
+
void test("Git unavailable and failed persistence are honest, without new workflows", async () => {
|
|
41
|
+
const service = createControlCenterGit({ snapshot: () => ({ enabled: false, placement: "belowEditor" }),
|
|
42
|
+
exec: () => Promise.reject(new Error("not a repository")), applyEnabled: () => Promise.resolve(false) });
|
|
43
|
+
assert.match((await service.read()).summary, /unavailable/);
|
|
44
|
+
assert.match((await service.apply({ kind: "git-enabled", value: true })).message, /Applied live.*could not be saved/);
|
|
45
|
+
const detached = createControlCenterGit({ snapshot: () => ({ enabled: true, placement: "aboveEditor" }),
|
|
46
|
+
exec: () => Promise.resolve(""), applyEnabled: () => Promise.resolve(true) });
|
|
47
|
+
assert.match((await detached.read()).summary, /detached.*clean/);
|
|
48
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ControlCenterService } from "./control-center.js";
|
|
2
|
+
import type { WorkingTreePlacement } from "./types.js";
|
|
3
|
+
|
|
4
|
+
interface GitDependencies {
|
|
5
|
+
snapshot(): { enabled: boolean; placement: WorkingTreePlacement };
|
|
6
|
+
exec(args: string[]): Promise<string>;
|
|
7
|
+
applyEnabled(value: boolean): Promise<boolean>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Read-only Git inspection; the runtime retains ownership of widget persistence. */
|
|
11
|
+
export function createControlCenterGit(dependencies: GitDependencies): ControlCenterService {
|
|
12
|
+
return {
|
|
13
|
+
async read() {
|
|
14
|
+
let summary: string;
|
|
15
|
+
try {
|
|
16
|
+
const [branch, status] = await Promise.all([
|
|
17
|
+
dependencies.exec(["--no-optional-locks", "branch", "--show-current"]),
|
|
18
|
+
dependencies.exec(["--no-optional-locks", "status", "--short", "--untracked-files=normal"]),
|
|
19
|
+
]);
|
|
20
|
+
const changed = status.split("\n").filter((line) => line.trim()).length;
|
|
21
|
+
summary = `Branch: ${branch.trim() || "detached HEAD"} | ${changed ? `${changed} changed entries` : "working tree clean"}`;
|
|
22
|
+
} catch {
|
|
23
|
+
summary = "Git branch/worktree unavailable (not a repository or inspection failed).";
|
|
24
|
+
}
|
|
25
|
+
const state = dependencies.snapshot();
|
|
26
|
+
return {
|
|
27
|
+
summary,
|
|
28
|
+
note: `Position: ${state.placement} (session detail, read-only). Inspection never writes Git.`,
|
|
29
|
+
rows: [true, false].map((value) => ({ label: `Working-tree widget: ${value ? "enabled" : "disabled"}`,
|
|
30
|
+
current: state.enabled === value, action: { kind: "git-enabled" as const, value } })),
|
|
31
|
+
};
|
|
32
|
+
},
|
|
33
|
+
async apply(action) {
|
|
34
|
+
if (action.kind !== "git-enabled") return { failed: true, message: "Not a Git preference action" };
|
|
35
|
+
const saved = await dependencies.applyEnabled(action.value);
|
|
36
|
+
return { failed: !saved, message: saved ? "Saved globally: working-tree widget"
|
|
37
|
+
: "Applied live: working-tree widget, but could not be saved. Retry to persist." };
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { registerHooks } from "node:module";
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import type { VisualPreferenceSnapshot } from "./control-center-preferences.js";
|
|
7
|
+
|
|
8
|
+
registerHooks({
|
|
9
|
+
resolve(specifier, context, nextResolve) {
|
|
10
|
+
if (specifier.startsWith(".") && specifier.endsWith(".js") && context.parentURL?.endsWith(".ts")) {
|
|
11
|
+
const url = new URL(`${specifier.slice(0, -3)}.ts`, context.parentURL);
|
|
12
|
+
if (existsSync(fileURLToPath(url))) return { shortCircuit: true, url: url.href };
|
|
13
|
+
}
|
|
14
|
+
return nextResolve(specifier, context);
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
const { preferenceDetail } = await import("./control-center-preferences.js");
|
|
18
|
+
const snapshot: VisualPreferenceSnapshot = { enabled: true, headerVariant: "osdy-theme", mascot: "current",
|
|
19
|
+
editorMode: "auto", editorEffective: true, smallMode: false };
|
|
20
|
+
|
|
21
|
+
void test("preference rows expose every catalog value and exactly one current indicator", () => {
|
|
22
|
+
for (const [category, field, values] of [
|
|
23
|
+
["Header", "headerVariant", ["osdy-theme", "neon"]],
|
|
24
|
+
["Mascot", "mascot", ["current", "bts"]],
|
|
25
|
+
["Editor", "editorMode", ["auto", "extended", "simple"]],
|
|
26
|
+
] as const) {
|
|
27
|
+
for (const value of values) {
|
|
28
|
+
const detail = preferenceDetail(category, { ...snapshot, [field]: value });
|
|
29
|
+
assert.ok(detail);
|
|
30
|
+
assert.deepEqual(detail.rows.map((row) => "value" in row.action ? row.action.value : undefined), values);
|
|
31
|
+
assert.equal(detail.rows.filter((row) => row.current).length, 1);
|
|
32
|
+
assert.equal(detail.rows.find((row) => row.current)?.action.kind, category.toLowerCase());
|
|
33
|
+
assert.match(detail.summary, new RegExp(`Current: ${value === "bts" ? "Bts" : value}`));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
void test("effective editor and disabled visuals describe live runtime, not a derived rendering policy", () => {
|
|
39
|
+
assert.match(preferenceDetail("Editor", snapshot)?.summary ?? "", /Effective: extended/);
|
|
40
|
+
const disabled = { ...snapshot, enabled: false, editorEffective: false };
|
|
41
|
+
assert.match(preferenceDetail("Editor", disabled)?.summary ?? "", /Osdy disabled.*Effective: simple\/native/);
|
|
42
|
+
assert.match(preferenceDetail("Header", disabled)?.note ?? "", /apply when Osdy is enabled/);
|
|
43
|
+
assert.match(preferenceDetail("Mascot", { ...snapshot, smallMode: true })?.summary ?? "", /Current: current/);
|
|
44
|
+
assert.equal(preferenceDetail("Git", snapshot), undefined);
|
|
45
|
+
assert.equal(preferenceDetail("Sounds", snapshot), undefined);
|
|
46
|
+
assert.equal(preferenceDetail("Account", snapshot), undefined);
|
|
47
|
+
assert.equal(preferenceDetail("Usage", snapshot), undefined);
|
|
48
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { EDITOR_MODES, HEADER_VARIANT_CHOICES, MASCOT_CHOICES } from "./types.js";
|
|
2
|
+
import type { EditorMode, HeaderVariant, MascotChoice, OsdyState } from "./types.js";
|
|
3
|
+
import type { ControlCenterCategory, ControlCenterRow } from "./control-center.js";
|
|
4
|
+
|
|
5
|
+
export type VisualPreferenceAction =
|
|
6
|
+
| { kind: "header"; value: HeaderVariant }
|
|
7
|
+
| { kind: "mascot"; value: MascotChoice }
|
|
8
|
+
| { kind: "editor"; value: EditorMode };
|
|
9
|
+
|
|
10
|
+
export type VisualPreferenceSnapshot = Pick<OsdyState,
|
|
11
|
+
"enabled" | "headerVariant" | "mascot" | "editorMode" | "editorEffective" | "smallMode">;
|
|
12
|
+
|
|
13
|
+
export interface ControlCenterPreferences {
|
|
14
|
+
snapshot(): VisualPreferenceSnapshot;
|
|
15
|
+
/** Applies live before awaiting the existing global store. False means save failed. */
|
|
16
|
+
apply(action: VisualPreferenceAction): Promise<boolean>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function visualPreferenceLabel(action: VisualPreferenceAction): string {
|
|
20
|
+
return action.kind === "mascot" && action.value === "bts" ? "Bts" : action.value;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Explicit view adaptation only; application and persistence belong to runtime. */
|
|
24
|
+
export function preferenceDetail(category: ControlCenterCategory, state: VisualPreferenceSnapshot): {
|
|
25
|
+
rows: ControlCenterRow[];
|
|
26
|
+
summary: string;
|
|
27
|
+
note: string;
|
|
28
|
+
} | undefined {
|
|
29
|
+
let actions: VisualPreferenceAction[];
|
|
30
|
+
let current: string;
|
|
31
|
+
let effective = state.enabled ? "" : " | Osdy disabled";
|
|
32
|
+
switch (category) {
|
|
33
|
+
case "Header":
|
|
34
|
+
actions = HEADER_VARIANT_CHOICES.map((value) => ({ kind: "header", value }));
|
|
35
|
+
current = state.headerVariant;
|
|
36
|
+
break;
|
|
37
|
+
case "Mascot":
|
|
38
|
+
actions = MASCOT_CHOICES.map((value) => ({ kind: "mascot", value }));
|
|
39
|
+
current = state.mascot;
|
|
40
|
+
break;
|
|
41
|
+
case "Editor":
|
|
42
|
+
actions = Object.values(EDITOR_MODES).map((value) => ({ kind: "editor", value }));
|
|
43
|
+
current = state.editorMode;
|
|
44
|
+
effective += ` | Effective: ${state.editorEffective ? "extended" : "simple/native"}`;
|
|
45
|
+
if (state.smallMode) effective += " (small terminal)";
|
|
46
|
+
break;
|
|
47
|
+
default: return undefined;
|
|
48
|
+
}
|
|
49
|
+
const currentAction = actions.find((action) => action.value === current);
|
|
50
|
+
return {
|
|
51
|
+
rows: actions.map((action) => ({ label: visualPreferenceLabel(action), current: action.value === current, action })),
|
|
52
|
+
summary: `Current: ${currentAction ? visualPreferenceLabel(currentAction) : current}${effective}`,
|
|
53
|
+
note: state.enabled
|
|
54
|
+
? "Applies live; saves globally in Osdy settings."
|
|
55
|
+
: "Saves globally; visuals apply when Osdy is enabled.",
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { existsSync, mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { registerHooks } from "node:module";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import type { GlobalAudioNotificationSettings } from "./audio-notification-types.js";
|
|
9
|
+
registerHooks({ resolve(specifier, context, nextResolve) {
|
|
10
|
+
if (specifier.startsWith(".") && specifier.endsWith(".js") && context.parentURL?.endsWith(".ts")) {
|
|
11
|
+
const url = new URL(`${specifier.slice(0, -3)}.ts`, context.parentURL);
|
|
12
|
+
if (existsSync(fileURLToPath(url))) return { shortCircuit: true, url: url.href };
|
|
13
|
+
}
|
|
14
|
+
return nextResolve(specifier, context);
|
|
15
|
+
} });
|
|
16
|
+
const { createControlCenterSounds } = await import("./control-center-sounds.js");
|
|
17
|
+
|
|
18
|
+
function fixture(platform: "darwin" | "unsupported" = "darwin") {
|
|
19
|
+
let settings: GlobalAudioNotificationSettings = { version: 1, sounds: { completion: "/saved.wav" } };
|
|
20
|
+
let saveFails = false;
|
|
21
|
+
let loadFails = false;
|
|
22
|
+
let playFails = false;
|
|
23
|
+
let failAfterSave = false;
|
|
24
|
+
const played: string[] = [];
|
|
25
|
+
const writes: GlobalAudioNotificationSettings[] = [];
|
|
26
|
+
const service = createControlCenterSounds({
|
|
27
|
+
cwd: process.cwd(),
|
|
28
|
+
store: { path: "/unused", load: () => {
|
|
29
|
+
if (loadFails) throw new Error("refresh failed");
|
|
30
|
+
return Promise.resolve(settings);
|
|
31
|
+
}, save: (value) => {
|
|
32
|
+
if (saveFails) throw new Error("disk full");
|
|
33
|
+
writes.push(value); settings = value;
|
|
34
|
+
if (failAfterSave) loadFails = true;
|
|
35
|
+
return Promise.resolve();
|
|
36
|
+
} },
|
|
37
|
+
effective: (_settings, event) => event === "completion"
|
|
38
|
+
? { source: "startup-flag", path: "/flag.wav" } : { source: "unconfigured" },
|
|
39
|
+
resolve: (event) => Promise.resolve({ ok: true, event, path: "/flag.wav", source: "startup-flag" }),
|
|
40
|
+
playback: { platform, play: (path) => {
|
|
41
|
+
if (playFails) throw new Error("player failed");
|
|
42
|
+
played.push(path);
|
|
43
|
+
return Promise.resolve();
|
|
44
|
+
} },
|
|
45
|
+
validate: (value) => Promise.resolve(value === "valid.wav"
|
|
46
|
+
? { ok: true, inputPath: value, resolvedPath: "/valid.wav", persistedPath: "/valid.wav" }
|
|
47
|
+
: { ok: false, reason: "missing" }),
|
|
48
|
+
});
|
|
49
|
+
return { service, played, writes, settings: () => settings,
|
|
50
|
+
failSave: () => { saveFails = true; }, failLoad: () => { loadFails = true; }, failPlay: () => { playFails = true; }, failRefresh: () => { failAfterSave = true; } };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
void test("Sounds shows saved/effective paths and flag precedence without playing or saving", async () => {
|
|
54
|
+
const f = fixture();
|
|
55
|
+
const view = await f.service.read();
|
|
56
|
+
assert.match(view.summary, /Master.*unavailable/);
|
|
57
|
+
assert.match(view.rows[0]?.details?.join(" | ") ?? "", /saved: \/saved.wav.*Effective \(startup-flag\): \/flag.wav/);
|
|
58
|
+
assert.match(view.note, /clearing.*does not disable/i);
|
|
59
|
+
assert.equal(view.rows.length, 12);
|
|
60
|
+
assert.deepEqual(f.played, []); assert.deepEqual(f.writes, []);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
void test("configure validates before saving and refreshes; clear keeps v1 and other paths", async () => {
|
|
64
|
+
const f = fixture();
|
|
65
|
+
assert.equal((await f.service.apply({ kind: "sound-set", event: "completion", path: "missing.wav" })).failed, true);
|
|
66
|
+
assert.equal(f.writes.length, 0);
|
|
67
|
+
assert.equal((await f.service.apply({ kind: "sound-set", event: "question", path: "valid.wav" })).failed, false);
|
|
68
|
+
assert.deepEqual(f.settings(), { version: 1, sounds: { completion: "/saved.wav", question: "/valid.wav" } });
|
|
69
|
+
const clear = await f.service.apply({ kind: "sound-clear", event: "completion" });
|
|
70
|
+
assert.equal(clear.failed, false);
|
|
71
|
+
assert.deepEqual(f.settings(), { version: 1, sounds: { question: "/valid.wav" } });
|
|
72
|
+
assert.match((await f.service.read()).rows[0]?.details?.join(" | ") ?? "", /saved: none.*Effective \(startup-flag\): \/flag.wav/);
|
|
73
|
+
assert.deepEqual(f.played, []);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
void test("test playback is explicit, reports unavailable platforms and player failures", async () => {
|
|
77
|
+
const f = fixture();
|
|
78
|
+
assert.equal((await f.service.apply({ kind: "sound-test", event: "completion" })).failed, false);
|
|
79
|
+
assert.deepEqual(f.played, ["/flag.wav"]); assert.deepEqual(f.writes, []);
|
|
80
|
+
f.failPlay();
|
|
81
|
+
assert.match((await f.service.apply({ kind: "sound-test", event: "completion" })).message, /player failed/);
|
|
82
|
+
const unsupported = fixture("unsupported");
|
|
83
|
+
assert.match((await unsupported.service.apply({ kind: "sound-test", event: "completion" })).message, /unavailable/);
|
|
84
|
+
assert.deepEqual(unsupported.played, []);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
void test("save errors and refresh errors never falsely report success", async () => {
|
|
88
|
+
const f = fixture(); f.failSave();
|
|
89
|
+
assert.match((await f.service.apply({ kind: "sound-clear", event: "completion" })).message, /Not saved.*disk full/);
|
|
90
|
+
const g = fixture();
|
|
91
|
+
g.failLoad();
|
|
92
|
+
assert.equal((await g.service.apply({ kind: "sound-clear", event: "completion" })).failed, true);
|
|
93
|
+
assert.deepEqual(g.writes, []);
|
|
94
|
+
const h = fixture(); h.failRefresh();
|
|
95
|
+
const result = await h.service.apply({ kind: "sound-clear", event: "completion" });
|
|
96
|
+
assert.equal(result.failed, true);
|
|
97
|
+
assert.match(result.message, /Saved globally, but runtime refresh failed/);
|
|
98
|
+
assert.deepEqual(h.writes, [{ version: 1, sounds: {} }]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
void test("default validator rejects missing, unsupported and directory paths without saving", async () => {
|
|
102
|
+
const root = mkdtempSync(join(tmpdir(), "osdy-sound-validation-"));
|
|
103
|
+
mkdirSync(join(root, "directory.wav"));
|
|
104
|
+
writeFileSync(join(root, "sound.wav"), "mock wave bytes");
|
|
105
|
+
const f = fixture();
|
|
106
|
+
const writes: GlobalAudioNotificationSettings[] = [];
|
|
107
|
+
const service = createControlCenterSounds({ cwd: root,
|
|
108
|
+
store: { path: "/unused", load: () => Promise.resolve(f.settings()), save: (value) => { writes.push(value); return Promise.resolve(); } },
|
|
109
|
+
effective: () => ({ source: "unconfigured" }),
|
|
110
|
+
resolve: (event) => Promise.resolve({ ok: false, event, source: "unconfigured", reason: "unconfigured" }),
|
|
111
|
+
playback: { platform: "unsupported", play: () => Promise.reject(new Error("must not play")) },
|
|
112
|
+
});
|
|
113
|
+
for (const path of ["README.md", "absent-file.wav", "directory.wav", ""]) {
|
|
114
|
+
const result = await service.apply({ kind: "sound-set", event: "error", path });
|
|
115
|
+
assert.equal(result.failed, true); assert.match(result.message, /Invalid/);
|
|
116
|
+
}
|
|
117
|
+
assert.equal(writes.length, 0);
|
|
118
|
+
assert.equal((await service.apply({ kind: "sound-set", event: "error", path: "sound.wav" })).failed, false);
|
|
119
|
+
assert.equal(writes[0]?.sounds.error, join(root, "sound.wav"));
|
|
120
|
+
});
|