feat(ios): P1-A — server touch-points (lastOutputAt, APNs sender+token endpoint) + APIClient P1 contract

T-iOS-37: LiveSessionInfo.lastOutputAt additive-optional field + manager.list() mapping (+4 tests; web consumers unaffected)
T-iOS-20: src/push/apns.ts — env-gated (all-or-disabled, no crash, zero key-material logging), hand-rolled ES256 JWT
(node:crypto ieee-p1363, zero new deps), NEEDS-INPUT/DONE payloads with structural minimization, token store per
subscription-store conventions, combineNotifyServices parallel to web-push, POST/DELETE /push/apns-token per frozen
wire shape (G-guard, 5/min/IP, 8kb); 65 tests incl. dual-channel e2e vs local fake APNs
T-iOS-38: APIClient builders — apns-token (client-side hex mirror, pre-network reject), projects/detail (lossy decode,
single-point percent-encoding), prefs (unknown-key byte-exact round-trip preservation), public four-tier HostNetworkTier
Coordination: webterminal:// CFBundleURLTypes pre-registered in project.yml for T-iOS-22
Verified: root 1470 tests + tsc clean; APIClient 76 tests, 95.26% coverage; wire-shape cross-check zero mismatches
This commit is contained in:
Yaojia Wang
2026-07-05 13:34:01 +02:00
parent aa956fcbb4
commit 4871e8ac3d
20 changed files with 3319 additions and 48 deletions

View File

@@ -1049,3 +1049,66 @@ describe('list — telemetry field', () => {
expect(entry?.telemetry).toBeNull();
});
});
// ── list() carries lastOutputAt (T-iOS-37 unread watermark) ───────────────────
// The runtime record (Session.lastOutputAt, refreshed on every pty.onData — M3)
// is serialized into LiveSessionInfo so clients can compute an unread dot
// (lastOutputAt > local last-seen). Additive OPTIONAL field: existing consumers
// (preview grid, projects panel) are unaffected.
describe('list — lastOutputAt field (T-iOS-37)', () => {
it('includes lastOutputAt matching the session runtime record after output flows', () => {
const mgr = createSessionManager(CFG);
nextPty = createMockPty();
const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
// Output flows → session.ts pty.onData refreshes the runtime record with
// Date.now(); list() must serialize exactly that value.
const before = Date.now();
(s.pty as MockIPty).emitData('hello from claude');
const entry = mgr.list().find((e) => e.id === s.meta.id);
expect(s.lastOutputAt).toBeGreaterThanOrEqual(before);
expect(entry?.lastOutputAt).toBe(s.lastOutputAt);
});
it('serializes a pinned runtime value verbatim (epoch ms, no transformation)', () => {
const mgr = createSessionManager(CFG);
nextPty = createMockPty();
const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
// Pin the runtime record to a known cursor (same style as the M3 reap tests).
s.lastOutputAt = 123_456;
const entry = mgr.list().find((e) => e.id === s.meta.id);
expect(entry?.lastOutputAt).toBe(123_456);
});
it('equals spawn time for a fresh session before any output (createSession inits lastOutputAt = now)', () => {
const mgr = createSessionManager(CFG);
nextPty = createMockPty();
const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
// createSession initializes lastOutputAt: now (src/session/session.ts) —
// a fresh session's watermark IS its spawn time, never absent/zero.
const entry = mgr.list().find((e) => e.id === s.meta.id);
expect(entry?.lastOutputAt).toBe(1_000);
});
it('keeps the existing LiveSessionInfo shape intact (additive optional field)', () => {
const mgr = createSessionManager(CFG);
nextPty = createMockPty();
const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
const entry = mgr.list().find((e) => e.id === s.meta.id);
expect(entry).toMatchObject({
id: s.meta.id,
createdAt: 1_000,
clientCount: 1,
status: 'unknown',
exited: false,
cols: 80,
rows: 24,
telemetry: null,
});
});
});

1084
test/push-apns.test.ts Normal file

File diff suppressed because it is too large Load Diff