test(vitest): make the lane-budget regression load-independent and own its resources
The fixture's poll case counts attempts instead of reading a clock, so a host stall cannot turn a budget-ended poll into a pass; the control case reads the poll's reported duration, which the 1000 ms default bounds from below. The spawning spec acquires its temporary config in beforeAll, bounds the child with a timeout and asserts no signal ended it, and counts the polled values it inspects. CoverageTestTimeoutOptions is Vitest's own TestUserConfig members; the rule's reach has one home in its JSDoc, which the config, gate, spec, and note comments reference.
This commit is contained in:
1 parent
333d7e75d2
commit
3e38e2118c
10 files changed
+96
-71
No files matched your search
+2
-2
@@ -9,8 +9,8 @@
|
||||
en: 5853599207f6b3bb
|
||||
zh: d58b5e8e19d93a4f
|
||||
/agent-note-subagent-teardown-tests-inherit-their-execution-lane-budgets/decision:
|
||||
en: 5c60e4926ac090af
|
||||
zh: e34b73ab1f6358e0
|
||||
en: f2f641fe1f271edf
|
||||
zh: 321df1f4ea9e783d
|
||||
/agent-note-subagent-teardown-tests-inherit-their-execution-lane-budgets/alternatives-considered:
|
||||
en: 3ad30b2574818a61
|
||||
zh: d323adddd5065ae4
|
||||
|
||||
@@ -12,7 +12,7 @@ The Windows coverage run (run 34085536250, job 101628739668) reports two teardow
|
||||
|
||||
The [ACP test](../../../../packages/subagent/subagent-acp/tests/subagent-acp.spec.ts) awaits disposal under the execution lane’s test budget, then checks the actual child outcome. Failure cleanup awaits disposal and child completion before removing the private directory. A deferred exit observation proves that disposal cannot finish merely because termination was requested. The production EOF and termination grace periods remain unchanged.
|
||||
|
||||
The [Codex test](../../../../packages/subagent/subagent-codex/tests/real-product.spec.ts) inherits the execution lane’s hook budget, which reaches the inline Vitest projects only through `vitest.config.ts` reading `DSH_COVERAGE_TEST_TIMEOUT_MS`: Vitest forwards `--testTimeout` but neither `--hookTimeout` nor `--expect.poll.timeout` into inline projects, so a flag-carried lane budget raises the per-test default alone. Cleanup captures its contexts, HTTP fixtures, and temporary roots before its first asynchronous wait, so an overdue hook cannot drain resources registered by another test. It preserves context-disposal, server-closure, and directory-removal ordering, waits for every captured disposer, and attempts the remaining cleanup stages after a rejection. Collected errors identify each failing stage or path and retain their causes; cleanup reports them only after all captured resources have been attempted.
|
||||
The [Codex test](../../../../packages/subagent/subagent-codex/tests/real-product.spec.ts) inherits the execution lane’s hook budget, which `vitest.config.ts` applies to every inline project from `DSH_COVERAGE_TEST_TIMEOUT_MS` ([`coverageTestTimeoutOptions`](../../../../scripts/coverage-partitions.ts) owns why a CLI flag cannot carry it). Cleanup captures its contexts, HTTP fixtures, and temporary roots before its first asynchronous wait, so an overdue hook cannot drain resources registered by another test. It preserves context-disposal, server-closure, and directory-removal ordering, waits for every captured disposer, and attempts the remaining cleanup stages after a rejection. Collected errors identify each failing stage or path and retain their causes; cleanup reports them only after all captured resources have been attempted.
|
||||
|
||||
The [native Windows CI decision](../process/2026-08-08-native-windows-pull-request-ci.md) continues to own lane scheduling and budgets. This change only removes conflicting local deadlines and strengthens resource-lifetime assertions; it does not establish a Windows process-kill or filesystem defect.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Windows 覆盖率运行 (run 34085536250, job 101628739668) 为测试和钩子
|
||||
|
||||
[ACP 测试](../../../../packages/subagent/subagent-acp/tests/subagent-acp.spec.ts) 在执行通道的测试预算内等待清理完成,然后检查真实子进程的结果。失败清理先等待释放和子进程完成,再删除私有目录。延迟的退出观察证明,清理不能仅因已请求终止而完成。生产环境的 EOF 与终止宽限期保持不变。
|
||||
|
||||
[Codex 测试](../../../../packages/subagent/subagent-codex/tests/real-product.spec.ts) 继承执行通道的钩子预算;该预算只通过 `vitest.config.ts` 读取 `DSH_COVERAGE_TEST_TIMEOUT_MS` 才到达内联 Vitest projects:Vitest 只把 `--testTimeout` 转发给内联 project,不转发 `--hookTimeout` 与 `--expect.poll.timeout`,因此以标志传递的通道预算只会提高单测试默认值。清理在第一次异步等待前取得其上下文、HTTP 夹具和临时根目录,因此超时钩子不能取走其他测试注册的资源。清理保留上下文释放、服务器关闭、目录删除的顺序,等待所有已取得的释放操作,并在拒绝后继续尝试其余清理阶段。收集的错误指出各自失败的阶段或路径并保留原始原因;只有全部已取得资源都尝试清理后才报告错误。
|
||||
[Codex 测试](../../../../packages/subagent/subagent-codex/tests/real-product.spec.ts) 继承执行通道的钩子预算;`vitest.config.ts` 从 `DSH_COVERAGE_TEST_TIMEOUT_MS` 把该预算施加到每个内联 project([`coverageTestTimeoutOptions`](../../../../scripts/coverage-partitions.ts) 负责说明 CLI 标志为何无法承载它)。清理在第一次异步等待前取得其上下文、HTTP 夹具和临时根目录,因此超时钩子不能取走其他测试注册的资源。清理保留上下文释放、服务器关闭、目录删除的顺序,等待所有已取得的释放操作,并在拒绝后继续尝试其余清理阶段。收集的错误指出各自失败的阶段或路径并保留原始原因;只有全部已取得资源都尝试清理后才报告错误。
|
||||
|
||||
[原生 Windows CI 决策](../process/2026-08-08-native-windows-pull-request-ci.zh.md) 继续负责通道调度和预算。本次改动仅移除冲突的局部期限并加强资源生命周期断言;它并不证明 Windows 进程终止或文件系统存在缺陷。
|
||||
|
||||
|
||||
@@ -615,10 +615,9 @@ describe('CI workflow', () => {
|
||||
})
|
||||
|
||||
it('applies the lane test budget inside every Vitest project', () => {
|
||||
// The coverage and serial lanes grant DSH_COVERAGE_TEST_TIMEOUT_MS; Vitest
|
||||
// forwards only --testTimeout of the three budgets into inline projects,
|
||||
// so each project reads the budget from the config (behavior pinned by
|
||||
// scripts/lane-test-budget.spec.ts).
|
||||
// Each inline project spreads coverageTestTimeoutOptions, the only route
|
||||
// for DSH_COVERAGE_TEST_TIMEOUT_MS into projects; the behavior itself is
|
||||
// pinned by scripts/lane-test-budget.spec.ts.
|
||||
const config = readFileSync(resolve(root, 'vitest.config.ts'), 'utf8')
|
||||
|
||||
expect(config).toContain('const laneTestBudget = coverageTestTimeoutOptions(process.env[COVERAGE_TEST_TIMEOUT_ENV])')
|
||||
|
||||
@@ -3,6 +3,7 @@ import { spawn } from 'node:child_process'
|
||||
import { globSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { lstat, mkdir, readdir, rm, unlink, writeFile } from 'node:fs/promises'
|
||||
import { join, relative, sep } from 'node:path'
|
||||
import type { TestUserConfig } from 'vitest/config'
|
||||
import { coverageExemptHeavySuites } from './coverage-exempt.ts'
|
||||
import { pnpmInvocation } from './pnpm-invocation.ts'
|
||||
|
||||
@@ -100,11 +101,7 @@ export function parseCoverageTestTimeout(raw: string | undefined): number | unde
|
||||
}
|
||||
|
||||
/** The Vitest `test` options one lane budget sets in each inline project. */
|
||||
export interface CoverageTestTimeoutOptions {
|
||||
testTimeout?: number
|
||||
hookTimeout?: number
|
||||
expect?: { poll: { timeout: number } }
|
||||
}
|
||||
export type CoverageTestTimeoutOptions = Pick<TestUserConfig, 'testTimeout' | 'hookTimeout' | 'expect'>
|
||||
|
||||
/**
|
||||
* Resolve the Vitest test options that apply one lane budget to the per-test,
|
||||
@@ -113,11 +110,13 @@ export interface CoverageTestTimeoutOptions {
|
||||
* raised test budget accounts for: fixtures that await child exit or retry
|
||||
* Windows handle release spend that cost in `afterEach`, where Vitest's
|
||||
* separate 10 s default would otherwise fail a suite whose cases all passed.
|
||||
* The root config spreads these into each inline project: Vitest forwards
|
||||
* only a fixed list of CLI overrides into projects, and `--hookTimeout` and
|
||||
* `--expect.poll.timeout` are not on it, so a flag-based budget reaches only
|
||||
* the per-test default. Explicit `describe`, case, and fixture timeouts remain
|
||||
* authoritative.
|
||||
* The root `vitest.config.ts` spreads these into each inline project, so the
|
||||
* budget governs every Vitest run of that config in an environment carrying
|
||||
* the variable, whichever gate or script starts it. The config, not a CLI
|
||||
* flag, is the only route into inline projects: Vitest forwards a fixed list
|
||||
* of CLI overrides into projects, and `--hookTimeout` and
|
||||
* `--expect.poll.timeout` are not on it (only `--testTimeout` is). Explicit
|
||||
* `describe`, case, and fixture timeouts remain authoritative.
|
||||
* @param raw - the configured millisecond budget, or undefined to keep Vitest's defaults.
|
||||
* @returns the options to spread into a project's `test` block, empty when unset.
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Waits that end only when a lane budget below them is in force. Runs only
|
||||
* inside the Vitest child that scripts/lane-test-budget.spec.ts spawns.
|
||||
* Waits that end only through the lane budget in force. Runs only inside the
|
||||
* Vitest child that scripts/lane-test-budget.spec.ts spawns.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
@@ -11,11 +11,15 @@ describe('lane test budget fixture', () => {
|
||||
await sleep(600)
|
||||
})
|
||||
|
||||
// Its own case budget stays above the wait so the poll budget, not the
|
||||
// per-test budget, is what ends the assertion.
|
||||
// The predicate counts attempts instead of reading a clock, so a host stall
|
||||
// cannot turn a budget-ended poll into a pass: a 10 ms interval fits at most
|
||||
// ~20 attempts into a 200 ms budget and ~100 into Vitest's 1000 ms default,
|
||||
// both far below the target, and the case's own budget stays above both so
|
||||
// the poll budget is what ends the assertion. The reported duration is then
|
||||
// the poll budget (or longer under load), which the spawning spec reads.
|
||||
it('expect.poll budget', { timeout: 5_000 }, async () => {
|
||||
const start = Date.now()
|
||||
await expect.poll(() => Date.now() - start).toBeGreaterThan(500)
|
||||
let attempts = 0
|
||||
await expect.poll(() => { attempts += 1; return attempts }, { interval: 10 }).toBeGreaterThan(10_000)
|
||||
})
|
||||
|
||||
describe('hook budget', () => {
|
||||
|
||||
@@ -1,47 +1,57 @@
|
||||
/**
|
||||
* DSH_COVERAGE_TEST_TIMEOUT_MS reaches every inline Vitest project. The root
|
||||
* config spreads the budget into each project because Vitest forwards only a
|
||||
* fixed list of CLI overrides into projects: --testTimeout is on it,
|
||||
* --hookTimeout and --expect.poll.timeout are not, so a flag-based budget
|
||||
* raises only the per-test default. A budget below the fixture's waits must
|
||||
* end all three inside both projects; unset must keep Vitest's defaults, under
|
||||
* which the same fixture passes.
|
||||
* DSH_COVERAGE_TEST_TIMEOUT_MS reaches every inline Vitest project through the
|
||||
* root config (coverageTestTimeoutOptions owns the rule and why a CLI flag
|
||||
* cannot carry it). A budget below the fixture's waits must end all three
|
||||
* inside both projects; unset must keep Vitest's defaults, under which the
|
||||
* fixture's per-test and hook waits pass and its poll ends at the 1000 ms
|
||||
* default; a malformed value must fail at config load.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { COVERAGE_TEST_TIMEOUT_ENV } from './coverage-partitions.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const vitestCli = fileURLToPath(new URL('../node_modules/vitest/vitest.mjs', import.meta.url))
|
||||
const fixture = 'scripts/fixtures/lane-test-budget.fixture.ts'
|
||||
const fixtureLine = /lane-test-budget\.fixture\.ts \((\d+) tests(?: \| (\d+) failed)?\)/g
|
||||
|
||||
/** One spawned Vitest run takes a few seconds; a hung child must not hold the worker. */
|
||||
const CHILD_TIMEOUT_MS = 60_000
|
||||
|
||||
let temporaryRoot: string | undefined
|
||||
let configPath = ''
|
||||
|
||||
// The root config with every project's inventory narrowed to the fixture, so
|
||||
// the fixture never joins the ordinary inventory and still runs once per
|
||||
// project. Absolute import in POSIX spelling because the temporary directory
|
||||
// is outside the repository; the sibling package.json keeps Vite bundling the
|
||||
// config as ESM, which the repository root's "type" otherwise supplies.
|
||||
const temporaryRoot = mkdtempSync(join(tmpdir(), 'dsh-lane-test-budget-'))
|
||||
const configPath = join(temporaryRoot, 'vitest.config.ts')
|
||||
writeFileSync(join(temporaryRoot, 'package.json'), '{ "type": "module" }\n', 'utf8')
|
||||
writeFileSync(configPath, [
|
||||
`import base from ${JSON.stringify(resolve(root, 'vitest.config.ts').split('\\').join('/'))}`,
|
||||
'export default {',
|
||||
' ...base,',
|
||||
' test: {',
|
||||
' ...base.test,',
|
||||
' projects: (base.test.projects ?? []).map(project => ({',
|
||||
' ...project,',
|
||||
` test: { ...project.test, include: [${JSON.stringify(fixture)}] },`,
|
||||
' })),',
|
||||
' },',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'), 'utf8')
|
||||
afterAll(() => { rmSync(temporaryRoot, { recursive: true, force: true }) })
|
||||
beforeAll(() => {
|
||||
temporaryRoot = mkdtempSync(join(tmpdir(), 'dsh-lane-test-budget-'))
|
||||
configPath = join(temporaryRoot, 'vitest.config.ts')
|
||||
writeFileSync(join(temporaryRoot, 'package.json'), '{ "type": "module" }\n', 'utf8')
|
||||
writeFileSync(configPath, [
|
||||
`import base from ${JSON.stringify(resolve(root, 'vitest.config.ts').split('\\').join('/'))}`,
|
||||
'export default {',
|
||||
' ...base,',
|
||||
' test: {',
|
||||
' ...base.test,',
|
||||
' projects: (base.test.projects ?? []).map(project => ({',
|
||||
' ...project,',
|
||||
` test: { ...project.test, include: [${JSON.stringify(fixture)}] },`,
|
||||
' })),',
|
||||
' },',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'), 'utf8')
|
||||
})
|
||||
afterAll(() => {
|
||||
if (temporaryRoot !== undefined) rmSync(temporaryRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function runFixture(budget: string | undefined): { status: number | null; output: string } {
|
||||
const env: NodeJS.ProcessEnv = { ...process.env, NO_COLOR: '1' }
|
||||
@@ -57,41 +67,56 @@ function runFixture(budget: string | undefined): { status: number | null; output
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
timeout: CHILD_TIMEOUT_MS,
|
||||
killSignal: 'SIGKILL',
|
||||
})
|
||||
if (child.error !== undefined) throw child.error
|
||||
expect(child.signal, 'the child Vitest run ended through a signal').toBeNull()
|
||||
return { status: child.status, output: `${child.stdout}\n${child.stderr}` }
|
||||
}
|
||||
|
||||
/** Per-project fixture summary lines, `[total, failed]` each, in output order. */
|
||||
function fixtureSummaries(output: string): Array<[number, number]> {
|
||||
return [...output.matchAll(fixtureLine)].map(([, total, failed]) => [Number(total), Number(failed ?? 0)])
|
||||
}
|
||||
|
||||
describe('lane test budget', () => {
|
||||
it('ends per-test, hook, and expect.poll waits in both inline projects', { timeout: 90_000 }, () => {
|
||||
const { status, output } = runFixture('200')
|
||||
|
||||
expect(output).toMatch(/\|thread-safe\| scripts\/fixtures\/lane-test-budget\.fixture\.ts/)
|
||||
expect(output).toMatch(/\|process-bound\| scripts\/fixtures\/lane-test-budget\.fixture\.ts/)
|
||||
expect(output).toMatch(/Test Files\s+2 failed \(2\)/)
|
||||
expect(output).toMatch(/Tests\s+6 failed \(6\)/)
|
||||
expect(fixtureSummaries(output)).toEqual([[3, 3], [3, 3]])
|
||||
expect(output.match(/Test timed out in 200ms\./g)).toHaveLength(2)
|
||||
expect(output.match(/Hook timed out in 200ms\./g)).toHaveLength(2)
|
||||
expect(output.match(/Matcher did not succeed in time\./g)).toHaveLength(2)
|
||||
for (const [, polled] of output.matchAll(/expected (\d+) to be greater than 500/g)) {
|
||||
expect(Number(polled)).toBeLessThan(500)
|
||||
}
|
||||
// At most ~20 attempts fit into the 200 ms budget; the target is 10 000.
|
||||
const polled = [...output.matchAll(/expected (\d+) to be greater than 10000/g)].map(([, count]) => Number(count))
|
||||
expect(polled).toHaveLength(2)
|
||||
for (const count of polled) expect(count).toBeLessThan(100)
|
||||
expect(status).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps Vitest defaults when the budget is unset', { timeout: 90_000 }, () => {
|
||||
const { status, output } = runFixture(undefined)
|
||||
|
||||
expect(output).toMatch(/Test Files\s+2 passed \(2\)/)
|
||||
expect(output).toMatch(/Tests\s+6 passed \(6\)/)
|
||||
expect(status).toBe(0)
|
||||
// 600 ms waits pass under the 5000 ms per-test and 10 000 ms hook defaults;
|
||||
// the poll case ends at the 1000 ms default and can only take longer
|
||||
// under load, never less.
|
||||
expect(fixtureSummaries(output)).toEqual([[3, 1], [3, 1]])
|
||||
expect(output.match(/Matcher did not succeed in time\./g)).toHaveLength(2)
|
||||
expect(output).not.toMatch(/Test timed out in|Hook timed out in/)
|
||||
const pollDurations = [...output.matchAll(/× expect\.poll budget (\d+)ms/g)].map(([, ms]) => Number(ms))
|
||||
expect(pollDurations).toHaveLength(2)
|
||||
for (const ms of pollDurations) expect(ms).toBeGreaterThanOrEqual(1000)
|
||||
expect(status).toBe(1)
|
||||
})
|
||||
|
||||
it('refuses a malformed budget at config load before any test runs', { timeout: 90_000 }, () => {
|
||||
const { status, output } = runFixture('90000ms')
|
||||
|
||||
expect(output).toContain(`${COVERAGE_TEST_TIMEOUT_ENV} must be a positive integer, got "90000ms".`)
|
||||
expect(output).not.toMatch(/lane-test-budget\.fixture\.ts \(/)
|
||||
expect(fixtureSummaries(output)).toEqual([])
|
||||
expect(status).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -519,18 +519,18 @@ describe('gate graph validation', () => {
|
||||
})
|
||||
|
||||
it('leaves the lane test budget to the inherited environment on both coverage gates', () => {
|
||||
// vitest.config.ts reads DSH_COVERAGE_TEST_TIMEOUT_MS per inline project;
|
||||
// a CLI flag here would reach only the per-test default and split the
|
||||
// budget between two owners.
|
||||
// vitest.config.ts reads DSH_COVERAGE_TEST_TIMEOUT_MS per inline project
|
||||
// (coverageTestTimeoutOptions); a gate argument would add a second owner.
|
||||
for (const budget of ['15000', undefined]) {
|
||||
const gates = withEnv('DSH_COVERAGE_TEST_TIMEOUT_MS', budget, () =>
|
||||
withPnpmEntrypoint(() => gatesForMode('ci-windows-complete')))
|
||||
for (const id of ['coverage', 'coverage-exempt-heavy']) {
|
||||
const gate = gates.find(subject => subject.id === id)
|
||||
expect(gate?.args).not.toEqual(expect.arrayContaining([
|
||||
if (gate === undefined) throw new Error(`ci-windows-complete must define the ${id} gate`)
|
||||
expect(gate.args).not.toEqual(expect.arrayContaining([
|
||||
expect.stringMatching(/^--(?:testTimeout|expect\.poll\.timeout|hookTimeout)=/),
|
||||
]))
|
||||
expect(gate?.env ?? {}).not.toHaveProperty('DSH_COVERAGE_TEST_TIMEOUT_MS')
|
||||
expect(gate.env ?? {}).not.toHaveProperty('DSH_COVERAGE_TEST_TIMEOUT_MS')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -648,8 +648,8 @@ function lintGate(options: { needs?: string[] } = {}): Gate {
|
||||
// small share. A budget of 1 gives each gate 1 worker; lanes that need a strict
|
||||
// total of one (the serial reference jobs) also set DSH_GATE_CONCURRENCY=1,
|
||||
// which keeps the gates from overlapping at all.
|
||||
// DSH_COVERAGE_TEST_TIMEOUT_MS reaches both gates through the inherited
|
||||
// environment: vitest.config.ts applies it to every inline project.
|
||||
// DSH_COVERAGE_TEST_TIMEOUT_MS is not a gate argument: vitest.config.ts reads
|
||||
// it from the environment every gate inherits (coverageTestTimeoutOptions).
|
||||
function coverageWorkerArgs(): { instrumented: string[]; exempt: string[] } {
|
||||
const [flag] = positiveIntArg('DSH_COVERAGE_MAX_WORKERS', '--maxWorkers')
|
||||
if (flag === undefined) return { instrumented: [], exempt: [] }
|
||||
|
||||
+3
-5
@@ -145,11 +145,9 @@ if (coveragePartitionRaw !== undefined && coveragePartitionRaw !== '' && coverag
|
||||
const coveragePartitionMode = coveragePartitionRaw === '1'
|
||||
|
||||
// Lanes on shared hosts raise the per-test, hook, and expect.poll defaults
|
||||
// together through DSH_COVERAGE_TEST_TIMEOUT_MS. The budget lands in each
|
||||
// inline project below because Vitest forwards only a fixed list of CLI
|
||||
// overrides into projects: --testTimeout is on it, --hookTimeout and
|
||||
// --expect.poll.timeout are not. Unset keeps Vitest's defaults; explicit
|
||||
// describe, case, and fixture timeouts remain authoritative.
|
||||
// together through DSH_COVERAGE_TEST_TIMEOUT_MS; it lands in each inline
|
||||
// project below because CLI flags do not reach them (coverageTestTimeoutOptions
|
||||
// owns the rule and its reach).
|
||||
const laneTestBudget = coverageTestTimeoutOptions(process.env[COVERAGE_TEST_TIMEOUT_ENV])
|
||||
|
||||
// These suites exercise process-global state, process APIs, or timing-sensitive process I/O
|
||||
|
||||
Reference in new issue
Block a user