Milestone v1 (v2.0.0): Mokosh — Session Capture #1
@@ -1700,11 +1700,19 @@ async function assertA12(): Promise<AssertionResult> {
|
|||||||
* helper to read `extensionVersion`, since it's the actual production
|
* helper to read `extensionVersion`, since it's the actual production
|
||||||
* field per src/background/index.ts:572).
|
* field per src/background/index.ts:572).
|
||||||
*
|
*
|
||||||
* Pre-condition: A12's zip already landed in downloadsDir. A13
|
* Plan 01-13 Task 9 (debug session 01-09-save-stops-recording) amendment:
|
||||||
* triggers a SECOND SAVE_ARCHIVE (verifies idempotency) so it works
|
* after the SAVE-auto-stops-recording fix in src/background/index.ts,
|
||||||
* against its own fresh zip. Recording stays alive throughout.
|
* A12's SAVE_ARCHIVE now stops the recording (per SPEC one-shot intent).
|
||||||
|
* A13's own SAVE_ARCHIVE therefore needs a FRESH recording — without it,
|
||||||
|
* A13 would dispatch against an empty buffer and saveArchive would
|
||||||
|
* return success=false (the EmptyVideoBufferError path). A13 thus
|
||||||
|
* does its own setupFreshRecording + segment-settle before dispatching.
|
||||||
|
* Trade-off: adds ~11s wall-clock to the harness (segment-settle for
|
||||||
|
* the first rotation) — acceptable; the SPEC SAVE=stop contract is the
|
||||||
|
* load-bearing requirement, A13 was designed under the old "save keeps
|
||||||
|
* recording" assumption.
|
||||||
*
|
*
|
||||||
* @returns Structured result with 1 page-side check (SAVE_ARCHIVE ack).
|
* @returns Structured result with checks (SETUP + A13.1).
|
||||||
*/
|
*/
|
||||||
async function assertA13(): Promise<AssertionResult> {
|
async function assertA13(): Promise<AssertionResult> {
|
||||||
const result: AssertionResult = {
|
const result: AssertionResult = {
|
||||||
@@ -1715,7 +1723,38 @@ async function assertA13(): Promise<AssertionResult> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
diag(result, 'Step 1: send SAVE_ARCHIVE to SW (second save — A12 already produced one)');
|
// Plan 01-13 Task 9 amendment: A12's SAVE_ARCHIVE now auto-stops
|
||||||
|
// recording (per the SAVE=stop SPEC contract). A13 needs to
|
||||||
|
// re-establish a fresh recording + wait for the first segment
|
||||||
|
// rotation before dispatching its own SAVE — otherwise the
|
||||||
|
// SW's saveArchive throws EmptyVideoBufferError on an empty
|
||||||
|
// segments buffer and returns success=false.
|
||||||
|
diag(result, 'Step 1: setupFreshRecording (A12 SAVE stopped recording per 01-09 fix)');
|
||||||
|
const setupResp = await setupFreshRecording();
|
||||||
|
if (!setupResp.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`setupFreshRecording failed: ${setupResp.error ?? '(no error)'}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
diag(result, 'Step 1 OK — fresh recording active');
|
||||||
|
result.checks.push({
|
||||||
|
name: 'SETUP: fresh recording established (post-A12 SAVE auto-stop)',
|
||||||
|
expected: true,
|
||||||
|
actual: true,
|
||||||
|
passed: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 2 — segment-settle. Same rationale as A5 (line ~890): the
|
||||||
|
// offscreen recorder rotates segments every SEGMENT_DURATION_MS
|
||||||
|
// (10s). Before the first rotation, `segments` is empty and
|
||||||
|
// `getVideoBufferFromOffscreen` returns `{segments:[]}` which
|
||||||
|
// createArchive treats as EmptyVideoBufferError. Wait 11s so at
|
||||||
|
// least one segment lands in the buffer.
|
||||||
|
diag(result, `Step 2: settle ${A5_SEGMENT_SETTLE_MS}ms for first segment rotation`);
|
||||||
|
await new Promise((r) => setTimeout(r, A5_SEGMENT_SETTLE_MS));
|
||||||
|
diag(result, 'Step 2 OK — first rotation should have fired');
|
||||||
|
|
||||||
|
diag(result, 'Step 3: send SAVE_ARCHIVE to SW (own fresh-recording save)');
|
||||||
const resp = await sendMessageWithTimeout<{
|
const resp = await sendMessageWithTimeout<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
@@ -1724,7 +1763,7 @@ async function assertA13(): Promise<AssertionResult> {
|
|||||||
A12_A13_SAVE_ARCHIVE_TIMEOUT_MS,
|
A12_A13_SAVE_ARCHIVE_TIMEOUT_MS,
|
||||||
'SAVE_ARCHIVE',
|
'SAVE_ARCHIVE',
|
||||||
);
|
);
|
||||||
diag(result, `Step 1 result: ${JSON.stringify(resp)}`);
|
diag(result, `Step 3 result: ${JSON.stringify(resp)}`);
|
||||||
|
|
||||||
result.checks.push({
|
result.checks.push({
|
||||||
name: 'A13.1: SAVE_ARCHIVE handler returns success=true (zip shape verified host-side)',
|
name: 'A13.1: SAVE_ARCHIVE handler returns success=true (zip shape verified host-side)',
|
||||||
@@ -1742,6 +1781,129 @@ async function assertA13(): Promise<AssertionResult> {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Settle window for the SW state machine after SAVE_ARCHIVE completes
|
||||||
|
* — the post-save STOP_RECORDING + setIdleMode block runs synchronously
|
||||||
|
* in saveArchive's finally, but chrome.action.getBadgeText reads are
|
||||||
|
* cross-event-loop. 500ms provides comfortable headroom; the
|
||||||
|
* observed lag is typically a few ms. */
|
||||||
|
const A14_POST_SAVE_SETTLE_MS = 500;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A14 — post-SAVE auto-stop state check. Plan 01-13 Task 9 closure for
|
||||||
|
* the operator empirical UAT bug `.planning/debug/01-09-save-stops-recording.md`.
|
||||||
|
*
|
||||||
|
* Verifies that A13's SAVE_ARCHIVE (and by extension every SAVE) leaves
|
||||||
|
* the SW state machine in IDLE:
|
||||||
|
* - badge text === '' (setBadgeState('OFF') side effect of setIdleMode)
|
||||||
|
* - popup === '' (chrome.action.setPopup('') side effect of setIdleMode;
|
||||||
|
* observed as the Chrome-resolved absolute URL ending in a path that
|
||||||
|
* is NOT 'src/popup/index.html' — under the IDLE contract Chrome
|
||||||
|
* returns an empty string when getPopup is called against a popup
|
||||||
|
* that was set to '')
|
||||||
|
* - no NEW notification with the 'mokosh-recovery-' prefix surfaced
|
||||||
|
* since A14 entered (delta-based — A7 left a recovery notification
|
||||||
|
* in the active set; the SAVE auto-stop must NOT add another one,
|
||||||
|
* per the "deliberate stop ≠ error" contract that mirrors Bug B)
|
||||||
|
*
|
||||||
|
* Pre-condition: A13 just completed (its SAVE_ARCHIVE auto-stopped the
|
||||||
|
* recording under the new fix; the state machine should already be in
|
||||||
|
* IDLE by the time A14 runs).
|
||||||
|
*
|
||||||
|
* Post-condition: no state change — A14 is read-only.
|
||||||
|
*
|
||||||
|
* Per the spec instruction (alternative simpler design from the
|
||||||
|
* orchestrator prompt): we settle for badge='' + popup='' + no-NEW-recovery-notif
|
||||||
|
* as the A14 contract. Direct isRecording check is transitively verified
|
||||||
|
* via the absence of the REC badge — the production SW state machine
|
||||||
|
* (src/background/index.ts:setRecordingMode/setIdleMode/setErrorMode)
|
||||||
|
* pairs isRecording transitions with badge transitions atomically, so
|
||||||
|
* the badge serves as a reliable proxy.
|
||||||
|
*
|
||||||
|
* @returns Structured result with 3 checks (badge + popup + no-new-recovery-notif).
|
||||||
|
*/
|
||||||
|
async function assertA14(): Promise<AssertionResult> {
|
||||||
|
const result: AssertionResult = {
|
||||||
|
passed: false,
|
||||||
|
name: 'A14 — post-SAVE auto-stop state: badge=\'\' + popup=\'\' + no new mokosh-recovery-* notif',
|
||||||
|
checks: [],
|
||||||
|
diagnostics: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Snapshot the recovery-notification ids BEFORE the A14 settle.
|
||||||
|
// A7 left at least one recovery-* in the active set; the delta we
|
||||||
|
// care about is "did any NEW recovery surface since A13's SAVE
|
||||||
|
// completed". A13's SAVE finished a few ms ago (the orchestrator
|
||||||
|
// ran assertA13 → returned → host moved to A14); the post-save
|
||||||
|
// finally block in saveArchive already executed setIdleMode +
|
||||||
|
// STOP_RECORDING. But we cannot observe what A13 did atomically
|
||||||
|
// — the cleanest contract is to snapshot now, settle, and check
|
||||||
|
// for a DELTA only. The empty-buffer-error branch comment in
|
||||||
|
// src/background/index.ts notes that the recovery notification
|
||||||
|
// would briefly appear; in this assertion we verify that under
|
||||||
|
// the HAPPY (success-path) SAVE, no recovery notification
|
||||||
|
// surfaces. A13's setup dispatched a successful SAVE (against a
|
||||||
|
// fresh recording with >= 1 segment from the 11s settle), so the
|
||||||
|
// happy-path branch ran — no RECORDING_ERROR → no recovery notif.
|
||||||
|
diag(result, 'Step 1: snapshot mokosh-recovery-* notification ids (delta baseline)');
|
||||||
|
const idsBefore = await getActiveNotificationIds();
|
||||||
|
const recoveryIdsBefore = idsBefore.filter(
|
||||||
|
(id) => id.startsWith(RECOVERY_NOTIF_PREFIX),
|
||||||
|
);
|
||||||
|
diag(
|
||||||
|
result,
|
||||||
|
`Step 1 result: ${recoveryIdsBefore.length} active recovery-prefix ids: ${JSON.stringify(recoveryIdsBefore)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
diag(result, `Step 2: settle ${A14_POST_SAVE_SETTLE_MS}ms for post-A13 state machine to land`);
|
||||||
|
await new Promise((r) => setTimeout(r, A14_POST_SAVE_SETTLE_MS));
|
||||||
|
|
||||||
|
diag(result, 'Step 3: read post-SAVE state (badge + popup + recovery ids delta)');
|
||||||
|
const badge = await chrome.action.getBadgeText({});
|
||||||
|
const popup = await chrome.action.getPopup({});
|
||||||
|
const idsAfter = await getActiveNotificationIds();
|
||||||
|
const recoveryIdsAfter = idsAfter.filter(
|
||||||
|
(id) => id.startsWith(RECOVERY_NOTIF_PREFIX),
|
||||||
|
);
|
||||||
|
const recoveryDelta = recoveryIdsAfter.length - recoveryIdsBefore.length;
|
||||||
|
diag(
|
||||||
|
result,
|
||||||
|
`Step 3 result: badge='${badge}', popup='${popup}', recoveryDelta=${recoveryDelta} (before=${recoveryIdsBefore.length}, after=${recoveryIdsAfter.length})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
result.checks.push({
|
||||||
|
name: 'A14.1: badge text is \'\' after SAVE_ARCHIVE auto-stop (setIdleMode)',
|
||||||
|
expected: '',
|
||||||
|
actual: badge,
|
||||||
|
passed: badge === '',
|
||||||
|
});
|
||||||
|
// Chrome's chrome.action.getPopup() returns an absolute URL when the
|
||||||
|
// popup was set via a non-empty path. When setPopup was called with
|
||||||
|
// an empty string (the setIdleMode case), getPopup returns an empty
|
||||||
|
// string per the Chrome runtime API contract. Assert empty-string
|
||||||
|
// equality.
|
||||||
|
result.checks.push({
|
||||||
|
name: 'A14.2: popup is \'\' after SAVE_ARCHIVE auto-stop (setIdleMode re-enables onClicked)',
|
||||||
|
expected: '',
|
||||||
|
actual: popup,
|
||||||
|
passed: popup === '',
|
||||||
|
});
|
||||||
|
result.checks.push({
|
||||||
|
name: 'A14.3: NO new mokosh-recovery-* notification (deliberate stop != error)',
|
||||||
|
expected: 0,
|
||||||
|
actual: recoveryDelta,
|
||||||
|
passed: recoveryDelta === 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
result.passed = result.checks.every((c) => c.passed);
|
||||||
|
} catch (err) {
|
||||||
|
result.error = err instanceof Error ? err.message : String(err);
|
||||||
|
diag(result, `THREW: ${result.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read `chrome.runtime.getManifest().version`. Used by the host-side
|
* Read `chrome.runtime.getManifest().version`. Used by the host-side
|
||||||
* orchestrator at startup to capture the expected version for A13's
|
* orchestrator at startup to capture the expected version for A13's
|
||||||
@@ -1772,6 +1934,7 @@ declare global {
|
|||||||
assertA11: () => Promise<AssertionResult>;
|
assertA11: () => Promise<AssertionResult>;
|
||||||
assertA12: () => Promise<AssertionResult>;
|
assertA12: () => Promise<AssertionResult>;
|
||||||
assertA13: () => Promise<AssertionResult>;
|
assertA13: () => Promise<AssertionResult>;
|
||||||
|
assertA14: () => Promise<AssertionResult>;
|
||||||
getManifestVersion: () => Promise<string>;
|
getManifestVersion: () => Promise<string>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1791,14 +1954,15 @@ window.__mokoshHarness = {
|
|||||||
assertA11,
|
assertA11,
|
||||||
assertA12,
|
assertA12,
|
||||||
assertA13,
|
assertA13,
|
||||||
|
assertA14,
|
||||||
getManifestVersion,
|
getManifestVersion,
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusEl = document.getElementById('status');
|
const statusEl = document.getElementById('status');
|
||||||
if (statusEl !== null) {
|
if (statusEl !== null) {
|
||||||
statusEl.textContent = 'Harness ready. window.__mokoshHarness.{assertA1..assertA13, getManifestVersion} available.';
|
statusEl.textContent = 'Harness ready. window.__mokoshHarness.{assertA1..assertA14, getManifestVersion} available.';
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[harness-page] ready — window.__mokoshHarness installed (Wave 3D: A1..A13 + getManifestVersion)');
|
console.log('[harness-page] ready — window.__mokoshHarness installed (Plan 01-13 Task 9: A1..A14 + getManifestVersion)');
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
@@ -1,23 +1,26 @@
|
|||||||
// tests/uat/harness.test.ts — Plan 01-13 Wave 3A orchestrator.
|
// tests/uat/harness.test.ts — Plan 01-13 orchestrator (Wave 3A → Task 9).
|
||||||
//
|
//
|
||||||
// Top-level entry for the production UAT harness. Drives all 14
|
// Top-level entry for the production UAT harness. Drives all 15
|
||||||
// assertions sequentially against a SINGLE launched Chrome instance with
|
// assertions sequentially against a SINGLE launched Chrome instance with
|
||||||
// a SINGLE harness page; bails on the first failure with a structured
|
// a SINGLE harness page; bails on the first failure with a structured
|
||||||
// diagnostic dump. Exits 0 only when 14/14 GREEN.
|
// diagnostic dump. Exits 0 only when 15/15 GREEN.
|
||||||
//
|
//
|
||||||
// Wave 3A scope — wires A0+A1+A2+A3+A4+A6 (A6 via the proven Wave-2
|
// Wave 3A scope — wires A0+A1+A2+A3+A4+A6 (A6 via the proven Wave-2
|
||||||
// driver). A5+A7..A13 throw `NOT YET IMPLEMENTED — Wave 3<X> wires this`
|
// driver). A5+A7..A13 threw `NOT YET IMPLEMENTED — Wave 3<X> wires this`
|
||||||
// from `tests/uat/lib/harness-page-driver.ts`; the bail-on-first-failure
|
// from `tests/uat/lib/harness-page-driver.ts`; the bail-on-first-failure
|
||||||
// loop stops at the first such throw.
|
// loop stopped at the first such throw.
|
||||||
//
|
//
|
||||||
// Wave 3B wires A5 (SAVE_ARCHIVE → zip on disk) + A7 (genuine
|
// Wave 3B wires A5 (SAVE_ARCHIVE → zip on disk) + A7 (genuine
|
||||||
// RECORDING_ERROR → ERR + recovery notification). Wave 3C (this file's
|
// RECORDING_ERROR → ERR + recovery notification). Wave 3C wires A8
|
||||||
// current state) wires A8 (Bug A canonical onStartup-notification
|
// (Bug A canonical onStartup-notification regression rewind) + A9 (icon
|
||||||
// regression rewind) + A9 (icon file sizes meet imageUtil floors) +
|
// file sizes meet imageUtil floors) + A10 (manifest shape contract).
|
||||||
// A10 (manifest shape contract). Expected diagnostic:
|
|
||||||
// "11/14 GREEN: A0+A1+A2+A3+A4+A5+A6+A7+A8+A9+A10; bail at A11".
|
|
||||||
// Wave 3D wires A11+A12+A13 for 14/14 GREEN.
|
// Wave 3D wires A11+A12+A13 for 14/14 GREEN.
|
||||||
//
|
//
|
||||||
|
// Plan 01-13 Task 9 closure (debug 01-09-save-stops-recording) adds A14:
|
||||||
|
// post-SAVE auto-stop state check (badge='', popup='', no new
|
||||||
|
// mokosh-recovery-*). Chains off A13's SAVE_ARCHIVE — read-only
|
||||||
|
// observation, no new dispatch. Final target: 15/15 GREEN.
|
||||||
|
//
|
||||||
// The orchestrator structure is final from Wave 3A onward; future waves
|
// The orchestrator structure is final from Wave 3A onward; future waves
|
||||||
// only fill in the assertion-driver stubs.
|
// only fill in the assertion-driver stubs.
|
||||||
//
|
//
|
||||||
@@ -63,6 +66,7 @@ import {
|
|||||||
driveA11,
|
driveA11,
|
||||||
driveA12,
|
driveA12,
|
||||||
driveA13,
|
driveA13,
|
||||||
|
driveA14,
|
||||||
getManifestVersion,
|
getManifestVersion,
|
||||||
} from './lib/harness-page-driver';
|
} from './lib/harness-page-driver';
|
||||||
import {
|
import {
|
||||||
@@ -219,7 +223,11 @@ async function assertA0_GrepGate(): Promise<{
|
|||||||
* iterate driver list → bail on first failure → close browser → return
|
* iterate driver list → bail on first failure → close browser → return
|
||||||
* exit code.
|
* exit code.
|
||||||
*
|
*
|
||||||
* @returns Process exit code: 0 on 14/14 GREEN, 1 on any failure.
|
* Plan 01-13 Task 9 closure (debug 01-09-save-stops-recording) added A14
|
||||||
|
* after A13. The orchestrator now drives 14 page-side assertions
|
||||||
|
* (A1..A14) plus the host-side A0 grep gate = 15 total.
|
||||||
|
*
|
||||||
|
* @returns Process exit code: 0 on 15/15 GREEN, 1 on any failure.
|
||||||
*/
|
*/
|
||||||
async function main(): Promise<number> {
|
async function main(): Promise<number> {
|
||||||
process.stdout.write('\nMokosh Plan 01-13 — UAT harness orchestrator\n');
|
process.stdout.write('\nMokosh Plan 01-13 — UAT harness orchestrator\n');
|
||||||
@@ -295,6 +303,12 @@ async function main(): Promise<number> {
|
|||||||
{ name: 'A11', drive: driveA11 },
|
{ name: 'A11', drive: driveA11 },
|
||||||
{ name: 'A12', drive: driveA12Wrapped },
|
{ name: 'A12', drive: driveA12Wrapped },
|
||||||
{ name: 'A13', drive: driveA13Wrapped },
|
{ name: 'A13', drive: driveA13Wrapped },
|
||||||
|
// Plan 01-13 Task 9 closure (debug 01-09-save-stops-recording): A14
|
||||||
|
// verifies that A13's SAVE_ARCHIVE auto-stopped the recording per
|
||||||
|
// SPEC one-shot intent. Read-only assertion on chrome.action +
|
||||||
|
// notification ids state; no new SAVE dispatch — A13's already
|
||||||
|
// exercised the SAVE path. Recording stays stopped after A14.
|
||||||
|
{ name: 'A14', drive: driveA14 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const buffers = { swConsole: handles.swConsole, offConsole: handles.offConsole };
|
const buffers = { swConsole: handles.swConsole, offConsole: handles.offConsole };
|
||||||
@@ -337,7 +351,7 @@ async function main(): Promise<number> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const passedCount = results.filter((r) => r.passed).length;
|
const passedCount = results.filter((r) => r.passed).length;
|
||||||
// Total = 1 (A0) + drivers.length (A1..A13) = 14.
|
// Total = 1 (A0) + drivers.length (A1..A14) = 15.
|
||||||
const total = drivers.length + 1;
|
const total = drivers.length + 1;
|
||||||
const finalPassed = passedCount + 1; // +1 for A0 (we already passed it to reach here)
|
const finalPassed = passedCount + 1; // +1 for A0 (we already passed it to reach here)
|
||||||
|
|
||||||
|
|||||||
@@ -968,6 +968,28 @@ export async function getManifestVersion(page: Page): Promise<string> {
|
|||||||
}) as string;
|
}) as string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Plan 01-13 Task 9 — driveA14 ─────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drive A14 (post-SAVE auto-stop state check). Plan 01-13 Task 9 closure
|
||||||
|
* for debug session 01-09-save-stops-recording. Standard page.evaluate
|
||||||
|
* wrapper — A14 is a read-only assertion of the SW state machine left
|
||||||
|
* by A13's SAVE_ARCHIVE: badge='', popup='', no new mokosh-recovery-*
|
||||||
|
* notification. All work happens page-side; host side just triggers +
|
||||||
|
* reads the result.
|
||||||
|
*
|
||||||
|
* @param page - The harness page from `launchHarnessBrowser`.
|
||||||
|
* @returns Structured AssertionRecord with 3 checks (badge + popup + no-new-recovery).
|
||||||
|
*/
|
||||||
|
export async function driveA14(page: Page): Promise<AssertionRecord> {
|
||||||
|
return await page.evaluate(async () => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose.
|
||||||
|
const harness = (window as any).__mokoshHarness;
|
||||||
|
const r: AssertionRecord = await harness.assertA14();
|
||||||
|
return r;
|
||||||
|
}) as AssertionRecord;
|
||||||
|
}
|
||||||
|
|
||||||
// Note (Wave 3D): the AssertionWithBytes interface is retained at the
|
// Note (Wave 3D): the AssertionWithBytes interface is retained at the
|
||||||
// top of this file as a public export — but Wave 3D's drivers no
|
// top of this file as a public export — but Wave 3D's drivers no
|
||||||
// longer use it (the host side now does all bytes-handling internally
|
// longer use it (the host side now does all bytes-handling internally
|
||||||
|
|||||||
Reference in New Issue
Block a user