fix(mobile): default injected timers to receiver-free wrappers (#21416)

* fix(mobile): default injected timers to receiver-free wrappers

Every transport class stored a global timer function on an object and then
called it back through that object, so the receiver was the instance or the
dependency bag rather than the global. Hermes ignores the receiver; browsers
reject it with TypeError: Illegal invocation, which makes the web build fatal
at the first retry, liveness probe, or relay grace timer.

Default each injected timer to a wrapper that calls the global receiver-free,
and narrow the seam's type from `typeof setTimeout` to the call signature it
actually uses. Node's `typeof setTimeout` also demands a `__promisify__`
member that no injected timer or wrapper can supply, so the wrapper cannot
satisfy it. Pruning mobile-relay-background-grace.test.ts from the typecheck
baseline follows: the narrower type makes that file check clean.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the default timers against a browser receiver check

Both classes are now constructed with no injected timers under a global
setTimeout/clearTimeout that throws Illegal invocation for any explicit
non-global receiver, mirroring the WebIDL rule. The watchdog gets its own
file because its existing test is grandfathered out of the typecheck ratchet.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): prove the default clear leg and drop bare timer injections

The clear assertions were vacuous: cancel() and stop() also drop the state a
fired callback checks, so a no-op default clearTimer stayed green. Both tests
now assert the wrapped global clearTimeout received the exact handle setTimeout
returned, which fails when that default is mutated to a no-op.

Three relay tests injected bare setTimeout/clearTimeout into dependency bags,
the same receiver shape the product fix removed; inert under node, fatal under
jsdom. relay-host-signed-out-verdict drops two `as unknown as typeof setTimeout`
casts, since ScheduleTimer now types those arrows contextually.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): census bare global timers parked in properties and defaults

mobile-endpoint-lifecycle could regress to bare globals with every other test
green, because nothing there is reachable from a unit test. Walk every product
file's AST and fail on a global timer parked where a later call reaches it
through a receiver: a `??` or `||` default, an object literal member, or an
assignment onto a property. A plain local capture stays legal, since calling it
bare leaves the receiver undefined.

A separate test asserts the walk sees the five fixed sites' wrapper shape, so an
empty or misdirected scan fails instead of passing vacuously.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): define the receiver-free timer defaults once

Five hand-written wrappers each restated the same invariant, so five places
could drift. timer-scheduler now exports defaultScheduleTimer and
defaultCancelTimer, and carries the reason for them; every site takes its
default from there. The census keys its presence precondition on those two
identifiers instead of the arrow shape.

The census also missed `??=` and `||=`, which park a global exactly like their
non-assigning forms. Both are handled now, with a parsed-source case per parking
form and one for the local capture that stays legal.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo Hong
2026-09-18 04:59:56 -04:00
committed by GitHub
parent 5c2d3322c1
commit db2ffe7afe
21 changed files with 265 additions and 29 deletions
@@ -1,4 +1,5 @@
import { classifyMobileArtifact } from '../session/mobile-artifact-kind'
import { defaultScheduleTimer } from '../transport/timer-scheduler'
import {
createMobileFilePreviewHref,
type MobileFilePreviewHref,
@@ -24,7 +25,7 @@ export function navigateToMobileFilePreview(
if (options.embedded && options.onRequestClose) {
// Why: closing the dock immediately can unmount the subtree before Expo
// commits the route transition.
const scheduleClose = options.scheduleClose ?? setTimeout
const scheduleClose = options.scheduleClose ?? defaultScheduleTimer
scheduleClose(options.onRequestClose, 0)
}
}
@@ -0,0 +1,34 @@
import { vi } from 'vitest'
// Mirrors the browser rule for WebIDL global operations: an explicit non-global
// receiver is rejected, while an absent one resolves to the global.
function assertGlobalReceiver(receiver: unknown): void {
if (receiver !== undefined && receiver !== globalThis) {
throw new TypeError('Illegal invocation')
}
}
export type GuardedTimerHandles = {
scheduled: ReturnType<typeof setTimeout>[]
cleared: ReturnType<typeof setTimeout>[]
}
// Wraps whatever timers are currently installed (real or vitest's fakes), so callers
// keep using vi.advanceTimersByTime. Undo with vi.unstubAllGlobals().
export function installIllegalInvocationTimerGuards(): GuardedTimerHandles {
const scheduleTimer = globalThis.setTimeout
const cancelTimer = globalThis.clearTimeout
const handles: GuardedTimerHandles = { scheduled: [], cleared: [] }
vi.stubGlobal('setTimeout', function (this: unknown, handler: () => void, ms?: number) {
assertGlobalReceiver(this)
const handle = scheduleTimer(handler, ms)
handles.scheduled.push(handle)
return handle
})
vi.stubGlobal('clearTimeout', function (this: unknown, handle: ReturnType<typeof setTimeout>) {
assertGlobalReceiver(this)
handles.cleared.push(handle)
cancelTimer(handle)
})
return handles
}
@@ -1,9 +1,13 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { installIllegalInvocationTimerGuards } from './global-timer-receiver-test-fakes'
import { HostOpenRetryScheduler } from './host-open-retry-scheduler'
describe('HostOpenRetryScheduler', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
afterEach(() => {
vi.unstubAllGlobals()
vi.useRealTimers()
})
it('advances through bounded retry tiers', async () => {
let generation = 1
@@ -45,6 +49,23 @@ describe('HostOpenRetryScheduler', () => {
expect(open).toHaveBeenCalledTimes(2)
})
it('schedules and clears with no injected timers when the global rejects a non-global receiver', async () => {
const timers = installIllegalInvocationTimerGuards()
const open = vi.fn()
const scheduler = new HostOpenRetryScheduler({ canRetry: () => true, open })
scheduler.recordFailure('host-1', 1)
await vi.advanceTimersByTimeAsync(1_000)
expect(open).toHaveBeenCalledOnce()
scheduler.recordFailure('host-1', 1)
scheduler.cancel('host-1')
expect(timers.cleared).toHaveLength(1)
expect(timers.cleared[0]).toBe(timers.scheduled[1])
await vi.advanceTimersByTimeAsync(60_000)
expect(open).toHaveBeenCalledOnce()
})
it('cancels retry delivery', async () => {
const open = vi.fn()
const scheduler = new HostOpenRetryScheduler({ canRetry: () => true, open })
@@ -1,3 +1,5 @@
import { defaultCancelTimer, defaultScheduleTimer, type ScheduleTimer } from './timer-scheduler'
const RETRY_DELAYS_MS = [1_000, 2_000, 5_000, 15_000, 30_000, 60_000] as const
type RetryState = {
@@ -9,18 +11,18 @@ type RetryState = {
type HostOpenRetrySchedulerOptions = {
canRetry: (hostId: string, generation: number) => boolean
open: (hostId: string) => void
setTimer?: typeof setTimeout
setTimer?: ScheduleTimer
clearTimer?: typeof clearTimeout
}
export class HostOpenRetryScheduler {
private readonly states = new Map<string, RetryState>()
private readonly setTimer: typeof setTimeout
private readonly setTimer: ScheduleTimer
private readonly clearTimer: typeof clearTimeout
constructor(private readonly options: HostOpenRetrySchedulerOptions) {
this.setTimer = options.setTimer ?? setTimeout
this.clearTimer = options.clearTimer ?? clearTimeout
this.setTimer = options.setTimer ?? defaultScheduleTimer
this.clearTimer = options.clearTimer ?? defaultCancelTimer
}
recordFailure(hostId: string, generation: number): { failureCount: number; nextDelayMs: number } {
@@ -1,6 +1,7 @@
import { openAuthenticatedDirectEndpoint } from './mobile-direct-endpoint-probe'
import type { MobileEndpointHysteresis } from './mobile-endpoint-hysteresis'
import type { RpcClient } from './rpc-client'
import type { ScheduleTimer } from './timer-scheduler'
import type { HostProfile } from './types'
import type { MobileConnectionPath } from './stable-logical-rpc-client'
@@ -17,7 +18,7 @@ export class DirectReturnProbe {
constructor(
private readonly deps: {
now: () => number
setTimer: typeof setTimeout
setTimer: ScheduleTimer
clearTimer: typeof clearTimeout
openDirect: (endpoint: string) => RpcClient
},
@@ -11,6 +11,7 @@ import {
import { saveHost } from './host-store'
import { upgradeDirectMobileRelay } from './mobile-relay-direct-upgrade'
import { MobileRelayDirectUpgradeController } from './mobile-relay-direct-upgrade-controller'
import { defaultCancelTimer, defaultScheduleTimer } from './timer-scheduler'
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
type EndpointLifecycle = {
@@ -104,7 +105,7 @@ function createSupervisor(
onLog,
now: Date.now,
randomBytes: ExpoCrypto.getRandomBytes,
setTimer: setTimeout,
clearTimer: clearTimeout
setTimer: defaultScheduleTimer,
clearTimer: defaultCancelTimer
})
}
@@ -4,6 +4,7 @@ import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bund
import type { MobileRelayRpcSession } from './mobile-relay-rpc-session'
import type { resolveMobileRelayEndpoint } from './mobile-relay-resume-director'
import type { RpcClient } from './rpc-client'
import type { ScheduleTimer } from './timer-scheduler'
import type { ConnectionLogSink, HostProfile } from './types'
export type MobileEndpointSupervisorDependencies = {
@@ -20,7 +21,7 @@ export type MobileEndpointSupervisorDependencies = {
saveHost: (host: HostProfile) => Promise<void>
now: () => number
randomBytes: (length: number) => Uint8Array
setTimer: typeof setTimeout
setTimer: ScheduleTimer
clearTimer: typeof clearTimeout
onLog?: ConnectionLogSink
}
@@ -2,6 +2,7 @@ import { vi } from 'vitest'
import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
import type { MobileRelayRpcSession } from './mobile-relay-rpc-session'
import { RelayDialStageTracker, type RelayDialStage } from './relay-dial-stage'
import { defaultCancelTimer, defaultScheduleTimer } from './timer-scheduler'
import type { MobileEndpointSupervisorDependencies } from './mobile-endpoint-supervisor'
import type { RpcClient } from './rpc-client'
import type { MobileConnectionPath, StableLogicalRpcClient } from './stable-logical-rpc-client'
@@ -216,8 +217,8 @@ export function dependencies(
saveHost: vi.fn(async () => {}),
now: Date.now,
randomBytes: (length) => new Uint8Array(length).fill(1),
setTimer: setTimeout,
clearTimer: clearTimeout,
setTimer: defaultScheduleTimer,
clearTimer: defaultCancelTimer,
...overrides
}
}
@@ -11,7 +11,11 @@ describe('MobileRelayBackgroundGraceTimer', () => {
vi.useFakeTimers()
const onExpired = vi.fn()
const timer = new MobileRelayBackgroundGraceTimer(
{ now: Date.now, setTimer: setTimeout, clearTimer: clearTimeout },
{
now: Date.now,
setTimer: (handler, ms) => setTimeout(handler, ms),
clearTimer: (handle) => clearTimeout(handle)
},
onExpired
)
@@ -1,12 +1,13 @@
import type { RelayReconnectController } from './mobile-relay-reconnect-controller'
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
import type { ScheduleTimer } from './timer-scheduler'
// Retain a healthy Relay briefly across routine app switches without waking the app.
export const RELAY_BACKGROUND_GRACE_MS = 30_000
type RelayBackgroundGraceDependencies = {
now: () => number
setTimer: typeof setTimeout
setTimer: ScheduleTimer
clearTimer: typeof clearTimeout
}
@@ -1,4 +1,5 @@
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
import type { ScheduleTimer } from './timer-scheduler'
// Why: on a black-holed LAN endpoint the direct dial sits in 'connecting' for the
// whole 12s connect timeout (rpc-client CONNECT_TIMEOUT_MS), and relay recovery
@@ -8,7 +9,7 @@ import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
const DIRECT_DIAL_GRACE_MS = 2500
type DirectGraceTimerDependencies = {
setTimer: typeof setTimeout
setTimer: ScheduleTimer
clearTimer: typeof clearTimeout
}
@@ -1,3 +1,5 @@
import type { ScheduleTimer } from './timer-scheduler'
// Why: the relay resume lease expires; the phone must proactively re-resume a
// little before the deadline (and retry shortly if a forced rotation didn't land)
// so the session never lapses. Owns the single lease/rotation timer slot.
@@ -12,7 +14,7 @@ const LEASE_ROTATION_MAX_DELAY_MS = 6 * 60 * 60 * 1000
export type RelayLeaseRotationDependencies = {
now: () => number
setTimer: typeof setTimeout
setTimer: ScheduleTimer
clearTimer: typeof clearTimeout
}
@@ -393,8 +393,8 @@ function createController(
{
now: Date.now,
randomBytes: () => new Uint8Array([128, 0]),
setTimer: setTimeout,
clearTimer: clearTimeout
setTimer: (handler, ms) => setTimeout(handler, ms),
clearTimer: (handle) => clearTimeout(handle)
},
onRetry
)
@@ -12,6 +12,7 @@ import { RelayCredentialEligibility } from './relay-credential-eligibility'
import { RelayPairingRejectionLatch } from './relay-pairing-rejection-latch'
import { RelayRecoveryFailureCount } from './relay-recovery-failure-count'
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
import type { ScheduleTimer } from './timer-scheduler'
import type { ConnectionState, ForegroundNudgeReason } from './types'
type RelayCredentialLease = { expiresAt: number; version: number }
@@ -19,7 +20,7 @@ type RelayCredentialLease = { expiresAt: number; version: number }
export type RelayReconnectDependencies = {
now: () => number
randomBytes: (length: number) => Uint8Array
setTimer: typeof setTimeout
setTimer: ScheduleTimer
clearTimer: typeof clearTimeout
}
@@ -236,8 +236,8 @@ function dependencies(
saveHost: vi.fn(async () => {}),
now: Date.now,
randomBytes: (length: number) => new Uint8Array(length),
setTimer: setTimeout,
clearTimer: clearTimeout,
setTimer: (handler, ms) => setTimeout(handler, ms),
clearTimer: (handle) => clearTimeout(handle),
...overrides
}
}
@@ -138,11 +138,11 @@ describe('RelayReconnectController cadence', () => {
{
now: () => 0,
randomBytes: () => new Uint8Array([0, 0]),
setTimer: ((callback: () => void, delay: number) => {
setTimer: (callback, delay) => {
delays.push(delay)
return 1 as unknown as ReturnType<typeof setTimeout>
}) as unknown as typeof setTimeout,
clearTimer: (() => {}) as unknown as typeof clearTimeout
},
clearTimer: () => {}
},
vi.fn()
)
@@ -0,0 +1,33 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { installIllegalInvocationTimerGuards } from './global-timer-receiver-test-fakes'
import {
LIVENESS_IDLE_MS,
LIVENESS_PROBE_TIMEOUT_MS,
RpcSessionLivenessWatchdog
} from './rpc-session-liveness-watchdog'
describe('RpcSessionLivenessWatchdog default timers', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => {
vi.unstubAllGlobals()
vi.useRealTimers()
})
it('schedules and clears with no injected timers when the global rejects a non-global receiver', async () => {
const timers = installIllegalInvocationTimerGuards()
const sendProbe = vi.fn(() => true)
const terminate = vi.fn()
const watchdog = new RpcSessionLivenessWatchdog({ transport: 'direct', sendProbe, terminate })
const identity = {}
watchdog.start(identity)
await vi.advanceTimersByTimeAsync(LIVENESS_IDLE_MS)
expect(sendProbe).toHaveBeenCalledOnce()
watchdog.stop(identity)
expect(timers.cleared).toHaveLength(1)
expect(timers.cleared[0]).toBe(timers.scheduled[1])
await vi.advanceTimersByTimeAsync(LIVENESS_PROBE_TIMEOUT_MS)
expect(terminate).not.toHaveBeenCalled()
})
})
@@ -1,3 +1,5 @@
import { defaultCancelTimer, defaultScheduleTimer, type ScheduleTimer } from './timer-scheduler'
export const LIVENESS_IDLE_MS = 20_000
export const LIVENESS_PROBE_TIMEOUT_MS = 8_000
export const MISSED_PROBE_LIMIT = 3
@@ -17,7 +19,7 @@ type WatchdogOptions = {
missedProbeLimit?: number
voluntaryProbeMinIntervalMs?: number
now?: () => number
setTimer?: typeof setTimeout
setTimer?: ScheduleTimer
clearTimer?: typeof clearTimeout
}
@@ -41,7 +43,7 @@ export class RpcSessionLivenessWatchdog {
private readonly missedProbeLimit: number
private readonly voluntaryProbeMinIntervalMs: number
private readonly now: () => number
private readonly setTimer: typeof setTimeout
private readonly setTimer: ScheduleTimer
private readonly clearTimer: typeof clearTimeout
constructor(private readonly options: WatchdogOptions) {
@@ -50,8 +52,8 @@ export class RpcSessionLivenessWatchdog {
this.missedProbeLimit = options.missedProbeLimit ?? MISSED_PROBE_LIMIT
this.voluntaryProbeMinIntervalMs = options.voluntaryProbeMinIntervalMs ?? 0
this.now = options.now ?? Date.now
this.setTimer = options.setTimer ?? setTimeout
this.clearTimer = options.clearTimer ?? clearTimeout
this.setTimer = options.setTimer ?? defaultScheduleTimer
this.clearTimer = options.clearTimer ?? defaultCancelTimer
}
start(identity: RpcSessionIdentity): void {
@@ -0,0 +1,124 @@
import { readdirSync, readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import ts from 'typescript-api'
import { describe, expect, it } from 'vitest'
const SOURCE_ROOT = fileURLToPath(new URL('..', import.meta.url))
const TIMER_GLOBALS = new Set(['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'])
const GLOBAL_RECEIVERS = new Set(['global', 'globalThis', 'window'])
const SHARED_DEFAULTS = new Set(['defaultScheduleTimer', 'defaultCancelTimer'])
// Sites that take their default from timer-scheduler; the census is meaningless if it
// cannot see them, so an empty or misdirected walk fails instead of passing vacuously.
const SHARED_DEFAULT_SITES = [
'files/mobile-file-preview-navigation.ts',
'transport/host-open-retry-scheduler.ts',
'transport/mobile-endpoint-lifecycle.ts',
'transport/mobile-endpoint-supervisor-test-fakes.ts',
'transport/rpc-session-liveness-watchdog.ts'
]
const PARKING_OPERATORS = new Set([
ts.SyntaxKind.QuestionQuestionToken,
ts.SyntaxKind.QuestionQuestionEqualsToken,
ts.SyntaxKind.BarBarToken,
ts.SyntaxKind.BarBarEqualsToken
])
type Census = { parked: string[]; shared: string[] }
function productFiles(): string[] {
return readdirSync(SOURCE_ROOT, { recursive: true, encoding: 'utf8' })
.filter((entry) => /\.tsx?$/.test(entry) && !/\.test\.tsx?$|\.generated\.ts$/.test(entry))
.map((entry) => entry.replaceAll('\\', '/'))
}
function timerName(node: ts.Node): string | null {
if (ts.isIdentifier(node) && TIMER_GLOBALS.has(node.text)) {
return node.text
}
if (
ts.isPropertyAccessExpression(node) &&
TIMER_GLOBALS.has(node.name.text) &&
ts.isIdentifier(node.expression) &&
GLOBAL_RECEIVERS.has(node.expression.text)
) {
return node.name.text
}
return null
}
// The receiver is only lost once the function is parked somewhere a later call reaches
// through: a nullish/logical default, an object literal member, or an assignment onto a
// property. A plain local capture stays legal: calling it bare leaves the receiver undefined.
function parkedTimer(node: ts.Node): ts.Node | null {
if (ts.isBinaryExpression(node)) {
const operator = node.operatorToken.kind
const parks =
PARKING_OPERATORS.has(operator) ||
(operator === ts.SyntaxKind.EqualsToken && ts.isPropertyAccessExpression(node.left))
return parks ? node.right : null
}
if (ts.isPropertyAssignment(node)) {
return node.initializer
}
if (ts.isShorthandPropertyAssignment(node)) {
return node.name
}
return null
}
function scanSource(relativePath: string, text: string, census: Census): void {
const sourceFile = ts.createSourceFile(relativePath, text, ts.ScriptTarget.Latest, true)
const visit = (node: ts.Node): void => {
const candidate = parkedTimer(node)
const name = candidate === null ? null : timerName(candidate)
if (candidate !== null && name !== null) {
const line = sourceFile.getLineAndCharacterOfPosition(candidate.getStart(sourceFile)).line + 1
census.parked.push(`${relativePath}:${line} ${name}`)
}
if (ts.isIdentifier(node) && SHARED_DEFAULTS.has(node.text)) {
census.shared.push(relativePath)
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
}
function parkedIn(source: string): string[] {
const census: Census = { parked: [], shared: [] }
scanSource('fixture.ts', source, census)
return census.parked
}
describe('global timer receiver census', () => {
const census: Census = { parked: [], shared: [] }
for (const relativePath of productFiles()) {
scanSource(relativePath, readFileSync(`${SOURCE_ROOT}${relativePath}`, 'utf8'), census)
}
it('sees the shared receiver-free defaults, so an empty or misdirected walk cannot pass', () => {
expect(census.shared).toEqual(expect.arrayContaining(SHARED_DEFAULT_SITES))
})
it('parks no bare global timer where a later call would supply a non-global receiver', () => {
expect(census.parked).toEqual([])
})
it.each([
['a nullish default', 'const schedule = injected ?? setTimeout'],
['a logical default', 'const schedule = injected || setTimeout'],
['a nullish assignment default', 'schedule ??= setTimeout'],
['a logical assignment default', 'schedule ||= setTimeout'],
['an object literal member', 'const deps = { setTimer: setTimeout }'],
['a shorthand object member', 'const deps = { setTimeout }'],
['an assignment onto a property', 'this.setTimer = setTimeout'],
['a qualified global read', 'const deps = { setTimer: globalThis.setTimeout }']
])('flags a global timer parked by %s', (_form, source) => {
expect(parkedIn(source)).toEqual(['fixture.ts:1 setTimeout'])
})
it('leaves a plain local capture alone, which a bare call invokes receiver-free', () => {
expect(parkedIn('const schedule = globalThis.setTimeout')).toEqual([])
})
})
+7
View File
@@ -0,0 +1,7 @@
// The injected-timer seam's real contract: `typeof setTimeout` additionally demands
// Node's `__promisify__` member, which no injected timer (or safe wrapper) can supply.
export type ScheduleTimer = (handler: () => void, ms: number) => ReturnType<typeof setTimeout>
// Why: browsers throw Illegal invocation when a global timer is called with a non-global receiver; Hermes does not.
export const defaultScheduleTimer: ScheduleTimer = (handler, ms) => setTimeout(handler, ms)
export const defaultCancelTimer: typeof clearTimeout = (handle) => clearTimeout(handle)
-1
View File
@@ -103,7 +103,6 @@ src/transport/host-removal-lifecycle.test.ts
src/transport/host-status-gates.test.ts
src/transport/host-store.test.ts
src/transport/mobile-endpoint-supervisor-nudge.test.ts
src/transport/mobile-relay-background-grace.test.ts
src/transport/mobile-relay-background-lifecycle.test.ts
src/transport/mobile-relay-direct-upgrade.test.ts
src/transport/mobile-relay-e2ee-link.test.ts