Skip to content

Commit ec3ecfc

Browse files
committed
Merge remote-tracking branch 'origin/main' into refactor/mobile-repo-view
# Conflicts: # docs/specs/README.md
2 parents 72f0ec2 + 832c013 commit ec3ecfc

38 files changed

Lines changed: 2448 additions & 35 deletions

.github/workflows/integration-ci.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ name: Integration E2E (stub adapter)
77
# MANUAL (workflow_dispatch) only — not a per-PR gate. The cheap Linux gates
88
# (server-ci unit tests + typecheck, protocol-contract, real-pi-pinned headless
99
# smoke) cover regressions on every PR. The real-pi path stays in real-pi-e2e.yml.
10+
#
11+
# LOCAL-FIRST: prefer running this locally before pushing —
12+
# app/tool/e2e.sh --mode=stub
13+
# (see CONTRIBUTING.md → "End-to-end tests"). This CI job is a convenience
14+
# escape hatch you trigger by hand from the Actions tab, not an automatic gate.
1015

1116
on:
1217
workflow_dispatch: {}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
name: Desktop Control E2E (stub adapter)
2+
3+
# Full-stack control-plane e2e for the macOS desktop control app (SPEC-03):
4+
# the real desktop app ↔ a real daemon control socket backed by the StubAdapter
5+
# (server/test/e2e-control-server.ts). No LLM, no `pi`, no WS — the control
6+
# socket (status/devices/sessions/pair/logs) is the whole surface under test.
7+
# Counterpart to integration-ci.yml, which exercises the mobile WS stack on an
8+
# iOS simulator. macOS runners are expensive, so this is MANUAL
9+
# (workflow_dispatch) only — the cheap Linux gates (server-ci, protocol-contract)
10+
# cover the control protocol contract on every PR.
11+
#
12+
# LOCAL-FIRST: prefer running this locally before pushing —
13+
# app/tool/e2e-desktop.sh
14+
# (see CONTRIBUTING.md → "End-to-end tests"). This CI job is a convenience
15+
# escape hatch you trigger by hand from the Actions tab, not an automatic gate.
16+
17+
on:
18+
workflow_dispatch: {}
19+
20+
jobs:
21+
desktop-e2e:
22+
runs-on: macos-latest
23+
timeout-minutes: 20
24+
steps:
25+
- name: Checkout code
26+
uses: actions/checkout@v5
27+
28+
- name: Setup Flutter
29+
uses: subosito/flutter-action@v2
30+
with:
31+
flutter-version: '3.44.4'
32+
channel: 'stable'
33+
cache: true
34+
35+
- name: Setup Node.js
36+
uses: actions/setup-node@v5
37+
with:
38+
node-version: '22'
39+
40+
- name: Enable Corepack
41+
run: corepack enable
42+
43+
- name: Prepare pnpm
44+
run: corepack prepare pnpm@11.8.0 --activate
45+
46+
- name: Cache pnpm store
47+
uses: actions/cache@v5
48+
with:
49+
path: ~/.local/share/pnpm/store
50+
key: ${{ runner.os }}-pnpm-${{ hashFiles('server/pnpm-lock.yaml') }}
51+
restore-keys: |
52+
${{ runner.os }}-pnpm-
53+
54+
- name: Cache Flutter packages
55+
uses: actions/cache@v5
56+
with:
57+
path: ~/.pub-cache
58+
key: ${{ runner.os }}-flutter-${{ hashFiles('app/pubspec.lock') }}
59+
restore-keys: |
60+
${{ runner.os }}-flutter-
61+
62+
# Native Xcode build products for the macOS desktop build.
63+
- name: Cache Xcode DerivedData
64+
uses: actions/cache@v5
65+
with:
66+
path: ~/Library/Developer/Xcode/DerivedData
67+
key: ${{ runner.os }}-deriveddata-macos-${{ hashFiles('app/pubspec.lock') }}
68+
restore-keys: |
69+
${{ runner.os }}-deriveddata-macos-
70+
71+
- name: Install server dependencies (lockfile-locked)
72+
working-directory: server
73+
run: pnpm secure:install
74+
75+
- name: Install app dependencies (lockfile-locked)
76+
working-directory: app
77+
run: flutter pub get --enforce-lockfile
78+
79+
- name: Desktop control-plane e2e (stub adapter)
80+
run: app/tool/e2e-desktop.sh

AGENTS.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,13 @@ Strong criteria = independent loop. Weak criteria ("make it work") = constant cl
6868
---
6969

7070
**Rules working if:** fewer needless changes in diffs, fewer rewrites from overcomplication, clarifying questions before code, every behavior change ships with passing test.
71+
72+
## Cursor Cloud specific instructions
73+
74+
Standard build/run/test commands live in [`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md); this section only covers cloud-specific caveats. The update script already runs `pnpm install` (server) and `flutter pub get` (app) on startup.
75+
76+
- **Toolchain locations (Linux VM):** Flutter `3.44.4` is installed at `~/flutter` and is on `PATH` via `~/.bashrc`. Non-interactive shells may not source `~/.bashrc`, so use the absolute binary `~/flutter/bin/flutter` when a command can't find `flutter`. The server uses pnpm `11.8.0` via corepack (plain `npm install` is blocked by a preinstall guard); Node is 22.x.
77+
- **Server (`server/`) runs fully on Linux.** Lint = `pnpm typecheck` (there is no ESLint). `pnpm test`, `pnpm build`, and `pnpm dev`/`pnpm start` all work headless.
78+
- **App (`app/`) has no Linux GUI target** (iOS-first, plus macOS desktop / Android — all needing macOS or a device). On this VM only `flutter analyze --no-pub` (lint) and `flutter test --no-pub` are runnable; `flutter run` and the `app/tool/e2e.sh` simulator flows cannot run here.
79+
- **Keyless end-to-end server loop:** `pnpm exec tsx test/e2e-server.ts --mode stub --project <path>` starts the WSS server on port `9787` with the in-process `StubAdapter` (deterministic echo/STREAM/THINK replies — no LLM key, no `pi` binary) and seeds a paired device with bearer `e2e-token`. Drive it with any WSS client (`{t:"hello",bearer}``sub``send.message`). The real `pi`/`codex`/`claude` adapters need external agent binaries/API keys that are not present on the VM.
80+
- **Harmless startup noise:** the server prints `/bin/sh: 1: tailscale: not found` and falls back to a loopback-only listener — expected on the VM (no Tailscale).

CONTRIBUTING.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,33 @@ covers all future contributions.
3434
4. Open a pull request describing **what** changed and **why**.
3535
5. Sign the CLA when the bot prompts you.
3636

37+
### End-to-end tests (run locally)
38+
39+
The full-stack e2e suites drive the real Flutter app against a real TLS/socket
40+
server. They need a macOS host (an iOS simulator or the macOS desktop build),
41+
so CI would run them on **expensive** macOS runners. To keep CI cheap they are
42+
**local-first**: not per-PR gates, only manually triggerable via
43+
`workflow_dispatch`. The cheap Linux gates (`server-ci`, `protocol-contract`,
44+
`real-pi-pinned`) cover regressions on every PR. Run the macOS suites locally
45+
before pushing changes that touch the app ↔ server boundary:
46+
47+
- **Mobile stub e2e** (real app ↔ TLS WS server, StubAdapter, iOS simulator):
48+
```sh
49+
cd server && pnpm secure:install # once
50+
cd app && flutter pub get # once
51+
app/tool/e2e.sh --mode=stub # pick a sim with MAKIT_SIM_NAME="iPhone 17 Pro"
52+
```
53+
- **Desktop control-plane e2e** (real macOS control app ↔ daemon control
54+
socket, StubAdapter):
55+
```sh
56+
app/tool/e2e-desktop.sh
57+
```
58+
- **Real-pi e2e** (genuine `pi` binary + local fake model) — optional, requires
59+
`pi` on `PATH`: `app/tool/e2e.sh --mode=real`.
60+
61+
The matching CI workflows (`integration-ci`, `integration-desktop-ci`, and the
62+
macOS job in `real-pi-e2e`) can also be run on demand from the Actions tab.
63+
3764
## Engineering standards
3865

3966
These are enforced (see [`AGENTS.md`](./AGENTS.md)):
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
// Full-stack control-plane e2e for the macOS desktop control app (SPEC-03).
2+
//
3+
// Counterpart to the mobile stub suite (integration_test/stub/): instead of the
4+
// WS client, this drives the real desktop control screens against a real daemon
5+
// control socket served by `server/test/e2e-control-server.ts`. The whole stack
6+
// is genuine — `MakitControlClient` speaks the real NDJSON protocol over the
7+
// real unix socket to the real `createServerBackend`, and the real
8+
// `DesktopDashboard`/`DesktopController` render the responses.
9+
//
10+
// The socket path is injected by `app/tool/e2e-desktop.sh` via
11+
// `--dart-define=MAKIT_CONTROL_SOCK`. We wire the same client/controller
12+
// `runDesktopApp` builds, but pump `DesktopDashboard` directly so the test
13+
// needs no tray/window native plugins.
14+
//
15+
// ignore_for_file: depend_on_referenced_packages
16+
import 'package:flutter/material.dart';
17+
import 'package:flutter_riverpod/flutter_riverpod.dart';
18+
import 'package:flutter_test/flutter_test.dart';
19+
import 'package:integration_test/integration_test.dart';
20+
import 'package:makit/control/control_client.dart';
21+
import 'package:makit/control/reconnecting_control_client.dart';
22+
import 'package:makit/desktop/daemon/daemon_lifecycle.dart';
23+
import 'package:makit/desktop/desktop_app.dart';
24+
import 'package:makit/desktop/desktop_controller.dart';
25+
import 'package:makit/desktop/screens/providers.dart';
26+
27+
const _socketPath = String.fromEnvironment('MAKIT_CONTROL_SOCK');
28+
const _timeout = Duration(seconds: 20);
29+
30+
/// Pump at 100ms steps until [finder] matches or [_timeout] expires.
31+
Future<void> _pumpUntil(
32+
WidgetTester tester,
33+
Finder finder, {
34+
String? reason,
35+
}) async {
36+
final deadline = DateTime.now().add(_timeout);
37+
while (DateTime.now().isBefore(deadline)) {
38+
await tester.pump(const Duration(milliseconds: 100));
39+
if (finder.evaluate().isNotEmpty) {
40+
await tester.pump(const Duration(milliseconds: 100));
41+
return;
42+
}
43+
}
44+
fail(reason ?? 'timed out waiting for $finder');
45+
}
46+
47+
void main() {
48+
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
49+
50+
testWidgets('desktop dashboard drives a real daemon control socket', (
51+
tester,
52+
) async {
53+
expect(
54+
_socketPath.isNotEmpty,
55+
isTrue,
56+
reason: 'MAKIT_CONTROL_SOCK must be passed via --dart-define',
57+
);
58+
59+
final client = ReconnectingControlClient(
60+
create: () => MakitControlClient(socketPath: _socketPath),
61+
connect: (c) => (c as MakitControlClient).connect(),
62+
dispose: (c) => (c as MakitControlClient).dispose(),
63+
);
64+
final controller = DesktopController(
65+
client: client,
66+
lifecycle: DaemonLifecycle(resolver: MakitCliResolver()),
67+
);
68+
addTearDown(() async {
69+
controller.dispose();
70+
await client.close();
71+
});
72+
controller.startPolling();
73+
74+
await tester.pumpWidget(
75+
ProviderScope(
76+
overrides: [
77+
controlClientProvider.overrideWithValue(client),
78+
desktopControllerProvider.overrideWithValue(controller),
79+
],
80+
child: const MaterialApp(home: DesktopDashboard()),
81+
),
82+
);
83+
84+
// status → the header renders the live pid over the real socket.
85+
await _pumpUntil(
86+
tester,
87+
find.textContaining('Server running (pid'),
88+
reason:
89+
'header never showed a running daemon — control socket handshake '
90+
'or status verb failed',
91+
);
92+
93+
// devices.list → the seeded device shows on the (default) Devices tab.
94+
await _pumpUntil(
95+
tester,
96+
find.text('e2e phone'),
97+
reason: 'devices.list did not surface the seeded device',
98+
);
99+
100+
// pair.mint → the QR tab mints and renders a makit:// pair url.
101+
await tester.tap(find.text('Pair QR'));
102+
await _pumpUntil(
103+
tester,
104+
find.textContaining('makit://pair'),
105+
reason: 'pair.mint did not return a pair url',
106+
);
107+
108+
// sessions.list → the Sessions tab lists the seeded default session.
109+
await tester.tap(find.textContaining('Sessions ('));
110+
await _pumpUntil(
111+
tester,
112+
find.text('new session'),
113+
reason: 'sessions.list did not surface the default session',
114+
);
115+
});
116+
}

app/integration_test/e2e_helpers.dart

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ const _messageTimeout = Duration(seconds: 15);
1313
Future<void> launchMakit(WidgetTester tester) async {
1414
app.main();
1515
await tester.pump(const Duration(milliseconds: 100));
16+
// The test creds are seeded as paired, so onboarding skips the pair step —
17+
// but on a fresh simulator notification permission is notDetermined, so the
18+
// wizard now stops at the skippable notifications gate before Home. Dismiss
19+
// it ("Not now") so the suite reaches the session list as before.
20+
await _skipNotificationsStep(tester);
1621
await pumpUntil(
1722
tester,
1823
find.text('new session'),
@@ -23,6 +28,23 @@ Future<void> launchMakit(WidgetTester tester) async {
2328
);
2429
}
2530

31+
/// Dismiss the notifications onboarding gate if it's showing. No-op once the
32+
/// app has already advanced past it, so it's safe to call unconditionally.
33+
Future<void> _skipNotificationsStep(WidgetTester tester) async {
34+
final skip = find.widgetWithText(TextButton, 'Not now');
35+
final deadline = DateTime.now().add(_connectionTimeout);
36+
while (DateTime.now().isBefore(deadline)) {
37+
await tester.pump(const Duration(milliseconds: 100));
38+
if (skip.evaluate().isNotEmpty) {
39+
await tester.tap(skip);
40+
await tester.pump(const Duration(milliseconds: 100));
41+
return;
42+
}
43+
// Already past the gate (session list rendering) — nothing to skip.
44+
if (find.text('new session').evaluate().isNotEmpty) return;
45+
}
46+
}
47+
2648
/// Open the first session in the list (the stub server pre-creates one
2749
/// "new session" entry).
2850
Future<void> openFirstSession(WidgetTester tester) async {
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/// Formats the server connection endpoint for display in the desktop UI.
2+
///
3+
/// The desktop chat client connects over loopback, so a bound host of
4+
/// `127.0.0.1` (or an empty/absent host) is shown as `localhost`. Returns
5+
/// `null` when there is no port to show (not yet connected).
6+
library;
7+
8+
String? formatEndpoint(String? host, int? port) {
9+
if (port == null) return null;
10+
final h =
11+
(host == null || host.isEmpty || host == '127.0.0.1' || host == '::1')
12+
? 'localhost'
13+
: host;
14+
return '$h:$port';
15+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/// Orders the two things the desktop chat window needs before it can talk to a
2+
/// harness: (1) the local daemon must be running, and (2) this desktop must be
3+
/// paired with it over loopback.
4+
///
5+
/// Kept separate from the widget so the ordering guard — never attempt pairing
6+
/// if the daemon failed to come up — is unit-testable without a real socket.
7+
library;
8+
9+
/// Sequences daemon startup then loopback self-pairing.
10+
class DesktopChatBootstrap {
11+
/// Creates a bootstrap.
12+
///
13+
/// [ensureDaemonRunning] starts the daemon if needed and resolves to whether
14+
/// it is running. [ensurePaired] self-pairs over loopback (a no-op when a
15+
/// bearer already exists).
16+
DesktopChatBootstrap({
17+
required Future<bool> Function() ensureDaemonRunning,
18+
required Future<void> Function() ensurePaired,
19+
}) : _ensureDaemonRunning = ensureDaemonRunning,
20+
_ensurePaired = ensurePaired;
21+
22+
final Future<bool> Function() _ensureDaemonRunning;
23+
final Future<void> Function() _ensurePaired;
24+
25+
/// Runs the sequence. Returns `true` once the daemon is running and pairing
26+
/// has been ensured; `false` (without pairing) if the daemon never came up.
27+
Future<bool> run() async {
28+
final running = await _ensureDaemonRunning();
29+
if (!running) return false;
30+
await _ensurePaired();
31+
return true;
32+
}
33+
}

0 commit comments

Comments
 (0)