mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 13:46:16 -08:00
Local review round 2 (Codex + Greptile): - The delete committed whenever the shared snack bar was dismissed, so another message (a refresh finishing elsewhere) cut the four-second Undo window short and deleted the source early. The window is now the service's own timer; only the toast's close button (commitNow) or a second delete commits sooner. - The health history was keyed by playlist ID only, so an edited address or account inherited the old connection's result, failing run included. Entries now keep a 32-bit FNV-1a fingerprint of the source health key (no URL or credential); a result for another connection starts over and a stale entry no longer shows. - The M3U refresh-cancellation probe still queried the removed row's .cancel-btn, .busy-state__message and .action-spinner, so its cancel never fired; it now uses the source row's selectors (smoke run passes, cancelEffect=true). - The Xtream delete benchmark waited to catch the "Removing…" row coming back before it left, which a fast delete could finish before the wait began. The driver now arms a delete-playlist operation window before the commit click and ends on its completed event and the absent row. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
253 lines
9.2 KiB
TypeScript
253 lines
9.2 KiB
TypeScript
import type { Page } from '@playwright/test';
|
|
|
|
const OPERATION_STATE_KEY = '__iptvnatorXtreamScenarioOperationWindow';
|
|
const DELETE_OPERATION = 'delete-xtream-content';
|
|
/** A content delete before a refresh, or a whole playlist's delete. */
|
|
export type XtreamObservedOperation =
|
|
typeof DELETE_OPERATION | 'delete-playlist';
|
|
const OPERATION_TIMEOUT_MS = 120_000;
|
|
|
|
export interface XtreamOperationWindowSnapshot<
|
|
Operation extends XtreamObservedOperation = typeof DELETE_OPERATION,
|
|
> {
|
|
readonly active: boolean;
|
|
readonly operation: Operation;
|
|
readonly operationId: string;
|
|
readonly playlistId: string;
|
|
readonly startedEpochMs: number;
|
|
readonly terminalEpochMs: number | null;
|
|
readonly terminalStatus: 'cancelled' | 'completed' | 'error' | null;
|
|
}
|
|
|
|
export interface XtreamOperationWindow<
|
|
Operation extends XtreamObservedOperation = typeof DELETE_OPERATION,
|
|
> {
|
|
assertActive(): Promise<XtreamOperationWindowSnapshot<Operation>>;
|
|
dispose(): Promise<void>;
|
|
waitForActive(): Promise<XtreamOperationWindowSnapshot<Operation>>;
|
|
waitForTerminal(): Promise<XtreamOperationWindowSnapshot<Operation>>;
|
|
}
|
|
|
|
export async function installXtreamOperationWindow<
|
|
Operation extends XtreamObservedOperation = typeof DELETE_OPERATION,
|
|
>(
|
|
page: Page,
|
|
operation: Operation = DELETE_OPERATION as Operation
|
|
): Promise<XtreamOperationWindow<Operation>> {
|
|
await page.evaluate(
|
|
({ command, operation, stateKey }) => {
|
|
interface State {
|
|
active: boolean;
|
|
operation: string;
|
|
operationId: string | null;
|
|
playlistId: string | null;
|
|
startedEpochMs: number | null;
|
|
terminalEpochMs: number | null;
|
|
terminalStatus: string | null;
|
|
unsubscribe: () => void;
|
|
}
|
|
const target = globalThis as unknown as Record<string, unknown>;
|
|
if (command !== 'install-operation' || target[stateKey]) {
|
|
throw new Error('xtream-operation-window-state-invalid');
|
|
}
|
|
const electron = target['electron'] as
|
|
| {
|
|
onDbOperationEvent?: (
|
|
callback: (event: Record<string, unknown>) => void
|
|
) => () => void;
|
|
}
|
|
| undefined;
|
|
if (typeof electron?.onDbOperationEvent !== 'function') {
|
|
throw new Error('xtream-db-operation-listener-unavailable');
|
|
}
|
|
const epoch = (): number =>
|
|
performance.timeOrigin + performance.now();
|
|
const state: State = {
|
|
active: false,
|
|
operation,
|
|
operationId: null,
|
|
playlistId: null,
|
|
startedEpochMs: null,
|
|
terminalEpochMs: null,
|
|
terminalStatus: null,
|
|
unsubscribe: () => undefined,
|
|
};
|
|
state.unsubscribe = electron.onDbOperationEvent((event) => {
|
|
if (event['operation'] !== operation) return;
|
|
const status = event['status'];
|
|
if (status === 'started' && state.startedEpochMs === null) {
|
|
state.active = true;
|
|
state.startedEpochMs = epoch();
|
|
state.operationId =
|
|
typeof event['operationId'] === 'string'
|
|
? event['operationId']
|
|
: null;
|
|
state.playlistId =
|
|
typeof event['playlistId'] === 'string'
|
|
? event['playlistId']
|
|
: null;
|
|
return;
|
|
}
|
|
if (
|
|
!state.active ||
|
|
!['cancelled', 'completed', 'error'].includes(
|
|
String(status)
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
const eventOperationId =
|
|
typeof event['operationId'] === 'string'
|
|
? event['operationId']
|
|
: null;
|
|
if (
|
|
state.operationId !== null &&
|
|
eventOperationId !== state.operationId
|
|
) {
|
|
return;
|
|
}
|
|
state.active = false;
|
|
state.terminalEpochMs = epoch();
|
|
state.terminalStatus = String(status);
|
|
});
|
|
target[stateKey] = state;
|
|
},
|
|
{
|
|
command: 'install-operation',
|
|
operation,
|
|
stateKey: OPERATION_STATE_KEY,
|
|
}
|
|
);
|
|
return {
|
|
assertActive: () => assertOperationActive(page, operation),
|
|
dispose: () => disposeOperationState(page),
|
|
async waitForActive() {
|
|
await waitForOperationState(page, 'operation-active');
|
|
return assertOperationActive(page, operation);
|
|
},
|
|
async waitForTerminal() {
|
|
await waitForOperationState(page, 'operation-terminal');
|
|
return readOperationSnapshot(page, operation);
|
|
},
|
|
};
|
|
}
|
|
|
|
async function waitForOperationState(
|
|
page: Page,
|
|
waitFor: 'operation-active' | 'operation-terminal'
|
|
): Promise<void> {
|
|
await page.waitForFunction(
|
|
({ stateKey, waitFor: condition }) => {
|
|
const target = globalThis as unknown as Record<string, unknown>;
|
|
const state = target[stateKey] as
|
|
| {
|
|
active?: boolean;
|
|
terminalEpochMs?: number | null;
|
|
}
|
|
| undefined;
|
|
return condition === 'operation-active'
|
|
? state?.active === true
|
|
: typeof state?.terminalEpochMs === 'number';
|
|
},
|
|
{ stateKey: OPERATION_STATE_KEY, waitFor },
|
|
{ timeout: OPERATION_TIMEOUT_MS }
|
|
);
|
|
}
|
|
|
|
async function assertOperationActive<Operation extends XtreamObservedOperation>(
|
|
page: Page,
|
|
operation: Operation
|
|
): Promise<XtreamOperationWindowSnapshot<Operation>> {
|
|
const snapshot = await readOperationSnapshot(page, operation);
|
|
if (!snapshot.active || snapshot.terminalEpochMs !== null) {
|
|
throw new Error('xtream-background-action-outside-delete-window');
|
|
}
|
|
return snapshot;
|
|
}
|
|
|
|
async function readOperationSnapshot<Operation extends XtreamObservedOperation>(
|
|
page: Page,
|
|
operation: Operation
|
|
): Promise<XtreamOperationWindowSnapshot<Operation>> {
|
|
const value = await page.evaluate(
|
|
({ stateKey }) => {
|
|
const target = globalThis as unknown as Record<string, unknown>;
|
|
const state = target[stateKey];
|
|
if (!state) throw new Error('xtream-operation-window-missing');
|
|
const {
|
|
active,
|
|
operation,
|
|
operationId,
|
|
playlistId,
|
|
startedEpochMs,
|
|
terminalEpochMs,
|
|
terminalStatus,
|
|
} = state as Record<string, unknown>;
|
|
return {
|
|
active,
|
|
operation,
|
|
operationId,
|
|
playlistId,
|
|
startedEpochMs,
|
|
terminalEpochMs,
|
|
terminalStatus,
|
|
};
|
|
},
|
|
{ command: 'snapshot-operation', stateKey: OPERATION_STATE_KEY }
|
|
);
|
|
if (!isOperationSnapshot(value, operation)) {
|
|
throw new Error('xtream-operation-window-invalid');
|
|
}
|
|
return value;
|
|
}
|
|
|
|
async function disposeOperationState(page: Page): Promise<void> {
|
|
await page.evaluate(
|
|
({ stateKey }) => {
|
|
const target = globalThis as unknown as Record<string, unknown>;
|
|
const state = target[stateKey] as
|
|
{ unsubscribe?: () => void } | undefined;
|
|
state?.unsubscribe?.();
|
|
delete target[stateKey];
|
|
},
|
|
{ command: 'dispose-observer', stateKey: OPERATION_STATE_KEY }
|
|
);
|
|
}
|
|
|
|
function isOperationSnapshot<Operation extends XtreamObservedOperation>(
|
|
value: unknown,
|
|
operation: Operation
|
|
): value is XtreamOperationWindowSnapshot<Operation> {
|
|
if (!isRecord(value)) return false;
|
|
const active = value['active'];
|
|
const startedEpochMs = value['startedEpochMs'];
|
|
const terminalEpochMs = value['terminalEpochMs'];
|
|
const terminalStatus = value['terminalStatus'];
|
|
return (
|
|
value['operation'] === operation &&
|
|
typeof active === 'boolean' &&
|
|
finitePositive(startedEpochMs) &&
|
|
nonEmptyString(value['operationId']) &&
|
|
nonEmptyString(value['playlistId']) &&
|
|
(active
|
|
? terminalEpochMs === null && terminalStatus === null
|
|
: finitePositive(terminalEpochMs) &&
|
|
terminalEpochMs >= startedEpochMs &&
|
|
['cancelled', 'completed', 'error'].includes(
|
|
String(terminalStatus)
|
|
))
|
|
);
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null;
|
|
}
|
|
|
|
function finitePositive(value: unknown): value is number {
|
|
return typeof value === 'number' && Number.isFinite(value) && value > 0;
|
|
}
|
|
|
|
function nonEmptyString(value: unknown): value is string {
|
|
return typeof value === 'string' && value.length > 0;
|
|
}
|