feat: refine language controls and add per-tier ChatGPT Fast mode
This commit is contained in:
@@ -170,6 +170,41 @@ test("case-insensitive Authorization overrides including null are refused before
|
||||
assert.equal(requests.length, before);
|
||||
});
|
||||
|
||||
test("Fast opt-in pins priority on the actual OAuth request without altering reasoning or accepting caller tier overrides", async () => {
|
||||
for (const fastMode of [undefined, false, true]) {
|
||||
requests = []; responseEvents = [complete()];
|
||||
const engine = piEngineForSpec({ provider: "chatgpt", id: model.id, thinkingLevel: "high", fastMode }, false);
|
||||
const result = await engine.streamFn(engine.model, context(), {
|
||||
samplingParams: { service_tier: "flex" },
|
||||
onPayload: (raw) => ({ ...raw as object, service_tier: "default" }),
|
||||
}).result();
|
||||
assert.equal(result.stopReason, "stop"); assert.equal(requests.length, 1);
|
||||
assert.equal(requests[0]!.body.service_tier, fastMode ? "priority" : undefined);
|
||||
assert.equal(requests[0]!.body.reasoning.effort, "high");
|
||||
assert.equal(requests[0]!.authorization, "Bearer scratch-oauth-only");
|
||||
}
|
||||
});
|
||||
|
||||
test("the Fast preference never injects priority into an API-key provider request", async () => {
|
||||
requests = []; responseEvents = [complete()];
|
||||
const engine = piEngineForSpec({ provider: "openai", id: model.id, fastMode: true }, false);
|
||||
const result = await engine.streamFn(engine.model, context(), { apiKey: "sk-offline-api-key-fixture" }).result();
|
||||
assert.equal(result.stopReason, "stop"); assert.equal(requests.length, 1);
|
||||
assert.equal(requests[0]!.body.service_tier, undefined);
|
||||
assert.equal(requests[0]!.authorization, "Bearer sk-offline-api-key-fixture");
|
||||
});
|
||||
|
||||
test("an unsupported priority tier gives actionable feedback without retrying or using an API key", async () => {
|
||||
requests = [];
|
||||
responseEvents = [{ type: "error", code: "unsupported_parameter", param: "service_tier", message: "private provider diagnostic" }];
|
||||
const engine = piEngineForSpec({ provider: "chatgpt", id: model.id, fastMode: true }, false);
|
||||
const result = await engine.streamFn(engine.model, context()).result();
|
||||
assert.equal(result.stopReason, "error"); assert.equal(requests.length, 1);
|
||||
assert(result.errorMessage?.includes("Turn off Fast mode"));
|
||||
assert(!result.errorMessage?.includes("private provider diagnostic"));
|
||||
assert.equal(requests[0]!.authorization, "Bearer scratch-oauth-only");
|
||||
});
|
||||
|
||||
test("full credential-storage invalidation aborts foreground and background controllers and clears capabilities", () => {
|
||||
const foreground = chatGPTAbortController(false), background = chatGPTAbortController(true);
|
||||
ipc({ channel: "lh:chatgpt", kind: "state", stamp: { epoch: 2, authorityRevision: 2, revision: 3 }, status: { ...status, available: false }, invalidate: "all" });
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import React, { act } from "react";
|
||||
(globalThis as Record<string, unknown>).React = React;
|
||||
import assert from "node:assert/strict";
|
||||
import { afterEach, it } from "node:test";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { AgentSection } from "../components/settings/agent-section.tsx";
|
||||
import { I18nProvider } from "../components/i18n.tsx";
|
||||
import type { ModelCatalogEntry, ModelsSettings } from "../src/core/index.ts";
|
||||
|
||||
const dom = parseHTML("<html><body></body></html>");
|
||||
Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element, HTMLElement: dom.window.HTMLElement, CustomEvent: dom.window.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true });
|
||||
const originalFetch = globalThis.fetch;
|
||||
const chatgpt = { provider: "chatgpt", id: "gpt-5" };
|
||||
const api = { provider: "openai", id: "gpt-5" };
|
||||
const fixture: ModelCatalogEntry = { ...chatgpt, name: "Offline model", reasoning: true, input: ["text", "image"], supportedLevels: ["low", "high"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 8192, maxTokens: 2048 };
|
||||
let root: Root | null = null;
|
||||
let host: HTMLDivElement;
|
||||
let writes: ModelsSettings[];
|
||||
let saved: ModelsSettings;
|
||||
let waitForSave: (() => Promise<void>) | undefined;
|
||||
let failSave = false;
|
||||
function button(slot: string, kind = "fast"): HTMLButtonElement {
|
||||
const found = document.querySelector<HTMLButtonElement>(`[data-testid="slot-${kind}-${slot}"]`);
|
||||
assert(found); return found;
|
||||
}
|
||||
async function click(target: HTMLButtonElement) { await act(async () => target.click()); }
|
||||
async function mount() {
|
||||
writes = []; waitForSave = undefined; failSave = false;
|
||||
saved = { strong: { model: chatgpt, thinkingLevel: "high" }, cheap: { model: chatgpt, thinkingLevel: "low" }, vision: { model: api } };
|
||||
const responses: Record<string, unknown> = {
|
||||
"/api/models": { models: [fixture, { ...fixture, provider: "openai" }], slots: saved },
|
||||
"/api/search": { available: false, engine: null, reason: "No search engine is configured." },
|
||||
"/api/keys": { keys: [] }, "/api/settings/prompts": { prober: [], teacher: [] },
|
||||
};
|
||||
globalThis.fetch = async input => { const name = String(input); assert(name in responses); return Response.json(responses[name]); };
|
||||
host = document.createElement("div"); document.body.appendChild(host); root = createRoot(host);
|
||||
function Harness() {
|
||||
const [models, setModels] = React.useState(saved);
|
||||
return <I18nProvider initialGeneral={{ language: "ru" }}><AgentSection agent={undefined} models={models} graph={undefined} compaction={undefined} onSectionChange={() => {}} onCompactionChange={() => {}} onModelsChange={async next => {
|
||||
writes.push(next); await waitForSave?.();
|
||||
if (failSave) throw new Error("offline save failure");
|
||||
saved = next; setModels(next);
|
||||
}} /></I18nProvider>;
|
||||
}
|
||||
await act(async () => { root!.render(<Harness />); });
|
||||
}
|
||||
afterEach(async () => { await act(async () => root?.unmount()); root = null; host?.remove(); globalThis.fetch = originalFetch; });
|
||||
|
||||
it("Fast controls wait for stored truth, lock related edits while saving, and preserve other tiers", async () => {
|
||||
await mount();
|
||||
assert.equal(button("strong").getAttribute("aria-pressed"), "false");
|
||||
assert.equal(button("vision").disabled, true);
|
||||
assert.equal(button("strong").textContent, "Быстро");
|
||||
let release!: () => void;
|
||||
const pending = new Promise<void>(resolve => { release = resolve; }); waitForSave = () => pending;
|
||||
await click(button("strong"));
|
||||
assert.equal(writes.length, 1);
|
||||
for (const slot of ["strong", "cheap"]) for (const kind of ["fast", "model", "thinking"]) assert.equal(button(slot, kind).disabled, true);
|
||||
assert.equal(button("strong").getAttribute("aria-pressed"), "false", "unconfirmed billing choices must not appear saved");
|
||||
await act(async () => { release(); }); waitForSave = undefined;
|
||||
assert.equal(button("strong").getAttribute("aria-pressed"), "true");
|
||||
await click(button("cheap"));
|
||||
assert.equal(saved.strong?.fastMode, true); assert.equal(saved.cheap?.fastMode, true);
|
||||
assert.equal(saved.strong?.thinkingLevel, "high"); assert.equal(saved.vision?.fastMode, undefined);
|
||||
});
|
||||
it("failed Fast saves retain the previous choice, show a Russian error, and allow retry", async () => {
|
||||
await mount(); failSave = true;
|
||||
await click(button("strong"));
|
||||
assert.equal(button("strong").getAttribute("aria-pressed"), "false"); assert.equal(button("strong").disabled, false);
|
||||
assert.equal(document.querySelector('[role="alert"]')?.textContent, "Не удалось сохранить настройки моделей");
|
||||
failSave = false; await click(button("strong"));
|
||||
assert.equal(button("strong").getAttribute("aria-pressed"), "true"); assert.equal(document.querySelector('[role="alert"]'), null);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { it } from "node:test";
|
||||
import { sanitizeUserSettings, type ModelsSettings, type UserSettings } from "../src/core/settings.ts";
|
||||
import { resolveConversationSpec, resolveModelSpec, resolveRoleSpec, resolveSettings } from "../src/core/settings-resolution.ts";
|
||||
import { parseModelsSettingsBody } from "../src/server/settings-body.ts";
|
||||
import { clampModelsLevels } from "../src/server/model-catalog.ts";
|
||||
import { composeSlotFast, composeSlotModel } from "../components/settings/agent-section.tsx";
|
||||
import { chatGPTPayload, chatGPTStreamError } from "../src/agent/chatgpt-stream.ts";
|
||||
import { translate } from "../src/core/i18n.ts";
|
||||
|
||||
const chatgpt = { provider: "chatgpt", id: "gpt-5" };
|
||||
const api = { provider: "openai", id: "gpt-5" };
|
||||
it("validates and saves explicit per-tier Fast choices, dropping invalid and unsupported saved flags", () => {
|
||||
const models = { strong: { model: chatgpt, thinkingLevel: "high", fastMode: true }, cheap: { model: chatgpt, fastMode: false }, vision: { model: api } };
|
||||
assert.deepEqual(parseModelsSettingsBody(models), { ok: true, settings: models });
|
||||
assert.deepEqual(sanitizeUserSettings({ version: 1, models })?.models, models);
|
||||
for (const fastMode of ["true", 1, null]) assert.equal(parseModelsSettingsBody({ strong: { model: chatgpt, fastMode } }).ok, false);
|
||||
assert.equal(parseModelsSettingsBody({ strong: { model: api, fastMode: true } }).ok, false);
|
||||
assert.deepEqual(sanitizeUserSettings({ version: 1, models: { strong: { model: api, fastMode: true }, cheap: { model: chatgpt, fastMode: "true" } } })?.models, { strong: { model: api }, cheap: { model: chatgpt } });
|
||||
});
|
||||
it("toggles one tier without altering its model, reasoning, or siblings; a provider swap clears Fast", () => {
|
||||
const models: ModelsSettings = { strong: { model: chatgpt, thinkingLevel: "high" }, cheap: { model: api, thinkingLevel: "low" } };
|
||||
const enabled = composeSlotFast(models, "strong", true);
|
||||
assert.deepEqual(enabled, { ...models, strong: { ...models.strong, fastMode: true } });
|
||||
assert.equal(models.strong?.fastMode, undefined);
|
||||
assert.deepEqual(composeSlotFast(enabled, "cheap", true), enabled);
|
||||
assert.equal(composeSlotModel(enabled, "strong", "chatgpt", "another-model", "low").strong?.fastMode, true);
|
||||
assert.equal(composeSlotModel(enabled, "strong", "openai", "gpt-5", "high").strong?.fastMode, undefined);
|
||||
assert.equal(composeSlotFast(enabled, "strong", false).strong?.fastMode, false);
|
||||
});
|
||||
it("updates existing conversations on the next resolution without replacing their saved model or reasoning", () => {
|
||||
const chat = { model: { provider: "chatgpt", id: "saved-chat-model" }, thinkingLevel: "low" as const };
|
||||
const before = structuredClone(chat);
|
||||
const strong = { provider: "chatgpt", id: "new-session-default", thinkingLevel: "high" as const, fastMode: true };
|
||||
assert.deepEqual(resolveConversationSpec(chat, strong), { provider: "chatgpt", id: "saved-chat-model", thinkingLevel: "low", fastMode: true });
|
||||
assert.equal(resolveConversationSpec(chat, { ...strong, fastMode: false })?.fastMode, undefined);
|
||||
assert.equal(resolveConversationSpec({ ...chat, model: api }, strong)?.fastMode, undefined);
|
||||
assert.equal(resolveConversationSpec(chat, { ...strong, provider: "openai" })?.fastMode, undefined);
|
||||
assert.deepEqual(chat, before);
|
||||
});
|
||||
it("background work uses only its mapped tier's Fast preference and never opts another provider in", () => {
|
||||
const settings: UserSettings = { version: 1, models: { strong: { model: chatgpt, fastMode: true }, cheap: { model: chatgpt, fastMode: false }, vision: { model: chatgpt, fastMode: true } } };
|
||||
assert.equal(resolveRoleSpec("researcher", settings)?.fastMode, undefined);
|
||||
assert.equal(resolveRoleSpec("visualMaker", settings)?.fastMode, true);
|
||||
assert.equal(resolveRoleSpec("pdfReader", settings)?.fastMode, true);
|
||||
settings.models!.cheap!.fastMode = true;
|
||||
assert.equal(resolveRoleSpec("researcher", settings)?.fastMode, true);
|
||||
assert.equal(resolveModelSpec("researcher", { model: api }, settings.models)?.fastMode, undefined);
|
||||
assert.equal(resolveSettings({ version: 1, models: { strong: { model: api, fastMode: true } } }).models.strong?.fastMode, undefined);
|
||||
});
|
||||
it("the server's reasoning clamp preserves Fast rather than silently dropping the opt-in on write", () => {
|
||||
const models: ModelsSettings = { strong: { model: chatgpt, thinkingLevel: "high", fastMode: true }, cheap: { model: api, thinkingLevel: "low" } };
|
||||
assert.equal(clampModelsLevels(models).strong?.fastMode, true);
|
||||
assert.equal(clampModelsLevels(models).strong?.thinkingLevel, "high");
|
||||
});
|
||||
it("Fast is off for old records, request sanitization pins the chosen tier, and unsupported-mode errors are localized", () => {
|
||||
const settings = resolveSettings({ version: 1, models: { strong: { model: chatgpt } } });
|
||||
assert.equal(settings.models.strong?.fastMode, undefined);
|
||||
assert.equal(chatGPTPayload({ input: [], service_tier: "priority" }, "gpt-5").service_tier, undefined);
|
||||
assert.equal(chatGPTPayload({ input: [], service_tier: "flex" }, "gpt-5", true).service_tier, "priority");
|
||||
const error = chatGPTStreamError("400 invalid service_tier");
|
||||
assert(error.includes("Turn off Fast mode"));
|
||||
assert(translate("ru", error).includes("Отключите быстрый режим"));
|
||||
assert(chatGPTStreamError("429 service_tier quota exceeded").includes("https://chatgpt.com/settings/usage"));
|
||||
});
|
||||
+2
-3
@@ -90,10 +90,9 @@ describe("translation quality and rendering", () => {
|
||||
it("offers independent saved language choices with localized accessible labels", () => {
|
||||
const html = renderToStaticMarkup(<I18nProvider initialGeneral={{ language: "ru", sttLanguage: "en" }}><LanguageSelect field="sttLanguage" /></I18nProvider>);
|
||||
assert(html.includes('aria-label="Язык распознавания речи"'));
|
||||
assert(html.includes('value="en" selected=""'));
|
||||
assert(html.includes("Язык системы"));
|
||||
assert(html.includes('aria-haspopup="menu"'));
|
||||
assert(html.includes('aria-expanded="false"'));
|
||||
assert(html.includes("English"));
|
||||
assert(html.includes("Русский"));
|
||||
});
|
||||
it("translates dialog text and accessibility labels", () => {
|
||||
const html = renderToStaticMarkup(<I18nProvider system="ru"><AbortTurnBody mode="turn" onClose={() => {}} onConfirm={() => {}} /></I18nProvider>);
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import React, { act } from "react";
|
||||
(globalThis as Record<string, unknown>).React = React;
|
||||
import assert from "node:assert/strict";
|
||||
import { afterEach, it } from "node:test";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { I18nProvider, LanguageSelect, useI18n } from "../components/i18n.tsx";
|
||||
import { MENU_CLOSE_EVENT, MENU_OPEN_EVENT } from "../components/menu-shell.tsx";
|
||||
import { translate, type GeneralSettings } from "../src/core/index.ts";
|
||||
|
||||
const dom = parseHTML("<html><body></body></html>");
|
||||
Object.assign(globalThis, {
|
||||
window: dom.window, document: dom.document, Element: dom.window.Element,
|
||||
HTMLElement: dom.window.HTMLElement, HTMLInputElement: dom.window.HTMLInputElement,
|
||||
HTMLTextAreaElement: dom.window.HTMLTextAreaElement, CustomEvent: dom.window.CustomEvent,
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(performance.now()), 0),
|
||||
cancelAnimationFrame: (id: number) => clearTimeout(id),
|
||||
});
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: { languages: ["en-US"] } });
|
||||
Object.assign(dom.window, { innerWidth: 1280, innerHeight: 800 });
|
||||
const originalFetch = globalThis.fetch;
|
||||
let root: Root | null = null;
|
||||
let host: HTMLDivElement;
|
||||
let general: GeneralSettings;
|
||||
let writes: GeneralSettings[];
|
||||
let failSave = false;
|
||||
let holdSave: (() => Promise<void>) | undefined;
|
||||
let recording: (active: boolean) => void;
|
||||
|
||||
function element(selector: string): HTMLElement {
|
||||
const found = document.querySelector<HTMLElement>(selector);
|
||||
assert(found, `expected ${selector}`);
|
||||
return found;
|
||||
}
|
||||
function trigger(field: string, compact = false) { return element(`[data-testid="language-select-${field}${compact ? "-compact" : ""}"]`); }
|
||||
async function click(target: HTMLElement) {
|
||||
await act(async () => {
|
||||
target.dispatchEvent(new dom.window.Event("pointerdown", { bubbles: true }));
|
||||
target.dispatchEvent(new dom.window.Event("click", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
async function pick(text: string) {
|
||||
const option = [...document.querySelectorAll<HTMLElement>('[role="menuitemradio"]')].find((node) => node.textContent?.includes(text));
|
||||
assert(option, `expected option ${text}`);
|
||||
await click(option);
|
||||
}
|
||||
async function mount() {
|
||||
general = { language: "ru", sttLanguage: "en", autoUpdate: false };
|
||||
writes = []; failSave = false; holdSave = undefined;
|
||||
globalThis.fetch = async (input, init) => {
|
||||
const url = String(input);
|
||||
if ((init?.method ?? "GET") === "GET") {
|
||||
assert.equal(url, "/api/settings");
|
||||
return Response.json({ settings: { general } });
|
||||
}
|
||||
assert.equal(url, "/api/settings/general"); assert.equal(init?.method, "PUT");
|
||||
const next = JSON.parse(String(init.body)) as GeneralSettings;
|
||||
writes.push(next);
|
||||
await holdSave?.();
|
||||
if (failSave) return new Response("failed", { status: 503 });
|
||||
general = next;
|
||||
return Response.json({ ok: true });
|
||||
};
|
||||
host = document.createElement("div"); document.body.appendChild(host); root = createRoot(host);
|
||||
function Controls() {
|
||||
recording = useI18n().setDictating;
|
||||
return <><LanguageSelect field="language" /><LanguageSelect field="sttLanguage" /><LanguageSelect field="sttLanguage" compact /></>;
|
||||
}
|
||||
await act(async () => { root!.render(<I18nProvider initialGeneral={general}><Controls /></I18nProvider>); });
|
||||
}
|
||||
afterEach(async () => {
|
||||
await act(async () => { root?.unmount(); }); root = null; host?.remove(); globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("picks languages through the shared menu and preserves the other language and update preference", async () => {
|
||||
await mount();
|
||||
assert.equal(document.querySelector("select"), null);
|
||||
await click(trigger("sttLanguage"));
|
||||
assert.equal(document.querySelectorAll('[role="menuitemradio"]').length, 3);
|
||||
assert(element('[role="menuitemradio"][aria-checked="true"]').textContent?.includes("English"));
|
||||
assert(document.querySelector('[role="menu"]')?.textContent?.includes("Язык системы"));
|
||||
await pick("Русский");
|
||||
assert.deepEqual(general, { language: "ru", sttLanguage: "ru", autoUpdate: false });
|
||||
assert(trigger("sttLanguage", true).textContent?.includes("Русский"));
|
||||
assert.equal(document.querySelector('[role="menu"]'), null);
|
||||
await click(trigger("language")); await pick("Язык системы");
|
||||
assert.deepEqual(general, { sttLanguage: "ru", autoUpdate: false });
|
||||
});
|
||||
|
||||
it("dismisses outside clicks and Escape, and opening another selector balances menu scroll locks", async () => {
|
||||
await mount();
|
||||
let opened = 0;
|
||||
const onOpen = () => opened++; const onClose = () => opened--;
|
||||
window.addEventListener(MENU_OPEN_EVENT, onOpen); window.addEventListener(MENU_CLOSE_EVENT, onClose);
|
||||
try {
|
||||
await click(trigger("language")); assert.equal(opened, 1);
|
||||
await click(trigger("sttLanguage", true)); assert.equal(opened, 1);
|
||||
assert.equal(trigger("language").getAttribute("aria-expanded"), "false");
|
||||
await act(async () => {
|
||||
const event = new dom.window.Event("keydown", { bubbles: true, cancelable: true });
|
||||
Object.assign(event, { key: "Escape" }); element('[role="menuitemradio"]').dispatchEvent(event);
|
||||
});
|
||||
assert.equal(opened, 0);
|
||||
await click(trigger("language")); await click(document.body);
|
||||
assert.equal(opened, 0); assert.equal(writes.length, 0);
|
||||
} finally { window.removeEventListener(MENU_OPEN_EVENT, onOpen); window.removeEventListener(MENU_CLOSE_EVENT, onClose); }
|
||||
});
|
||||
|
||||
it("locks both STT selectors and closes an open menu when recording begins", async () => {
|
||||
await mount(); await click(trigger("sttLanguage", true));
|
||||
await act(async () => recording(true));
|
||||
assert.equal(document.querySelector('[role="menu"]'), null);
|
||||
assert((trigger("sttLanguage") as HTMLButtonElement).disabled);
|
||||
assert((trigger("sttLanguage", true) as HTMLButtonElement).disabled);
|
||||
assert(!(trigger("language") as HTMLButtonElement).disabled);
|
||||
await click(trigger("sttLanguage")); assert.equal(document.querySelector('[role="menu"]'), null);
|
||||
await act(async () => recording(false));
|
||||
assert(!(trigger("sttLanguage", true) as HTMLButtonElement).disabled);
|
||||
assert.equal(trigger("sttLanguage", true).getAttribute("aria-expanded"), "false");
|
||||
assert.equal(writes.length, 0);
|
||||
});
|
||||
|
||||
it("shows a localized save failure without changing the selected language and allows retry", async () => {
|
||||
await mount(); failSave = true;
|
||||
await click(trigger("sttLanguage")); await pick("Русский");
|
||||
assert.equal(element('[role="alert"]').textContent, translate("ru", "Could not save language settings"));
|
||||
assert.equal(general.sttLanguage, "en"); assert(trigger("sttLanguage").textContent?.includes("English"));
|
||||
failSave = false;
|
||||
await click(trigger("sttLanguage")); await pick("Русский");
|
||||
assert.equal(general.sttLanguage, "ru"); assert.equal(document.querySelector('[role="alert"]'), null);
|
||||
});
|
||||
|
||||
it("serializes rapid picks in independent selectors without losing either preference", async () => {
|
||||
await mount(); let release!: () => void;
|
||||
const pending = new Promise<void>((resolve) => { release = resolve; });
|
||||
holdSave = () => pending;
|
||||
await click(trigger("language")); await pick("English");
|
||||
assert((trigger("language") as HTMLButtonElement).disabled);
|
||||
await click(trigger("sttLanguage")); await pick("Русский");
|
||||
assert.equal(writes.length, 1);
|
||||
await act(async () => { release(); });
|
||||
assert.deepEqual(general, { language: "en", sttLanguage: "ru", autoUpdate: false });
|
||||
assert.equal(writes.length, 2);
|
||||
});
|
||||
Reference in New Issue
Block a user