diff --git a/capture/README.md b/capture/README.md index b01ac0a..2fd0083 100644 --- a/capture/README.md +++ b/capture/README.md @@ -112,3 +112,49 @@ recordings as evidence of physical-hardware validation. The older `core-capture.json`, `core-controls.json`, and `core-workflows.json` describe their historical v3.0 capture run. For an output recaptured by `interfaces.json`, the current `manifest.json` entry is authoritative. To redo only the movies after a reviewed still capture at the same source revision, set `CAPTURE_MODE=video` on the capture command. + +## October 14 development preview + +`capture/development.json` records the unreleased Core and iOS source revisions +and capture methods. The site labels these features as development previews. + +For Core, use an isolated checkout at +`f905a776a8eca61cf0e855726b06e7774f241c52`. Install locked dependencies with scripts +disabled, then run `pnpm rebuild better-sqlite3`. Build with +`CI=1 NEXT_PUBLIC_DEMO=1 DATABASE_URL=:memory: BIOMETRICS_DATABASE_URL=:memory: pnpm build`. +Start the standalone server with `CI=1 HOSTNAME=127.0.0.1 PORT=3214`, both database +URLs set to `:memory:`, and a nonexistent `DAC_SOCK_PATH`. Run +`node scripts/capture-development.mjs` from this site worktree. API and external +network requests are blocked; the built-in demo handles queries and mutations. +The Watch dialog clip stops before uploading; the calibration clip imports only +the synthetic records embedded in the script and saves a candidate without +applying it. No product UI is modified. + +For iOS, use a clean checkout at `ce1d455cb69b00413f8d84630052c1878d9a5f5b` and +build the upstream `Marketing/AppStore/capture.py` app/test target. Create a +**sleepypod Docs Watch** iPhone 17 Pro Max simulator on iOS 26.2, then run: + +```sh +IOS_SOURCE_ROOT=/path/to/pinned/ios IOS_CAPTURE_UDID= \ + python3 scripts/capture-ios-watch.py +``` + +The wrapper runs the upstream Watch screenshot and `testClipSleepAndWatch` tour, +using its demo launch arguments and disabling Apple Health sync. It resets only +the named disposable simulator's app data. The upstream runtime auto-selection +failed to create an iPhone 17 Pro Max on this machine's iOS 27.1 runtime, so the +wrapper selects the explicitly supplied compatible simulator. Screenshots are +copied from `raw/`, with no marketing frame baked into their pixels. + +After reviewing raw captures, run: + +```sh +IOS_SOURCE_ROOT=/path/to/pinned/ios node scripts/promote-development.mjs +pnpm check +``` + +Promotion trims Core recording startup, encodes silent H.264, and copies the +upstream iOS clip and untouched screenshot. It records dimensions, durations, +byte sizes, and SHA-256 hashes. Captions provide the action transcript and +identify synthetic data. These demonstrations do not validate biometric accuracy, +live processor adoption, or Apple Health writes. diff --git a/capture/development.json b/capture/development.json new file mode 100644 index 0000000..8851779 --- /dev/null +++ b/capture/development.json @@ -0,0 +1,26 @@ +{ + "sourceCommit": "f905a776a8eca61cf0e855726b06e7774f241c52", + "method": "Unmodified Core production demo; local in-browser simulation; API and external network requests blocked", + "records": [ + { + "name": "core-vitals-preview", + "route": "/en/sleep?view=biometrics", + "raw": ".capture/development/page@f6ddb3b6f5f8dfab4020fafe5fa43c71.webm", + "start": 3.626, + "duration": 10.088000000000001 + }, + { + "name": "core-calibration-preview", + "route": "/en/settings?section=biometric-calibration", + "raw": ".capture/development/page@96b4e670c820c4c5418fccc1679af0e6.webm", + "start": 3.653, + "duration": 18.218 + } + ], + "ios": { + "sourceCommit": "ce1d455cb69b00413f8d84630052c1878d9a5f5b", + "simulator": "iPhone 17 Pro Max / iOS 26.2", + "method": "scripts/capture-ios-watch.py; upstream testClipSleepAndWatch", + "data": "Built-in synthetic demo; Apple Health sync disabled" + } +} diff --git a/capture/manifest.json b/capture/manifest.json index 92e6864..ee812b4 100644 --- a/capture/manifest.json +++ b/capture/manifest.json @@ -776,6 +776,75 @@ "bytes": 277849, "width": 1920, "height": 1080 + }, + { + "repository": "sleepypod-core", + "commit": "f905a776a8eca61cf0e855726b06e7774f241c52", + "source": "/en/sleep?view=biometrics", + "output": "core-vitals-preview.mp4", + "method": "scripts/capture-development.mjs + scripts/promote-development.mjs; unmodified loopback production demo, synthetic data, API/external requests blocked", + "sha256": "6f6cb9e6e84c19f045d8f0919d398c2946938c1cb4a8d809487997830a9f8ce6", + "bytes": 391634, + "width": 1440, + "height": 1000, + "duration": 9.96 + }, + { + "repository": "sleepypod-core", + "commit": "f905a776a8eca61cf0e855726b06e7774f241c52", + "source": "/en/sleep?view=biometrics", + "output": "core-vitals-preview.png", + "method": "scripts/capture-development.mjs + scripts/promote-development.mjs; unmodified loopback production demo, synthetic data, API/external requests blocked", + "sha256": "a2e7dcb06420fc1076e28334141ca151ba58a5c366b2a4e630b827da5a2d5054", + "bytes": 178778, + "width": 1440, + "height": 1000 + }, + { + "repository": "sleepypod-core", + "commit": "f905a776a8eca61cf0e855726b06e7774f241c52", + "source": "/en/settings?section=biometric-calibration", + "output": "core-calibration-preview.mp4", + "method": "scripts/capture-development.mjs + scripts/promote-development.mjs; unmodified loopback production demo, synthetic data, API/external requests blocked", + "sha256": "3bf4ed4d692b714cd5482e328dc1b916cff82a2dca1a8a3f12c9ec5d92bc7ad9", + "bytes": 650720, + "width": 1440, + "height": 1000, + "duration": 18.12 + }, + { + "repository": "sleepypod-core", + "commit": "f905a776a8eca61cf0e855726b06e7774f241c52", + "source": "/en/settings?section=biometric-calibration", + "output": "core-calibration-preview.png", + "method": "scripts/capture-development.mjs + scripts/promote-development.mjs; unmodified loopback production demo, synthetic data, API/external requests blocked", + "sha256": "2b5ad02d14952ea298d9991b709f7c806cde500a80f95f9b80c6c8a7d7c20cd4", + "bytes": 126781, + "width": 1440, + "height": 1000 + }, + { + "repository": "sleepypod-ios", + "commit": "ce1d455cb69b00413f8d84630052c1878d9a5f5b", + "source": "Marketing/AppStore/output/iphone-6.9/clip-sleep-watch.mp4", + "output": "ios-watch-preview.mp4", + "method": "scripts/capture-ios-watch.py; upstream XCUITest tour on dedicated iPhone 17 Pro Max iOS 26.2 simulator, demo backend, Health sync disabled; original raw screenshot and encoded clip", + "sha256": "402c50847fd46e8d2596ab8da09b84171a808af7c82b20ec39e2e4d00cb0f13c", + "bytes": 1255936, + "width": 886, + "height": 1920, + "duration": 19.433333 + }, + { + "repository": "sleepypod-ios", + "commit": "ce1d455cb69b00413f8d84630052c1878d9a5f5b", + "source": "Marketing/AppStore/raw/iphone-6.9/dark/07-apple-watch.png", + "output": "ios-watch-preview.png", + "method": "scripts/capture-ios-watch.py; upstream XCUITest tour on dedicated iPhone 17 Pro Max iOS 26.2 simulator, demo backend, Health sync disabled; original raw screenshot and encoded clip", + "sha256": "bba58073c87c1966e7bac39e29f4965b153541132f0f51188a13268df96f7305", + "bytes": 366350, + "width": 1320, + "height": 2868 } ] } diff --git a/capture/recent-pr-review.json b/capture/recent-pr-review.json new file mode 100644 index 0000000..4dcf957 --- /dev/null +++ b/capture/recent-pr-review.json @@ -0,0 +1,89 @@ +{ + "reviewedAt": "2026-10-14", + "scope": "Documentation and demonstration coverage of recent product PRs against product main; site PR review and merge. This is not a full audit or approval of product code.", + "baselines": { + "core": { + "main": "9bbc7232924fd2b18c7176c259556151aecc6412", + "development": "f905a776a8eca61cf0e855726b06e7774f241c52", + "promotionPR": 820 + }, + "ios": { + "main": "bb81c0aa08f71843283559c4cd45a723b26bb31b", + "development": "ce1d455cb69b00413f8d84630052c1878d9a5f5b" + } + }, + "coverage": [ + { + "repository": "sleepypod/core", + "prs": [802, 803, 814, 821, 826], + "topic": "Watch reference data and vitals exploration", + "guide": "/core/development-preview/", + "media": ["core-vitals-preview.mp4"], + "status": "development preview; upload dialog demonstrated without uploading Health data" + }, + { + "repository": "sleepypod/core", + "prs": [815, 816, 817, 822, 823, 828, 829, 830, 831], + "topic": "Stage rules, biometric profiles, references, replay and activation", + "guide": "/core/development-preview/", + "media": ["core-calibration-preview.mp4"], + "status": "development preview; synthetic reference import and candidate save demonstrated; no claim of live adoption or validated accuracy" + }, + { + "repository": "sleepypod/core", + "prs": [825, 827, 832], + "topic": "MCP sleep curves, saved templates and notes", + "guide": "/core/development-preview/", + "media": [], + "status": "development preview; existing schedule video retained at its own revision" + }, + { + "repository": "sleepypod/core", + "prs": [804, 805, 806, 807, 808, 813], + "topic": "Sleep detection fixes, away controls and side tabs", + "guide": "/core/development-preview/", + "media": [], + "status": "development preview; existing Stage video retains its own revision" + }, + { + "repository": "sleepypod/core", + "prs": [833, 834], + "topic": "HTTP and sensor WebSocket Host/Origin allowlist", + "guide": "/core/development-preview/", + "media": [], + "status": "development preview; pinned deployment reference; no visual workflow required" + }, + { + "repository": "sleepypod/core", + "prs": [811, 812, 818, 819], + "topic": "Build and dependency maintenance", + "guide": null, + "media": [], + "status": "no new user workflow; current development demo production build verified" + }, + { + "repository": "sleepypod/ios", + "prs": [58, 59, 60, 61, 62], + "topic": "Health resync/rebuild and Watch comparison", + "guide": "/ios/using-the-app/", + "media": ["ios-watch-preview.mp4"], + "status": "development preview; real simulator demo; no Health writes" + }, + { + "repository": "sleepypod/sleepypod.github.io", + "prs": [39], + "topic": "Firmware recovery claims and modified Slot B stop condition", + "guide": "/core/firmware-updates/", + "media": [], + "status": "corrected and merged at 945ff5dc6c2f2b6632249efa56fdd7a37eb73664; no new UI to record" + } + ], + "notReleased": [ + { + "repository": "sleepypod/core", + "prs": [809, 810], + "reason": "Open feature/fix branches outside the reviewed dev and main snapshots; not presented as released behavior." + } + ], + "preservedMedia": "Existing screenshots and clips retain their original pinned revisions; newly generated media are additive." +} diff --git a/content/core/_meta.ts b/content/core/_meta.ts index b29beef..c881839 100644 --- a/content/core/_meta.ts +++ b/content/core/_meta.ts @@ -10,6 +10,7 @@ export default { temperature: 'Temp', base: 'Base (experimental)', tour: 'Video tour', + 'development-preview': 'Development preview', autopilot: 'Autopilot', schedules: 'Schedule', biometrics: 'Sleep', diff --git a/content/core/biometrics.mdx b/content/core/biometrics.mdx index 041a159..5f923ab 100644 --- a/content/core/biometrics.mdx +++ b/content/core/biometrics.mdx @@ -5,6 +5,8 @@ description: 'Review nights, sleep stages, and vitals by night, week, or month, # Sleep +Looking for newer development controls? See the [development preview](/core/development-preview/) for their status and workflows. + **Sleep** shows what the Pod measured while you slept. It has two views: **Nights** for sleep records and **Biometrics** for heart rate, HRV, and breathing. Everything is processed on the Pod; see [Follow your data](/core/data-flow/) for how. Choose whose data you see with **Left** / **Right** at the top. The switch is hidden while one side is in [Away mode](/core/settings/sides/#away-mode). diff --git a/content/core/development-preview.mdx b/content/core/development-preview.mdx new file mode 100644 index 0000000..109d5ff --- /dev/null +++ b/content/core/development-preview.mdx @@ -0,0 +1,113 @@ +--- +title: 'Development preview' +description: 'Try the upcoming Core biometrics, calibration, and saved-curve workflows with their release status made explicit.' +--- + +import { Callout } from 'nextra/components' + +# Development preview + + + These features are on Core’s development branch at `f905a776`, reviewed against `main` at + `9bbc7232` on October 14, 2026. They are awaiting release in [Core PR + #820](https://github.com/sleepypod/core/pull/820). An installation from `main` may not have these + controls yet. + + +## Explore vitals and compare Watch data + +Open **Sleep → Biometrics**, select the sleeper, and choose **Night**, **Week**, or **Month**. Select a session to focus its full time span. The vitals explorer lets you inspect heart rate, HRV, and breathing; the **Raw log** keeps the per-minute readings separate from the chart. + +**Upload Apple Watch data** imports a Health export for the selected sleeper: + +1. In Apple Health on the iPhone, open your profile and choose **Export All Health Data**. +2. Transfer the export to the device running the Core browser. +3. Choose **Upload Apple Watch data**, verify the sleeper and side, and select `export.zip` or extracted `export.xml` (up to 512 MiB). +4. Select the last 7, 30, or 90 days, then **Upload and sync**. Keep the window open while the Pod processes the file. +5. Read the result: new samples, previously imported records, and any conflicting stage batches are reported separately. + +The full export is uploaded to your Pod. This workflow imports Watch heart rate and sleep stages, then removes the uploaded file. It preserves the Pod’s observations and skips duplicate imported records. Watch heart rate overlays the Pod’s heart rate; imported stages provide reference data. This is a one-time import, so upload a fresh export to add later nights. + +This Core import is separate from the iOS app’s Apple Health sync and its Watch comparison screen. + + + Real development UI with synthetic demo data. Switch from Week to Night, zoom the chart, reset the + zoom, then open the Watch import dialog and choose seven days. This clip does not upload a Health + export. + + +## Biometric calibration + +Open **Settings → Biometric calibration** and select a side. This is personalization of biometric processing and stage analysis; it is distinct from calibrating the physical presence sensors on an empty bed. + +| View | What to do | +| ---------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| **Overview** | Inspect the active profile, requested changes, and processor acknowledgment. | +| **References** | Preview and import reference records with a sleeper identifier, source, metric, and time span; inspect saved imports. | +| **Profiles** | Review candidate parameter changes and save a revision without applying it. The sleep-stage rule editor also lives here. | +| **Backtests** | Select sessions and references, freeze inputs into a dataset, configure an experiment, queue a replay, and review its report. | +| **History** | Inspect recorded profile changes and their outcomes. | +| **Observations** | Inspect prospective shadow runs and their comparisons. These do not replace the live profile. | + + + Real development demo: open References, preview two synthetic records, save the import, then open + Profiles, change a candidate parameter, review and save it without applying. Return to Overview: + the saved profile is still System defaults. No live processor or hardware is involved. + + +### Review before applying + +Saving a candidate and running a backtest do not apply that candidate to the live processor. Compare the candidate and baseline on the same retained inputs. Check coverage, metric definitions, excluded data, and the report’s evidence status before interpreting an apparent improvement. Missing raw data can prevent waveform replay; synthetic demo results do not establish accuracy on a real sleeper. + +**Apply unvalidated revision** is explicitly a manual choice without validation evidence. It requires reviewing the exact revision and acknowledging that choice. Saving an activation requests a change; **Waiting for delivery** is not **Applied**. Overview reports processor adoption separately. A conflicting save requires reloading the current profile before trying again. + +The recovery controls distinguish restoring the previous profile from returning to defaults. Review the comparison and resulting acknowledgment in either case. Historical observations keep their provenance; a replay result is not a replacement measurement from the bed. + +### Sleep-stage rules + +Under **Profiles**, the ordered rules read as sentences. Choose a preset or edit a condition, inspect the preview for recent nights, and compare the draft with the saved rules before saving. Quick adjustments, signal limits, transition cleanup, and the change log explain how the rules affect the classification. + +A saved rule change can remain pending until the processor adopts it. Displayed nights continue using the acknowledged rules while delivery is pending. Watch agreement only covers overlapping reference data; missing reference coverage is not perfect agreement. Pod HRV and Watch HRV can use different definitions, so check the metric identity before comparing values. + +## Saved curves and notes + +Named saved curves are templates stored on the Pod. They are not assigned to a sleeper or day and do not run by themselves. Schedule shows them with **NO DAYS**. Choose **Use** on a saved curve to open it in the editor, choose the side and days, review the points and end action, then save the schedule. + +Deleting a saved template does not delete schedules already created from it. Curve **Notes** can record why you chose a curve or what you want to compare next; notes do not change temperature behavior. + +For connected agents, `get_saved_curves` lists templates and `manage_saved_curve` saves or deletes one. `apply_sleep_curve` supports a preview before applying a curve to selected days. Review the preview, including day conflicts and the final temperature action, before requesting application. + +## Other changes in this preview + +- **Away mode:** Temperature’s side card and Stage panel expose the away toggle. Check the sleeper before changing it. +- **Settings:** sections with per-side values have their own side tabs, replacing the previous global side switch. +- **Sleep detection:** changes address quiet-sleeper re-entry, bed-empty transitions, pump-time movement scoring, and classification when calibration quality is unknown. These fixes do not promise accurate classification for every sleeper. +- **Network access:** HTTP and sensor WebSocket requests are checked against the Host/Origin allowlist. A custom hostname or reverse proxy may need configuration; see the pinned deployment guide below. +- **Agents:** `get_sleep_stage_rules`, `preview_sleep_stage_rules`, and `set_sleep_stage_rules` expose inspection, dry-run comparison, and a separate save operation. The calibration API also exposes versioned profiles, references, datasets, replay jobs, activation receipts, and rollback. + +## Pinned implementation references + +All Core references below identify the reviewed development revision, not a released version. + +- [Vitals and Watch import UI](https://github.com/sleepypod/core/tree/f905a776a8eca61cf0e855726b06e7774f241c52/src/components/biometrics) +- [Calibration UI](https://github.com/sleepypod/core/tree/f905a776a8eca61cf0e855726b06e7774f241c52/src/components/biometrics/Calibration) +- [Sleep-stage editor](https://github.com/sleepypod/core/blob/f905a776a8eca61cf0e855726b06e7774f241c52/src/components/Settings/SleepStageRules.tsx) +- [Schedule UI](https://github.com/sleepypod/core/tree/f905a776a8eca61cf0e855726b06e7774f241c52/src/components/Schedule) +- [MCP tools](https://github.com/sleepypod/core/tree/f905a776a8eca61cf0e855726b06e7774f241c52/src/mcp/tools) +- [Reference import contracts](https://github.com/sleepypod/core/blob/f905a776a8eca61cf0e855726b06e7774f241c52/docs/biometrics-reference-imports.md) +- [Datasets and replay inputs](https://github.com/sleepypod/core/blob/f905a776a8eca61cf0e855726b06e7774f241c52/docs/biometrics-datasets.md) +- [Backtest lifecycle and resource limits](https://github.com/sleepypod/core/blob/f905a776a8eca61cf0e855726b06e7774f241c52/docs/biometrics-backtest-jobs.md) +- [Activation and processor acknowledgment](https://github.com/sleepypod/core/blob/f905a776a8eca61cf0e855726b06e7774f241c52/docs/biometrics-runtime-activation.md) +- [Calibration API](https://github.com/sleepypod/core/blob/f905a776a8eca61cf0e855726b06e7774f241c52/docs/biometrics-calibration-api.md) +- [Validation evidence and limitations](https://github.com/sleepypod/core/blob/f905a776a8eca61cf0e855726b06e7774f241c52/docs/biometrics-calibration-readiness.md) +- [Network deployment configuration](https://github.com/sleepypod/core/blob/f905a776a8eca61cf0e855726b06e7774f241c52/docs/DEPLOYMENT.md) diff --git a/content/core/mcp.mdx b/content/core/mcp.mdx index ecc6b8c..05557d9 100644 --- a/content/core/mcp.mdx +++ b/content/core/mcp.mdx @@ -6,6 +6,8 @@ import { Tabs } from 'nextra/components' # MCP for agents +Looking for newer development controls? See the [development preview](/core/development-preview/) for their status and workflows. + Core includes a [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server. Any MCP host, such as Claude Desktop, Claude Code, Codex, Cursor, or a Home Assistant agent, can connect to it and read or control the Pod in plain language. "How did I sleep?", "Set my side to 68 for an hour", and "Is everything OK?" each map to one tool call. ## Connect a host diff --git a/content/core/schedules.mdx b/content/core/schedules.mdx index ec7679f..9622d2b 100644 --- a/content/core/schedules.mdx +++ b/content/core/schedules.mdx @@ -5,6 +5,8 @@ description: 'Build nightly temperature curves, set vibration alarms, and check # Schedule +Looking for newer development controls? See the [development preview](/core/development-preview/) for their status and workflows. + **Schedule** holds your nightly temperature curves and vibration alarms. They run on the Pod, so closing the app does not stop them. + Simulator demo with synthetic readings and Health sync disabled. Open Compare with Apple Watch + from Sleep, inspect the stage overlay and minutes-by-stage table, scroll through the heart-rate + and HRV charts, then return to Sleep. Silent video; no real Health data is shown. + + +**Pinned source references:** [Health sync and rebuild](https://github.com/sleepypod/ios/blob/ce1d455cb69b00413f8d84630052c1878d9a5f5b/Sleepypod/Services/HealthSyncService.swift) · [Health settings and confirmation](https://github.com/sleepypod/ios/blob/ce1d455cb69b00413f8d84630052c1878d9a5f5b/Sleepypod/Views/Settings/HealthAccessView.swift) · [Watch matching and scoring](https://github.com/sleepypod/ios/blob/ce1d455cb69b00413f8d84630052c1878d9a5f5b/Sleepypod/Services/WatchComparison.swift) · [Comparison charts](https://github.com/sleepypod/ios/blob/ce1d455cb69b00413f8d84630052c1878d9a5f5b/Sleepypod/Views/Data/WatchComparisonView.swift) diff --git a/public/media/core-calibration-preview.mp4 b/public/media/core-calibration-preview.mp4 new file mode 100644 index 0000000..a6f6486 Binary files /dev/null and b/public/media/core-calibration-preview.mp4 differ diff --git a/public/media/core-calibration-preview.png b/public/media/core-calibration-preview.png new file mode 100644 index 0000000..16c93ba Binary files /dev/null and b/public/media/core-calibration-preview.png differ diff --git a/public/media/core-vitals-preview.mp4 b/public/media/core-vitals-preview.mp4 new file mode 100644 index 0000000..651b7c9 Binary files /dev/null and b/public/media/core-vitals-preview.mp4 differ diff --git a/public/media/core-vitals-preview.png b/public/media/core-vitals-preview.png new file mode 100644 index 0000000..208174c Binary files /dev/null and b/public/media/core-vitals-preview.png differ diff --git a/public/media/ios-watch-preview.mp4 b/public/media/ios-watch-preview.mp4 new file mode 100644 index 0000000..2ba578c Binary files /dev/null and b/public/media/ios-watch-preview.mp4 differ diff --git a/public/media/ios-watch-preview.png b/public/media/ios-watch-preview.png new file mode 100644 index 0000000..31347d7 Binary files /dev/null and b/public/media/ios-watch-preview.png differ diff --git a/scripts/capture-development.mjs b/scripts/capture-development.mjs new file mode 100644 index 0000000..c985923 --- /dev/null +++ b/scripts/capture-development.mjs @@ -0,0 +1,117 @@ +// Real Core demo UI, pinned source, synthetic data, and loopback-only traffic. +import { chromium, expect } from '@playwright/test' +import { mkdir, writeFile } from 'node:fs/promises' +const commit = 'f905a776a8eca61cf0e855726b06e7774f241c52' +const origin = new URL(process.env.CORE_CAPTURE_URL || 'http://127.0.0.1:3214') +if (!['127.0.0.1', 'localhost'].includes(origin.hostname)) throw Error('Loopback only') +const directory = '.capture/development' +await mkdir(directory, { recursive: true }) +const browser = await chromium.launch({ args: ['--enable-unsafe-swiftshader'] }) +const records = [] +async function record(name, route, run) { + const context = await browser.newContext({ + viewport: { width: 1440, height: 1000 }, + colorScheme: 'dark', + timezoneId: 'America/Los_Angeles', + recordVideo: { dir: directory, size: { width: 1440, height: 1000 } }, + }) + await context.route('**/*', (r) => { + const u = new URL(r.request().url()) + return u.origin === origin.origin && !u.pathname.startsWith('/api/') ? r.continue() : r.abort() + }) + const page = await context.newPage() + const errors = [] + page.on('pageerror', (e) => { + if (!e.message.startsWith('Minified React error #418')) errors.push(e.message) + }) + await page.clock.install({ time: new Date('2026-10-14T16:30:00Z') }) + const before = Date.now() + const response = await page.goto(new URL(route, origin).href) + expect(response.status()).toBe(200) + await page.waitForTimeout(3500) + const start = (Date.now() - before) / 1000 + await page.screenshot({ path: `${directory}/${name}.png` }) + await run(page) + await page.waitForTimeout(2500) + const duration = (Date.now() - before) / 1000 - start + const video = page.video() + await context.close() + expect(errors).toEqual([]) + records.push({ name, route, raw: await video.path(), start, duration }) + console.log('Captured', name) +} +const pause = (p) => p.waitForTimeout(1800) +try { + await record('core-vitals-preview', '/en/sleep?view=biometrics', async (p) => { + await expect( + p.getByRole('button', { name: 'Upload Apple Watch data', exact: true }), + ).toBeVisible() + await pause(p) + await p.getByRole('tab', { name: 'Night', exact: true }).click() + await pause(p) + await p.getByRole('button', { name: 'Zoom in', exact: true }).click() + await pause(p) + await p.getByRole('button', { name: 'Reset zoom', exact: true }).click() + await p.getByRole('button', { name: 'Upload Apple Watch data', exact: true }).click() + await expect(p.getByRole('dialog')).toContainText('Import for') + await p.getByRole('combobox', { name: 'Import period', exact: true }).selectOption('7') + await pause(p) + }) + await record( + 'core-calibration-preview', + '/en/settings?section=biometric-calibration', + async (p) => { + const view = async (name) => { + await p + .getByRole('navigation', { name: 'Calibration views' }) + .getByRole('button', { name, exact: true }) + .click() + await pause(p) + } + await expect(p.getByText('SIMULATED DEMO: no live processor.', { exact: true })).toBeVisible() + await pause(p) + await view('References') + await p.getByLabel('Sleeper identifier', { exact: true }).fill('example-sleeper') + await p.getByLabel('Reference source', { exact: true }).fill('synthetic-reference') + await p + .getByLabel('Reference records', { exact: true }) + .fill( + 'recordKey,metric,value,unit,timestamp,start,end\nhr-1,heart_rate,62,bpm,2026-10-13T23:00:00Z,,\nstage-1,sleep_stages,light,stage,,2026-10-13T23:00:00Z,2026-10-13T23:10:00Z', + ) + await p.getByRole('button', { name: 'Preview import', exact: true }).click() + await expect(p.getByRole('region', { name: 'Import preview' })).toContainText( + 'Accepted: 2; rejected: 0', + ) + await pause(p) + await p.getByRole('button', { name: 'Save previewed import', exact: true }).click() + await expect( + p.getByRole('status').filter({ hasText: 'Reference import saved:' }), + ).toBeVisible() + await view('Profiles') + await p.getByLabel('heart_rate.trackingToleranceBpm', { exact: true }).fill('17') + await pause(p) + await p.getByRole('button', { name: 'Review candidate', exact: true }).click() + await pause(p) + await p.getByRole('button', { name: 'Save candidate without applying', exact: true }).click() + await expect(p.getByRole('status').filter({ hasText: 'Candidate saved:' })).toBeVisible() + await pause(p) + await view('Overview') + await expect(p.getByText('System defaults', { exact: true })).toBeVisible() + }, + ) +} finally { + await browser.close() + await writeFile( + `${directory}/capture.json`, + JSON.stringify( + { + sourceCommit: commit, + method: + 'Unmodified Core production demo; local in-browser simulation; API and external network requests blocked', + records, + }, + null, + 2, + ) + '\n', + ) +} diff --git a/scripts/capture-ios-watch.py b/scripts/capture-ios-watch.py new file mode 100644 index 0000000..cee3452 --- /dev/null +++ b/scripts/capture-ios-watch.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +"""Use the pinned iOS capture pipeline with an explicitly selected disposable simulator. +IOS_SOURCE_ROOT points to iOS ce1d455; IOS_CAPTURE_UDID must be a dedicated test device. +Build once with the upstream script before using --skip-build here. +""" +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import sys + +root = Path(os.environ['IOS_SOURCE_ROOT']).resolve() +revision = subprocess.check_output(['git', '-C', str(root), 'rev-parse', 'HEAD'], text=True).strip() +if revision != 'ce1d455cb69b00413f8d84630052c1878d9a5f5b': + raise RuntimeError('Use the pinned iOS revision') +udid = os.environ['IOS_CAPTURE_UDID'] +listing = json.loads(subprocess.check_output(['xcrun', 'simctl', 'list', 'devices', '-j'])) +device = next(d for group in listing['devices'].values() for d in group if d['udid'] == udid) +if device['name'] != 'sleepypod Docs Watch': + raise RuntimeError('Only the dedicated sleepypod Docs Watch simulator may be reset') +spec = importlib.util.spec_from_file_location('ios_capture', root / 'Marketing/AppStore/capture.py') +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) +module.device = lambda name: device +sys.argv = [str(module.__file__), '--skip-build', '--video', '--only', 'clip-sleep-watch', + '--only', '07-apple-watch', '--appearance', 'dark'] +module.main() diff --git a/scripts/promote-development.mjs b/scripts/promote-development.mjs new file mode 100644 index 0000000..250410b --- /dev/null +++ b/scripts/promote-development.mjs @@ -0,0 +1,114 @@ +import { readFile, writeFile, copyFile } from 'node:fs/promises' +import { createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import sharp from 'sharp' +import { relative } from 'node:path' +const manifest = JSON.parse(await readFile('capture/manifest.json', 'utf8')) +const capture = JSON.parse(await readFile('.capture/development/capture.json', 'utf8')) +async function add(output, source, repository, commit, method) { + const path = `public/media/${output}` + const bytes = await readFile(path) + let width, height, duration + if (output.endsWith('.png')) ({ width, height } = await sharp(bytes).metadata()) + else { + const probe = JSON.parse( + execFileSync( + 'ffprobe', + ['-v', 'error', '-show_streams', '-show_format', '-of', 'json', path], + { encoding: 'utf8' }, + ), + ) + const video = probe.streams.find((s) => s.codec_type === 'video') + width = video.width + height = video.height + duration = Number(probe.format.duration) + } + const entry = { + repository, + commit, + source, + output, + method, + sha256: createHash('sha256').update(bytes).digest('hex'), + bytes: bytes.length, + width, + height, + ...(duration ? { duration } : {}), + } + manifest.assets = manifest.assets.filter((a) => a.output !== output) + manifest.assets.push(entry) +} +for (const record of capture.records) { + const output = `${record.name}.mp4` + execFileSync( + 'ffmpeg', + [ + '-y', + '-ss', + String(record.start), + '-i', + record.raw, + '-t', + String(record.duration), + '-an', + '-vf', + 'fps=25', + '-c:v', + 'libx264', + '-crf', + '25', + '-pix_fmt', + 'yuv420p', + '-movflags', + '+faststart', + `public/media/${output}`, + ], + { stdio: 'ignore' }, + ) + await copyFile(`.capture/development/${record.name}.png`, `public/media/${record.name}.png`) + const method = + 'scripts/capture-development.mjs + scripts/promote-development.mjs; unmodified loopback production demo, synthetic data, API/external requests blocked' + await add(output, record.route, 'sleepypod-core', capture.sourceCommit, method) + await add(`${record.name}.png`, record.route, 'sleepypod-core', capture.sourceCommit, method) +} +const ios = process.env.IOS_SOURCE_ROOT +if (!ios) throw Error('Set IOS_SOURCE_ROOT to the pinned iOS capture checkout') +const iosCommit = 'ce1d455cb69b00413f8d84630052c1878d9a5f5b' +if ( + execFileSync('git', ['-C', ios, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim() !== iosCommit +) + throw Error('Wrong iOS revision') +for (const [source, output] of [ + ['Marketing/AppStore/output/iphone-6.9/clip-sleep-watch.mp4', 'ios-watch-preview.mp4'], + ['Marketing/AppStore/raw/iphone-6.9/dark/07-apple-watch.png', 'ios-watch-preview.png'], +]) { + await copyFile(`${ios}/${source}`, `public/media/${output}`) + await add( + output, + source, + 'sleepypod-ios', + iosCommit, + 'scripts/capture-ios-watch.py; upstream XCUITest tour on dedicated iPhone 17 Pro Max iOS 26.2 simulator, demo backend, Health sync disabled; original raw screenshot and encoded clip', + ) +} +await writeFile( + 'capture/development.json', + JSON.stringify( + { + ...capture, + records: capture.records.map((record) => ({ + ...record, + raw: relative(process.cwd(), record.raw), + })), + ios: { + sourceCommit: iosCommit, + simulator: 'iPhone 17 Pro Max / iOS 26.2', + method: 'scripts/capture-ios-watch.py; upstream testClipSleepAndWatch', + data: 'Built-in synthetic demo; Apple Health sync disabled', + }, + }, + null, + 2, + ) + '\n', +) +await writeFile('capture/manifest.json', JSON.stringify(manifest, null, 2) + '\n') diff --git a/tests/site.spec.ts b/tests/site.spec.ts index e63b264..dec64c4 100644 --- a/tests/site.spec.ts +++ b/tests/site.spec.ts @@ -81,6 +81,8 @@ test('videos decode and play to the end', async ({ page }) => { '/core/tour/', '/core/temperature/', '/core/base/', + '/core/development-preview/', + '/ios/using-the-app/', ]) { await page.goto(route) for (const video of await page.locator('video').all()) {