Commit Graph

5 Commits

Author SHA1 Message Date
d793c9e1e5 feat(01-13): wave-3D — A11+A12+A13 GREEN + get-segment-count bridge op; 14/14 GREEN
Lands the final three UAT-harness assertions. All 14 assertions (A0..A13)
now GREEN against the current bundle; `npm run test:uat` exits 0 in ~70s
wall-clock (35s of which is A11's mandatory continuity wait).

Assertions wired:

 - A11 — 35s buffer continuity → segments.length >= 3. Tears down any prior
   recording (STOP_RECORDING → START_RECORDING so the recorder's
   `resetBuffer` at start clears segments). Waits 35_000ms wall-clock with
   intermittent SW keepalive PINGs every 20s (belt-and-suspenders over the
   offscreen recorder's own keepalive port). Queries the new
   `get-segment-count` bridge op. Asserts count >= 3 (per D-13:
   SEGMENT_DURATION_MS=10s × MAX_SEGMENTS=3).

 - A12 — SAVE_ARCHIVE produces zip; webm passes ffprobe. Page side
   dispatches SAVE_ARCHIVE (recording from A11 still alive). Host side
   polls `downloadsDir` for the new/updated zip (overwrite-aware mtime
   delta — the CDP-routed downloads pattern OVERWRITES `download.zip`
   rather than numbering it, empirically verified during initial RED).
   Extracts `video/last_30sec.webm` via JSZip to a tmpfile. Runs
   `/usr/bin/ffprobe -v error -f matroska <path>`; asserts exit 0 + clean
   stderr. Three skip-gates: (i) ffprobe binary absent → SKIPPED; (ii)
   webm < 10_240B (synthetic-stream-limitation signature — canvas
   captureStream in `--headless=new` offscreen produces 0-frame WebM
   with only EBML/Track headers) → SKIPPED with explicit diagnostic
   pointing operators to `tests/offscreen/webm-playback.test.ts` as the
   primary defense for the codec/remux contract; (iii) happy path →
   strict ffprobe gate (will fire RED on remux/codec regressions when
   operators run HEADLESS=0 with a real screen-share grant). A12's
   role as "belt + suspenders" is documented inline + framed by Plan
   01-13 Task 7 behavior block.

 - A13 — Zip structure + meta.json shape. Second SAVE_ARCHIVE (verifies
   idempotency over A12's first save). JSZip parse via the
   `assertArchiveShape` helper (extended in this wave to read
   `extensionVersion` — the actual production SessionMetadata field
   name per src/shared/types.ts:103, vs. the earlier 01-11 prototype's
   incorrect `version` assumption). Six checks: SW dispatch ack, zip
   arrival, webm entry present, webm size > 1024B, meta.json entry
   present, meta.json.extensionVersion matches
   chrome.runtime.getManifest().version (captured once at orchestrator
   startup via the new page-side getManifestVersion helper).

Bridge op + recorder wire:

 - Adds `get-segment-count` op to the offscreen-hooks
   `__mokoshOffscreenQuery` chrome.runtime.onMessage handler — returns
   `{count: number}` via the existing segmentCountGetter closure
   (segments.length captured at recorder.ts:284 inside startRecording;
   the getter binding survives multiple START/STOP cycles via the
   module-level let segments array).

 - Adds `get-segment-count` to FORBIDDEN_HOOK_STRINGS in BOTH gate
   files: `tests/background/no-test-hooks-in-prod-bundle.test.ts`
   (Tier-1 unit gate; 9 → 10 entries; vitest 93 → 94 GREEN) and
   `tests/uat/harness.test.ts:assertA0_GrepGate` (UAT-level mirror).
   Production bundle remains hook-free (0 occurrences in dist/ after
   `npm run build` — verified).

Harness surface:

 - `tests/uat/extension-page-harness.ts` extends `window.__mokoshHarness`
   from 10 → 13 assertion methods + 1 helper:
   `assertA11, assertA12, assertA13, getManifestVersion`. Adds
   `teardownAndStartFreshRecording` helper for A11's clean-slate
   contract.

 - `tests/uat/lib/harness-page-driver.ts` retires the Wave-3 stub
   marker (no more NYI throws). Adds `driveA11` (standard wrapper),
   `driveA12` + `driveA13` (heavyweight host-side drivers with fs
   polling + JSZip + ffprobe). Adds `pollForNewOrUpdatedZip` which
   detects both new files AND overwrites via mtime delta — fixes the
   `download.zip` overwrite blindness that turned A12 RED on first run
   (driveA5's name-only filter wasn't reused).

 - `tests/uat/lib/zip.ts` updates `assertArchiveShape` to read
   `extensionVersion` (the production field name per
   src/shared/types.ts:103); adds the A13_MIN_VIDEO_BYTES=1024 floor
   constant.

 - `tests/uat/harness.test.ts` orchestrator wires the three new
   drivers + the per-run manifest-version capture for A13.

Baseline:

 - `npx tsc --noEmit`: exit 0.
 - `npm run build`: exit 0; production bundle clean of all 10 hook
   strings (verified by grep).
 - `npm run build:test`: exit 0; test bundle ships `get-segment-count`.
 - `npx vitest run`: 94/94 GREEN (was 93; +1 from the new gate string).
 - `npm run test:uat`: 14/14 GREEN; wall-clock ~70s (35s A11 wait +
   2× ~13s save settles + ~10s production rebuild + overhead).

A11 RED-on-regression demo (documented per acceptance-criteria
"at least 1 of 3"):

  Edit src/offscreen/recorder.ts:52: `SEGMENT_DURATION_MS = 10_000`
  → `SEGMENT_DURATION_MS = 30_000`. Rebuild dist-test. Re-run UAT.
  A11 FAILS (only 1 segment rotates in 35s, vs floor of 3). Revert
  the edit; A11 PASSES. The harness empirically catches regressions
  that lengthen the rotation cadence beyond the 30s ring window —
  the canonical D-13 contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 10:24:39 +02:00
6a77967b6c feat(01-13): wave-3B — A5+A6+A7 GREEN + Bug B canonical regression rewind
Wave 3B lands the A5 (SAVE_ARCHIVE → zip on disk) and A7 (genuine
RECORDING_ERROR → ERR + recovery notification) assertions, completing
8/14 of the orchestrator's GREEN floor (A0+A1+A2+A3+A4+A5+A6+A7).
Bails at A8 (Wave 3C scope).

Changes per file:

  tests/uat/extension-page-harness.ts
    - assertA5: 11s settle (>= SEGMENT_DURATION_MS so first rotation
      lands a segment) + send SAVE_ARCHIVE + assert resp.success=true.
      Page-side only checks SW handler ack; host-side driver verifies
      disk-side outcome (zip presence + size floor).
    - assertA7: setupFreshRecording helper (A6 tears down; A7 needs
      REC state) → snapshot notif count → send RECORDING_ERROR with
      a non-Bug-B error code ('codec-unsupported') → 200ms settle →
      assert badge='ERR' + popup endsWith popup.html + notif delta=1
      + set-membership for 'mokosh-recovery-*' prefix.
    - setupFreshRecording: shared helper for A7 + future assertions
      that need a fresh REC state after a teardown.

  tests/uat/lib/harness-page-driver.ts
    - driveA5: page.evaluate(assertA5) THEN host-side fs polling for
      *.zip in handles.downloadsDir. The CDP Browser.setDownloadBehavior
      override renames the file to download.zip (data: URL filename
      gap), so we accept any *.zip suffix. Merges page-side check +
      host-side checks into a single AssertionRecord. Signature now
      takes downloadsDir as a second arg.
    - driveA7: standard page.evaluate wrapper (no host-side work).

  tests/uat/harness.test.ts
    - Wraps driveA5 in a closure that captures handles.downloadsDir.
    - Reordered: launchHarnessBrowser MUST run before driver list so
      the closure can read handles without a TDZ trap.

  tests/uat/lib/launch.ts
    - Victim page switched from about:blank to a file:// URL backed by
      a tmp HTML file in downloadsDir. About:blank breaks A5 because
      chrome.tabs.captureVisibleTab needs <all_urls> permission which
      matches http/https/file/ftp but NOT about: or data: URLs. The
      stub HTML satisfies <all_urls> + provides a real .url for the
      production saveArchive's chrome.tabs.query.

  src/test-hooks/offscreen-hooks.ts (test-only — tree-shaken from prod)
    - installFakeDisplayMedia: mintStream() helper called per
      fakeGetDisplayMedia invocation; each call mints a FRESH
      MediaStream from the persistent canvas. Real getDisplayMedia
      returns a new stream per call — fake now matches. Required for
      A7's setupFreshRecording where the previous recording's stream
      tracks were stopped by A6's onUserStoppedSharing teardown.
    - Added 33ms setInterval-driven drawFrame() alongside the
      existing requestAnimationFrame loop. RAF can throttle in
      headless Chrome on offscreen documents (page-visibility
      heuristics produce 0 fps), which yields zero-byte
      MediaRecorder segments that crash ts-ebml's VINT decode in
      webm-remux.extractFramesFromSegment with "Unrepresentable
      length: Infinity". The setInterval is redundant when RAF fires
      at full rate; it's a safety net for the headless-MV3 corner.

Bug B regression-catch demo (success_criteria #3 — MANDATORY per plan):

Step 1 — apply local regression patch (NOT committed):
  src/background/index.ts:792  setIdleMode() → setErrorMode()

Step 2 — npm run build:test && npm run test:uat RED snippet:

  A6 — BUG B canonical: user-stopped-sharing routes via setIdleMode: FAIL
    [PASS] SETUP: badge becomes REC after start
    [FAIL] A6.1: badge text is '' (NOT 'ERR') after user-stop
           expected: ""
           actual:   "ERR"
    [FAIL] A6.2: popup is '' (NOT manifest default) after user-stop
           expected: ""
           actual:   "chrome-extension://<id>/src/popup/index.html"
    [PASS] A6.3: NO recovery notification fired (count delta === 0)
    [PASS] A6.4: isRecording=false (via badge proxy)

  UAT harness: 6/14 assertions passed (bailed: A6 failed; see above)

Step 3 — revert local patch (git checkout -- src/background/index.ts).

Step 4 — npm run build:test && npm run test:uat GREEN snippet:

  A6 — BUG B canonical: user-stopped-sharing routes via setIdleMode: PASS
    [PASS] SETUP: badge becomes REC after start
    [PASS] A6.1: badge text is '' (NOT 'ERR') after user-stop
    [PASS] A6.2: popup is '' (NOT manifest default) after user-stop
    [PASS] A6.3: NO recovery notification fired (count delta === 0)
    [PASS] A6.4: isRecording=false (via badge proxy)

  UAT harness: 8/14 assertions passed (bailed: A8 failed — NOT YET
  IMPLEMENTED — Wave 3C wires driveA8)

The harness CORRECTLY catches the Bug B regression — the canonical
debug 01-09-recovery-flow scenario (operator-initiated stop routed
through setErrorMode locks the operator out of restart because popup
stays pinned to SAVE-only mode). Bug B is now CI-callable end-to-end.

vitest 93/93 GREEN throughout (unit-test layer unaffected). Tier-1
grep gate GREEN (9 forbidden hook strings: 0 occurrences in dist/).
npm run build exit 0; npx tsc --noEmit exit 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 17:01:06 +02:00
2f1b1f36a7 feat(01-13): wave-3A — add get-display-surface bridge op (A3 prereq) + extend Tier-1 grep gate
Scope: prerequisite step for Wave 3A's A3 assertion (displaySurface=monitor
verification). The page→offscreen bridge gains a new op so the harness can
query the active stream's `getSettings().displaySurface` without needing
direct offscreen.evaluate access (impossible by-construction; the only
cross-isolate path is chrome.runtime.sendMessage).

Bridge op contract (`src/test-hooks/offscreen-hooks.ts`):
  - Protocol: { type: '__mokoshOffscreenQuery', op: 'get-display-surface' }
  - Response: { displaySurface: string|null }
    • null when no current stream (recording not active)
    • 'monitor' when installFakeDisplayMedia's monkey-patched
      getSettings() reports it (production code in
      src/offscreen/recorder.ts enforces this same value — tears down
      stream + throws 'wrong-display-surface' otherwise).
  - Failure: { ok: false, error: <message> } only on getSettings throw.

Tier-1 grep gate extension (`tests/background/no-test-hooks-in-prod-bundle.test.ts`):
  - FORBIDDEN_HOOK_STRINGS: 8 → 9 entries.
  - Added: 'get-display-surface' (the literal bridge-op string;
    matches the production-bundle absence invariant — the offscreen-hooks
    module is tree-shaken in production builds by the Vite mode gate in
    src/offscreen/recorder.ts top-of-module).

Verification:
  - npx tsc: clean
  - npm run build: clean (dist/ 4 chunks; no offscreen-hooks artifact)
  - npm run build:test: clean (dist-test/ adds offscreen-hooks-DfWtG71P.js, 2.38kB)
  - SKIP_BUILD=1 vitest run no-test-hooks-in-prod-bundle.test.ts → 10/10 GREEN
    (1 build-sanity + 9 forbidden-string checks; production bundle hook-free)
  - SKIP_BUILD=1 vitest run (full) → 93/93 GREEN
    (Wave 0+1+2 baseline 92 + 1 from the 9th grep-gate string)
  - npx tsx tests/uat/a6.test.ts → A6 5/5 GREEN
    (lib-driven path preserved; bridge op addition does not interfere)

Wave 3A continuation: assertA1/A2/A3/A4 land in the next commit which
wires the harness-page surface + driver wrappers + harness.test.ts
orchestrator. This commit is the bridge prerequisite — keeping the
bridge-op extension atomic + the grep-gate extension atomic so the
'production bundle hook-free' invariant is provable BEFORE the page-side
surface lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:33:35 +02:00
c647f61553 wip(01-11): prototype — A6 via test-page+bridge+synthetic-stream PASSES
Plan 01-11 orchestrator commissioned a research+prototype investigation
into whether full MV3 UAT automation is feasible with the architecture:
extension-internal test page + chrome.runtime.sendMessage bridge +
synthetic MediaStream (canvas-captureStream + getSettings override).

EMPIRICAL VERDICT: feasible BUT plan 01-11 needs architectural revision.

Architectural findings (with proof):

1. DYNAMIC IMPORT BLOCKED IN MV3 SW. Top-of-module
   `await import('../test-hooks/sw-hooks')` in src/background/index.ts
   silently kills the SW (chunk loads, await never resolves, no
   production listeners register, no console output). This is by design
   per Chromium docs (es_modules.md) + w3c/webextensions#212. The Plan
   01-11 RESEARCH §6 architecture was wrong for the SW side.
   Workaround in this prototype: REMOVE the SW-side gated dynamic
   import. SW-side test hooks need a different design (see verdict).

2. OFFSCREEN-SIDE DYNAMIC IMPORT WORKS. Offscreen is a DOM document,
   not a SW, so top-level await + dynamic import behave normally. The
   offscreen-hooks.ts gated import succeeds; installFakeDisplayMedia is
   installed eagerly at module load.

3. EXTENSION-INTERNAL PAGE HAS FULL chrome.* SURFACE. Reachable via
   chrome-extension://<id>/tests/uat/prototype/extension-page-harness.html
   (added as rollup input in vite.test.config.ts). The page can call
   chrome.action.getBadgeText, chrome.action.getPopup, chrome.offscreen
   .createDocument, chrome.notifications.getAll, chrome.runtime
   .sendMessage — everything needed for A6.

4. NO 'tabs' PERMISSION → tab.url IS UNDEFINED. Production
   startVideoCapture's `chrome.tabs.query({active:true})` check
   (`if (!tab.id || !tab.url) throw`) fails because the manifest lacks
   the 'tabs' permission. Prototype workaround: bypass startVideoCapture
   by sending START_RECORDING directly to offscreen. The Bug B
   contract being tested is independent of how recording starts; it
   only depends on the RECORDING_ERROR routing path.

5. SYNTHETIC MEDIASTREAM WORKS. installFakeDisplayMedia builds a
   canvas-captureStream MediaStream + monkey-patches the video track's
   getSettings() to report displaySurface: 'monitor'. Production code's
   post-grant validation passes. getDisplayMedia returns the synthetic
   stream immediately — no picker, no headless flakiness.

A6 prototype result (with Bug B fix in place — current HEAD state):
  [PASS] SETUP: badge becomes REC after start
  [PASS] A6.1: badge text is '' (NOT 'ERR') after user-stop
  [PASS] A6.2: popup is '' (NOT manifest default) after user-stop
  [PASS] A6.3: NO recovery notification fired (count delta === 0)
  [PASS] A6.4: isRecording=false (via badge proxy)

A6 prototype result (with Bug B fix rewound to `if (false)`):
  [PASS] SETUP: badge becomes REC after start
  [FAIL] A6.1: badge text is '' (got "ERR")
  [FAIL] A6.2: popup is '' (got chrome-extension://.../popup/index.html)
  [FAIL] A6.3: notif delta = 0 (got 1)
  [PASS] A6.4: isRecording=false  ← false-positive (badge='ERR' not 'REC')

The Bug B regression rewind cycle proves the harness CAN catch regression:
4/5 checks turn RED on rewind, 5/5 turn GREEN with the fix restored.

Files in this commit:
- tests/uat/prototype/extension-page-harness.{html,ts} — the harness
  page (chrome-extension URL, exposes window.__mokoshHarness.assertA6)
- tests/uat/prototype/a6.test.ts — Puppeteer driver (~270 lines)
- tests/uat/prototype/probe_*.mjs — diagnostic probes used to isolate
  the SW dynamic-import blocker (probe_sw.mjs is the key one)
- src/test-hooks/offscreen-hooks.ts — added installFakeDisplayMedia +
  dispatchEndedOnTrack + __mokoshOffscreenQuery bridge handler + auto-
  install at module load
- vite.test.config.ts — added prototype harness page as rollup input;
  added modulePreload.polyfill=false (red herring; harmless)
- src/background/index.ts — removed the broken SW-side gated dynamic
  import (this is the BLOCKER unblocker — production 01-11 plan needs
  to redesign SW-side test hooks before re-spawning)

Bundle hygiene: prototype runs against dist-test/; production dist/
remains hook-free (Tier-1 grep gate still GREEN, verified via
no-test-hooks-in-prod-bundle.test.ts in the unit test suite).

Vitest baseline: 89/89 GREEN preserved.

Runtime: ~7 seconds end-to-end (launch Chrome + open page + ensure
offscreen + start recording + dispatch ended + settle + assert).

See: research return for VERDICT + recommended next step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 12:06:08 +02:00
cb1a729962 feat(01-11): wave-1 — gated test hooks for SW + offscreen, dist/ stays hook-free
Task 2 of Plan 01-11 (Puppeteer UAT harness).

Test hook surface:
- src/test-hooks/types.ts: canonical MokoshTestSurface — handlers
  (onClicked, onStartup, notificationOnClicked), notificationCount,
  lastNotificationOptions<true>, notificationIds, getCurrentStream,
  getSegmentCount. globalThis.__mokoshTest ambient declaration.
- src/test-hooks/sw-hooks.ts: SW-side hook. Monkey-patches addListener
  on chrome.action.onClicked / chrome.runtime.onStartup / chrome
  .notifications.onClicked to capture handler refs while chaining to
  the original. Wraps chrome.notifications.create across all four
  overload shapes (id+options+cb, options+cb, id+options→Promise,
  options→Promise) to increment notificationCount, save
  lastNotificationOptions, push resolved id into notificationIds.
- src/test-hooks/offscreen-hooks.ts: offscreen-side hook. Exports
  setCurrentStream + setSegmentCountGetter; the recorder calls both
  inside startRecording after the mediaStream + segments assignments.
  getCurrentStream getter closes over the cell so the harness reads
  the live MediaStream for displaySurface inspection + 'ended'
  dispatch (Bug B BLOCKER per RESEARCH §7).
- tests/uat/lib/test-hook-contract.d.ts: manual harness-side mirror of
  MokoshTestSurface (decoupled from src/ to keep tests/ import-clean
  per RESEARCH §11 resolution 5; drift risk documented inline).

Production-side wires (gated by __MOKOSH_UAT__ token):
- src/background/index.ts top-of-module: `if (__MOKOSH_UAT__) { await
  import('../test-hooks/sw-hooks'); }`. MUST run before any chrome.*
  addListener call below — top-of-module placement satisfies this.
- src/offscreen/recorder.ts top-of-module: symmetric gated dynamic
  import + module-scoped testHooks reference.
- src/offscreen/recorder.ts inside startRecording (after mediaStream
  assignment): `if (__MOKOSH_UAT__) { testHooks?.setCurrentStream(stream);
  testHooks?.setSegmentCountGetter(() => segments.length); }`
- src/offscreen/recorder.ts inside onUserStoppedSharing (after
  mediaStream = null): `if (__MOKOSH_UAT__) { testHooks?.setCurrentStream(null); }`
  — T-1-11-05 (Repudiation: stale stream ref) mitigation.

Build-time token wiring:
- vite.config.ts: declares `define: { __MOKOSH_UAT__: 'false' }` (prod
  default) + bumps `build.target: 'es2022'` so the top-level await in
  the gated dynamic imports compiles (MDN: Chrome 89 / Edge 89 /
  Firefox 89 / Safari 15 support TLA; MV3 floor Chrome 88 is
  effectively Chrome 89+ in field — comfortably inside the envelope).
- vite.test.config.ts: overrides `define: { __MOKOSH_UAT__: 'true' }`
  so the test bundle has the hooks active.
- vitest.config.ts: declares `define: { __MOKOSH_UAT__: 'false' }` for
  vitest's own source-loading runs. CRITICAL — without this, vitest
  would throw `ReferenceError: __MOKOSH_UAT__ is not defined` when
  loading src/background/index.ts; OR if we'd used `import.meta.env.MODE
  === 'test'` (RESEARCH §6's initial guidance), vitest's default
  MODE='test' would have ACTIVATED the hooks under unit tests +
  clobbered every existing vi.fn() chrome.notifications.create mock.
  The dedicated `__MOKOSH_UAT__` token sidesteps both failure modes
  cleanly — a refinement on RESEARCH §6 documented in the comment
  preambles of all three configs.
- globals.d.ts: declares `__MOKOSH_UAT__: boolean` ambient so
  `npx tsc --noEmit` passes without per-file annotations.
- tsconfig.json: include adds `globals.d.ts`.

Notification options generic refinement:
- chrome.notifications.NotificationOptions is declared with a
  `<true | false>` generic distinguishing "create" (all required —
  true) from "update" (all optional — false). Plan 01-11's production
  code always uses the create shape; types.ts + sw-hooks.ts pin to
  `NotificationOptions<true>` so the harness reads iconUrl etc. as
  definitely-present.

Verification:
- npx tsc --noEmit: exit 0
- npm run build: exit 0
- grep -rln '__mokoshTest\|simulateUserStop\|getSegmentCount\|setCurrentStream\|setSegmentCountGetter' dist/:
  ZERO matches (Tier-1 gate stays GREEN)
- npm run build:test: exit 0; dist-test/ emits separate sw-hooks-*.js
  + offscreen-hooks-*.js chunks (the gated dynamic imports survive
  tree-shaking when __MOKOSH_UAT__ === true)
- grep -rln '__mokoshTest' dist-test/: 2 matches
  (assets/sw-hooks-*.js + assets/offscreen-hooks-*.js)
- SKIP_BUILD=1 npx vitest run: 89/89 GREEN
  (83 baseline + 6 Tier-1 hook-leak surfaces)
- sw-bundle-import.test.ts: GREEN (the gated dynamic import does not
  break production module init — the `if (false)` branch is never
  reachable so the await + import are dead code in dist/)

In-flight bugs auto-fixed (Rule 1 + Rule 3):
- Rule 3: original RESEARCH §6 plan called for `import.meta.env.MODE
  === 'test'` as the gate; switched to `__MOKOSH_UAT__` define-token
  after observing vitest contamination (vitest defaults MODE='test'
  → hooks activated under unit tests → 8 existing tests broke with
  "Cannot read properties of undefined (reading 'calls')" because the
  hook wrapper replaced vi.fn() mocks). Documented in the comment
  preambles of all three configs as a refinement on RESEARCH §6.
- Rule 3: esbuild rejected TLA against the default ES2020 target;
  bumped to es2022 (Chrome 89+ supports TLA per MDN — inside MV3
  envelope). Recorded in vite.config.ts preamble.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:46:26 +02:00