feat: compose local platform login host
This commit is contained in:
+195
@@ -0,0 +1,195 @@
|
|||||||
|
import { resolveLocalStartup } from '../../framework/config/local-startup.ts';
|
||||||
|
import type { ResolveRuntimeConfigOptions } from '../../framework/config/runtime-config.ts';
|
||||||
|
import { createLocalLoginSources, type LocalHostSnapshot, type LocalLoginSources } from '../../framework/adapters/local/login-sources.ts';
|
||||||
|
import type { LocalStoragePort } from '../../framework/adapters/local/local-storage.ts';
|
||||||
|
import { PlatformRuntime } from '../../framework/platform/runtime.ts';
|
||||||
|
import type { PlatformState } from '../../framework/platform/stores/platform-types.ts';
|
||||||
|
import { PlatformUiController } from '../../framework/presentation/platform-ui-controller.ts';
|
||||||
|
import type { PlatformViewPort, FrameScheduler } from '../../framework/presentation/ui-contracts.ts';
|
||||||
|
import { createDiagnosticGameEntry } from './local-login-game-entry.ts';
|
||||||
|
import { createLocalLoginWire } from './local-login-wire.ts';
|
||||||
|
import { LocalLoginTransport, type EvidenceSink } from './local-login-transport.ts';
|
||||||
|
|
||||||
|
export interface LocalLoginHostOptions {
|
||||||
|
configOptions: ResolveRuntimeConfigOptions;
|
||||||
|
storage: LocalStoragePort;
|
||||||
|
random: () => number;
|
||||||
|
now: () => number;
|
||||||
|
sampleHost: () => LocalHostSnapshot;
|
||||||
|
view: PlatformViewPort;
|
||||||
|
scheduler: FrameScheduler;
|
||||||
|
loadResources: () => Promise<void>;
|
||||||
|
waitForMinimumDisplay: () => Promise<void>;
|
||||||
|
createSocket: (url: string) => WebSocket;
|
||||||
|
record: EvidenceSink;
|
||||||
|
onBusy: (busy: boolean) => void;
|
||||||
|
onFailure: (error: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalLoginHost {
|
||||||
|
start(): Promise<void>;
|
||||||
|
login(): Promise<void>;
|
||||||
|
stop(): void;
|
||||||
|
getSnapshot(): { state: PlatformState | null; ready: boolean; busy: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
class LocalLoginHostFault extends Error {
|
||||||
|
readonly errors: readonly unknown[];
|
||||||
|
constructor(errors: readonly unknown[]) {
|
||||||
|
super('Local login host failed with additional observer or cleanup errors');
|
||||||
|
this.name = 'LocalLoginHostFault';
|
||||||
|
this.errors = Object.freeze([...errors]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collected(errors: readonly unknown[]): unknown {
|
||||||
|
return errors.length === 1 ? errors[0] : new LocalLoginHostFault(errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Development composition only; Runtime remains the sole business state owner. */
|
||||||
|
export function createLocalLoginHost(options: LocalLoginHostOptions): LocalLoginHost {
|
||||||
|
let runtime: PlatformRuntime | null = null;
|
||||||
|
let controller: PlatformUiController | null = null;
|
||||||
|
let sources: LocalLoginSources | null = null;
|
||||||
|
let startup: Promise<void> | null = null;
|
||||||
|
let loginOperation: Promise<void> | null = null;
|
||||||
|
let terminal = false;
|
||||||
|
let busy = false;
|
||||||
|
let cleanupErrors: unknown[] | null = null;
|
||||||
|
let serverFailure: { data: unknown } | null = null;
|
||||||
|
|
||||||
|
function requireActive(): void {
|
||||||
|
if (terminal) throw new Error('Local login host is stopped or terminal');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBusy(value: boolean): void {
|
||||||
|
if (busy === value) return;
|
||||||
|
busy = value;
|
||||||
|
options.onBusy(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shutdown(errors: unknown[]): void {
|
||||||
|
terminal = true;
|
||||||
|
cleanupErrors = errors;
|
||||||
|
// Disable input before releasing either owner; dispose preserves drawn output.
|
||||||
|
try { setBusy(false); } catch (error) { errors.push(error); }
|
||||||
|
try { controller?.dispose(); } catch (error) { errors.push(error); }
|
||||||
|
try { runtime?.stop(); } catch (error) { errors.push(error); }
|
||||||
|
cleanupErrors = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(error: unknown): void {
|
||||||
|
if (cleanupErrors !== null) {
|
||||||
|
if (!cleanupErrors.includes(error)) cleanupErrors.push(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (terminal) return;
|
||||||
|
const errors = [error];
|
||||||
|
shutdown(errors);
|
||||||
|
options.onFailure(collected(errors));
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireFault(error: unknown): void {
|
||||||
|
if (serverFailure === null) { fail(error); return; }
|
||||||
|
// The Wire scope marker follows the existing denial/kick handler. Keep the
|
||||||
|
// original server payload, and retain any independent Wire cleanup errors.
|
||||||
|
const extras = error instanceof Error && 'errors' in error
|
||||||
|
? (error as Error & { errors: readonly unknown[] }).errors.slice(1) : [];
|
||||||
|
fail(collected([serverFailure.data, ...extras]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function transportFault(error: unknown): void {
|
||||||
|
fail(serverFailure === null ? error : collected([serverFailure.data, error]));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readyTask(task: () => Promise<void>): Promise<void> {
|
||||||
|
try { requireActive(); await task(); }
|
||||||
|
catch (error) { fail(error); throw error; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function start(): Promise<void> {
|
||||||
|
if (startup !== null) return startup;
|
||||||
|
if (terminal) return Promise.reject(new Error('Local login host is stopped or terminal'));
|
||||||
|
startup = Promise.resolve().then(async () => {
|
||||||
|
requireActive();
|
||||||
|
const context = await resolveLocalStartup(options.configOptions);
|
||||||
|
requireActive();
|
||||||
|
options.record({ kind: 'startup-config', data: context.config });
|
||||||
|
requireActive();
|
||||||
|
const entry = createDiagnosticGameEntry(context.config.identity.gameid, options.record);
|
||||||
|
sources = createLocalLoginSources({ context, storage: options.storage, random: options.random,
|
||||||
|
now: options.now, sampleHost: options.sampleHost,
|
||||||
|
isSessionActive: () => busy || runtime?.state.app.phase === 'logged-in' });
|
||||||
|
controller = new PlatformUiController({
|
||||||
|
render: model => {
|
||||||
|
if (terminal) return;
|
||||||
|
try { options.view.render(model); }
|
||||||
|
catch (error) { fail(error); throw error; }
|
||||||
|
},
|
||||||
|
activate: page => {
|
||||||
|
if (terminal) return;
|
||||||
|
try {
|
||||||
|
options.view.activate(page);
|
||||||
|
if (!terminal && page === 'lobby') setBusy(false);
|
||||||
|
} catch (error) { fail(error); throw error; }
|
||||||
|
},
|
||||||
|
showOverlay: model => {
|
||||||
|
if (terminal) return;
|
||||||
|
if (model.kind === 'denial') serverFailure = { data: model.outcome.data };
|
||||||
|
if (model.kind === 'kicked') serverFailure = { data: model.data };
|
||||||
|
try { options.view.showOverlay(model); }
|
||||||
|
catch (error) { transportFault(error); throw error; }
|
||||||
|
if (model.kind === 'fatal') fail(model.error);
|
||||||
|
},
|
||||||
|
}, options.scheduler, fail);
|
||||||
|
runtime = new PlatformRuntime({ gameEntry: entry,
|
||||||
|
resolveRuntimeConfig: async () => context.config,
|
||||||
|
createWireClient: () => createLocalLoginWire({ context, record: options.record, onFault: wireFault,
|
||||||
|
transportFactory: () => new LocalLoginTransport({ context, createSocket: options.createSocket,
|
||||||
|
record: options.record, onFault: transportFault }) }),
|
||||||
|
scene: controller, loadResources: () => readyTask(options.loadResources),
|
||||||
|
waitForMinimumDisplay: () => readyTask(options.waitForMinimumDisplay),
|
||||||
|
getLoginDeviceSnapshot: sources.getLoginDeviceSnapshot,
|
||||||
|
});
|
||||||
|
controller.connect(runtime);
|
||||||
|
requireActive();
|
||||||
|
await runtime.start();
|
||||||
|
requireActive();
|
||||||
|
}).catch(error => { fail(error); throw error; });
|
||||||
|
return startup;
|
||||||
|
}
|
||||||
|
|
||||||
|
function login(): Promise<void> {
|
||||||
|
if (terminal) return Promise.reject(new Error('Local login host is stopped or terminal'));
|
||||||
|
if (busy && loginOperation !== null) return loginOperation;
|
||||||
|
if (runtime === null || controller === null || sources === null || !runtime.ready) {
|
||||||
|
return Promise.reject(new Error('Local login host is not ready for login'));
|
||||||
|
}
|
||||||
|
const activeSources = sources;
|
||||||
|
const activeController = controller;
|
||||||
|
loginOperation = Promise.resolve().then(async () => {
|
||||||
|
requireActive();
|
||||||
|
const account = await activeSources.prepareLogin();
|
||||||
|
requireActive();
|
||||||
|
activeController.login(account);
|
||||||
|
}).catch(error => { fail(error); throw error; });
|
||||||
|
try { setBusy(true); } catch (error) { fail(error); }
|
||||||
|
return loginOperation;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
start, login,
|
||||||
|
stop() {
|
||||||
|
if (terminal) return;
|
||||||
|
const errors: unknown[] = [];
|
||||||
|
shutdown(errors);
|
||||||
|
if (errors.length > 0) {
|
||||||
|
const error = collected(errors);
|
||||||
|
options.onFailure(error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getSnapshot: () => ({ state: runtime === null ? null : runtime.state,
|
||||||
|
ready: !terminal && runtime !== null && runtime.ready, busy }),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import type { PageModel, OverlayModel, PageId } from '../../YouleNexus/assets/framework/presentation/ui-contracts.ts';
|
||||||
|
import type { ResolveRuntimeConfigOptions } from '../../YouleNexus/assets/framework/config/runtime-config.ts';
|
||||||
|
|
||||||
|
function deferred() {
|
||||||
|
let resolve!: () => void;
|
||||||
|
let reject!: (error: unknown) => void;
|
||||||
|
const promise = new Promise<void>((yes, no) => { resolve = yes; reject = no; });
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
async function flush() { for (let i = 0; i < 30; i++) await Promise.resolve(); }
|
||||||
|
function fixture(name = 'player-login-success.json') {
|
||||||
|
return JSON.parse(readFileSync(new URL(`../fixtures/contracts/${name}`, import.meta.url), 'utf8'));
|
||||||
|
}
|
||||||
|
class Socket {
|
||||||
|
readyState = 0;
|
||||||
|
sent: string[] = [];
|
||||||
|
closes = 0;
|
||||||
|
closeError?: Error;
|
||||||
|
onopen: (() => void) | null = null;
|
||||||
|
onclose: (() => void) | null = null;
|
||||||
|
onmessage: ((event: { data: string }) => void) | null = null;
|
||||||
|
onerror: (() => void) | null = null;
|
||||||
|
send(frame: string) { this.sent.push(frame); }
|
||||||
|
close() { this.closes++; if (this.closeError) throw this.closeError; this.readyState = 3; this.onclose?.(); }
|
||||||
|
open() { this.readyState = 1; this.onopen?.(); }
|
||||||
|
push(message: unknown) { this.onmessage?.({ data: JSON.stringify(message) }); }
|
||||||
|
}
|
||||||
|
async function setup(overrides: { config?: Partial<ResolveRuntimeConfigOptions>; render?: (model: PageModel) => void;
|
||||||
|
record?: (event: { kind: string; data: unknown }) => void; onBusy?: (busy: boolean) => void } = {}) {
|
||||||
|
const api = await import('../../YouleNexus/assets/scripts/local-platform-login/local-login-host.ts')
|
||||||
|
.catch(error => assert.fail(`Local login host implementation must exist: ${String(error)}`));
|
||||||
|
const resources = deferred(), minimum = deferred();
|
||||||
|
const sockets: Socket[] = [], pages: PageModel[] = [], overlays: OverlayModel[] = [], activations: PageId[] = [];
|
||||||
|
const failures: unknown[] = [], busy: boolean[] = [], evidence: { kind: string; data: unknown }[] = [];
|
||||||
|
const values = new Map<string, string>(), frames = new Set<() => void>();
|
||||||
|
let fetches = 0;
|
||||||
|
const host = api.createLocalLoginHost({
|
||||||
|
configOptions: { mode: 'debug', hostKind: 'h5', win: {}, search: '?profile=local&version=731',
|
||||||
|
fetcher: { async fetch(): Promise<never> { fetches++; throw new Error('unexpected fetch'); } }, ...overrides.config },
|
||||||
|
storage: { getItem: key => values.get(key) ?? null, setItem: (key, value) => { values.set(key, value); } },
|
||||||
|
random: () => .5, now: () => 123456, sampleHost: () => ({ location: null }),
|
||||||
|
view: { render: model => { overrides.render?.(model); pages.push(model); },
|
||||||
|
activate: page => { activations.push(page); }, showOverlay: model => { overlays.push(model); } },
|
||||||
|
scheduler: { request: callback => { frames.add(callback); return () => { frames.delete(callback); }; } },
|
||||||
|
loadResources: () => resources.promise, waitForMinimumDisplay: () => minimum.promise,
|
||||||
|
createSocket: () => { const socket = new Socket(); sockets.push(socket); return socket as unknown as WebSocket; },
|
||||||
|
record: event => { evidence.push(event); overrides.record?.(event); },
|
||||||
|
onBusy: value => { busy.push(value); overrides.onBusy?.(value); }, onFailure: error => { failures.push(error); },
|
||||||
|
});
|
||||||
|
const draw = () => { for (const frame of [...frames]) { frames.delete(frame); frame(); } };
|
||||||
|
const ready = async () => { const starting = host.start(); resources.resolve(); minimum.resolve(); await flush(); sockets[0]!.open(); await starting; draw(); };
|
||||||
|
return { host, resources, minimum, sockets, pages, overlays, activations, failures, busy, evidence, values, frames, draw, ready, fetches: () => fetches };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const missing of ['resources', 'minimum', 'socket'] as const) test(`login waits for ${missing}, and start composes only once`, async t => {
|
||||||
|
const h = await setup(); t.after(() => h.host.stop());
|
||||||
|
const starting = h.host.start(); const repeated = h.host.start();
|
||||||
|
assert.strictEqual(starting, repeated);
|
||||||
|
await flush();
|
||||||
|
if (missing !== 'resources') h.resources.resolve();
|
||||||
|
if (missing !== 'minimum') h.minimum.resolve();
|
||||||
|
if (missing !== 'socket') h.sockets[0]!.open();
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.host.getSnapshot().ready, false);
|
||||||
|
await assert.rejects(h.host.login(), /ready/i);
|
||||||
|
assert.equal(h.values.size, 0); assert.deepEqual(h.sockets[0]!.sent, []);
|
||||||
|
h.resources.resolve(); h.minimum.resolve(); if (missing === 'socket') h.sockets[0]!.open();
|
||||||
|
await starting;
|
||||||
|
assert.equal(h.host.getSnapshot().ready, true); assert.equal(h.fetches(), 0);
|
||||||
|
assert.equal(h.sockets.length, 1);
|
||||||
|
assert.equal(h.evidence.filter(event => event.kind === 'startup-config').length, 1);
|
||||||
|
await h.host.login();
|
||||||
|
assert.equal(JSON.parse(h.sockets[0]!.sent[0]!).data.version, 731);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('duplicate clicks send once; response updates the sole Store and lobby projection and clears busy', async t => {
|
||||||
|
const h = await setup(); t.after(() => h.host.stop()); await h.ready();
|
||||||
|
const first = h.host.login(), second = h.host.login(); assert.strictEqual(first, second);
|
||||||
|
assert.equal(h.host.getSnapshot().busy, true);
|
||||||
|
await first; await h.host.login(); assert.equal(h.sockets[0]!.sent.length, 1);
|
||||||
|
assert.deepEqual(h.busy, [true]);
|
||||||
|
h.sockets[0]!.push(fixture()); h.draw();
|
||||||
|
assert.equal(h.host.getSnapshot().state?.app.phase, 'logged-in');
|
||||||
|
assert.equal(h.host.getSnapshot().state?.room.kind, 'outside');
|
||||||
|
assert.deepEqual(h.pages[h.pages.length - 1], { page: 'lobby', self: { playerid: 430511, nickname: '测试号', avatar: 'http://a', bean: 0 } });
|
||||||
|
assert.deepEqual(h.busy, [true, false]);
|
||||||
|
const before = h.pages.length; h.host.stop(); h.draw(); assert.equal(h.pages.length, before);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const rpc of ['player_login', 'kick_server']) test(`${rpc} retains original server reason through terminal cleanup`, async t => {
|
||||||
|
const h = await setup(); t.after(() => h.host.stop()); await h.ready(); await h.host.login();
|
||||||
|
const data = { state: 17, msg: '原始服务器原因' };
|
||||||
|
h.sockets[0]!.push({ route: 'agent', rpc, data });
|
||||||
|
assert.equal(h.failures.length, 1); assert.deepEqual(h.failures[0], data);
|
||||||
|
assert.equal(h.host.getSnapshot().ready, false); assert.equal(h.host.getSnapshot().busy, false);
|
||||||
|
assert.equal(h.sockets[0]!.closes, 1);
|
||||||
|
await assert.rejects(h.host.login(), /stop|terminal/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('room recovery is refused before any player or room state enters Store', async t => {
|
||||||
|
const h = await setup(); t.after(() => h.host.stop()); await h.ready(); await h.host.login();
|
||||||
|
h.sockets[0]!.push(fixture('player-login-room.json'));
|
||||||
|
assert.match(String(h.failures[0]), /room recovery/);
|
||||||
|
assert.equal(h.host.getSnapshot().state?.players.selfPlayerId, null);
|
||||||
|
assert.equal(h.host.getSnapshot().state?.room.kind, 'outside');
|
||||||
|
assert.equal(h.pages.some(model => model.page === 'lobby'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('corrupt account cache prevents sending and preserves the cache', async t => {
|
||||||
|
const h = await setup(); t.after(() => h.host.stop()); await h.ready();
|
||||||
|
// Discover the authority-owned key from a prior independent local login.
|
||||||
|
const prior = await setup(); t.after(() => prior.host.stop()); await prior.ready(); await prior.host.login();
|
||||||
|
for (const [key, value] of prior.values) h.values.set(key, value);
|
||||||
|
const accountKey = [...h.values].find(([, value]) => value.includes('account'))![0];
|
||||||
|
h.values.set(accountKey, '{broken');
|
||||||
|
await assert.rejects(h.host.login(), /invalid JSON/);
|
||||||
|
assert.equal(h.sockets[0]!.sent.length, 0); assert.equal(h.values.get(accountKey), '{broken');
|
||||||
|
assert.equal(h.failures.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const phase of ['config', 'resources', 'prepare'] as const) test(`stop during ${phase} suppresses late connection, send and render`, async () => {
|
||||||
|
const h = await setup();
|
||||||
|
let operation: Promise<void>;
|
||||||
|
if (phase === 'prepare') { await h.ready(); operation = h.host.login(); }
|
||||||
|
else { operation = h.host.start(); if (phase === 'resources') await flush(); }
|
||||||
|
const settled = operation.catch(() => {});
|
||||||
|
// Let config resolution / account preparation begin before terminal stop.
|
||||||
|
if (phase !== 'resources') await Promise.resolve();
|
||||||
|
h.host.stop(); const pageCount = h.pages.length;
|
||||||
|
h.resources.resolve(); h.minimum.resolve(); await settled; await flush(); h.draw();
|
||||||
|
assert.equal(h.pages.length, pageCount); assert.equal(h.frames.size, 0);
|
||||||
|
assert.equal(h.sockets.flatMap(socket => socket.sent).length, 0);
|
||||||
|
if (phase === 'config') assert.equal(h.sockets.length, 0);
|
||||||
|
assert.deepEqual(h.failures, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const config of [{ search: '?profile=prod' }, { hostKind: 'native-settings' as const }]) test('invalid local input cannot create an account or connection', async () => {
|
||||||
|
const h = await setup({ config });
|
||||||
|
await assert.rejects(h.host.start(), /local startup/);
|
||||||
|
assert.equal(h.values.size, 0); assert.equal(h.sockets.length, 0); assert.equal(h.failures.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resource failure is reported with original object after shutdown', async () => {
|
||||||
|
const h = await setup(); const error = new Error('resource dependency missing');
|
||||||
|
const starting = h.host.start(); await flush(); h.resources.reject(error); h.minimum.resolve();
|
||||||
|
await assert.rejects(starting);
|
||||||
|
assert.strictEqual(h.failures[0], error); assert.equal(h.sockets[0]!.closes, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('observer and socket cleanup failures both survive terminal reporting', async t => {
|
||||||
|
const observer = new Error('render observer failed'), cleanup = new Error('socket close failed');
|
||||||
|
const h = await setup({ render: model => { if (model.page === 'lobby') throw observer; } });
|
||||||
|
t.after(() => h.host.stop()); await h.ready(); await h.host.login(); h.sockets[0]!.closeError = cleanup;
|
||||||
|
h.sockets[0]!.push(fixture());
|
||||||
|
const contains = (value: unknown, target: unknown): boolean => value === target ||
|
||||||
|
(!!value && typeof value === 'object' && 'errors' in value && (value.errors as unknown[]).some(error => contains(error, target)));
|
||||||
|
assert.equal(contains(h.failures[0], observer), true); assert.equal(contains(h.failures[0], cleanup), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resource error remains primary when closing its socket also fails', async () => {
|
||||||
|
const h = await setup(); const primary = new Error('resource failed'), cleanup = new Error('close failed');
|
||||||
|
const starting = h.host.start(); await flush(); h.sockets[0]!.closeError = cleanup;
|
||||||
|
h.resources.reject(primary); h.minimum.resolve(); await assert.rejects(starting);
|
||||||
|
const errors = (h.failures[0] as { errors: unknown[] }).errors;
|
||||||
|
assert.strictEqual(errors[0], primary); assert.ok(errors.includes(cleanup));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('kick data remains primary when closing its socket also fails', async () => {
|
||||||
|
const h = await setup(); await h.ready(); await h.host.login();
|
||||||
|
const cleanup = new Error('kick close failed'); h.sockets[0]!.closeError = cleanup;
|
||||||
|
const data = { msg: 'original kick reason' };
|
||||||
|
h.sockets[0]!.push({ route: 'agent', rpc: 'kick_server', data });
|
||||||
|
const errors = (h.failures[0] as { errors: unknown[] }).errors;
|
||||||
|
assert.deepEqual(errors[0], data); assert.ok(errors.includes(cleanup));
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user