test(client-app): 웹 알림 smoke host를 추가한다

Flutter Web permission/display/click smoke를 반복 검증할 수 있도록 테스트 호스트 UI와 위젯 테스트를 갱신한다.

리뷰 완료 산출물을 archive로 이동해 마일스톤 test-host 완료 근거를 남긴다.
This commit is contained in:
toki 2026-06-07 20:56:22 +09:00
parent 9017482f4b
commit 9a2272d979
9 changed files with 351 additions and 42 deletions

View file

@ -42,29 +42,29 @@ task=m-flutter-web-notification, plan=1, tag=TEST_HOST
| 항목 | 완료 여부 |
|------|---------|
| [TEST_HOST-1] Web smoke host UI | [ ] |
| [TEST_HOST-2] Widget test contract update | [ ] |
| [TEST_HOST-1] Web smoke host UI | [x] |
| [TEST_HOST-2] Widget test contract update | [x] |
## 구현 체크리스트
- [ ] `apps/flutter-test/lib/main.dart`에 web notification permission refresh/request, sample foreground display, display result, opened/click routing 상태 표시를 추가한다.
- [ ] `apps/flutter-test/test/widget_test.dart`를 새 smoke scaffold에 맞춰 갱신하고 permission/display 버튼과 opened routing 표시를 검증한다. 중간 검증: `cd apps/flutter-test && flutter test`
- [ ] 최종 검증으로 `bin/test`와 `bin/lint`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
- [x] `apps/flutter-test/lib/main.dart`에 web notification permission refresh/request, sample foreground display, display result, opened/click routing 상태 표시를 추가한다.
- [x] `apps/flutter-test/test/widget_test.dart`를 새 smoke scaffold에 맞춰 갱신하고 permission/display 버튼과 opened routing 표시를 검증한다. 중간 검증: `cd apps/flutter-test && flutter test`
- [x] 최종 검증으로 `bin/test`와 `bin/lint`를 실행한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G04_N.log`로 아카이브한다.
- [ ] active `PLAN-*-G??.md`를 `plan_local_G04_M.log`로 아카이브한다.
- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-flutter-web-notification/`를 `agent-task/archive/YYYY/MM/m-flutter-web-notification/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G04_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_local_G04_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [x] PASS이면 active task 디렉터리 `agent-task/m-flutter-web-notification/`를 `agent-task/archive/2026/06/m-flutter-web-notification_1/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-flutter-web-notification/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G04.md`와 `CODE_REVIEW-local-G04.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
@ -72,11 +72,14 @@ task=m-flutter-web-notification, plan=1, tag=TEST_HOST
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
- 계획에서 `optional _webSmokeError` 상태 필드를 언급했으나 UI에서 읽히지 않아 Dart analyzer warning(`The value of the field '_webSmokeError' isn't used`)이 발생했다. 필드를 제거하고 에러 핸들러에서 `debugPrint`로 대체했다. `_showWebSmokeNotification`은 동기 호출이므로 try/catch 자체를 제거하고 직접 setState했다.
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
- `_webSmokeError` 미포함: 계획이 "optional"로 표기한 필드이며 UI 표시 대상이 아니어서 제거. 에러 로그는 `debugPrint`로 충분하다.
- `_showWebSmokeNotification`을 `void` (비동기 없음)로 유지: `showWebForegroundNotification`이 동기 반환이므로 `unawaited` 불필요. 버튼 `onPressed`에서 직접 호출.
- `build`에서 `final plugin = NexoMessagingPlugin.instance`를 로컬 변수로 선언해 버튼 핸들러에 전달하는 패턴을 사용. 싱글톤이므로 실질적 비용 없음.
- `web-permission-status`, `web-display-status` 두 Text에만 Key를 부여. 버튼은 `widgetWithText`로 충분히 식별 가능하므로 Key 불필요.
## 사용자 리뷰 요청
@ -110,24 +113,48 @@ _구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후
### TEST_HOST-1 중간 검증
```bash
$ cd apps/flutter-test && flutter test
(output)
00:00 +0: loading /config/workspace/nexo/apps/flutter-test/test/widget_test.dart
00:00 +0: renders initial harness state
00:00 +1: renders web smoke controls
00:00 +2: updates web permission status from request
00:00 +3: updates web display result on unsupported VM
00:00 +4: opened event still drives click routing status
[PushNotification] Opened: channelId=channel-123 rootId=null
00:00 +5: updates opened and navigation state from plugin events (channel)
[PushNotification] Opened: channelId=channel-123 rootId=null
00:00 +6: updates opened and navigation state from plugin events (thread/CRT)
[PushNotification] Opened: channelId=channel-123 rootId=thread-456
00:00 +7: updates opened and navigation state from plugin events (boolean CRT)
[PushNotification] Opened: channelId=channel-123 rootId=thread-456
00:00 +8: updates device token from plugin callback
00:00 +9: All tests passed!
```
### TEST_HOST-2 중간 검증
```bash
$ cd apps/flutter-test && flutter test
(output)
(TEST_HOST-1과 동일 — 단일 test 실행으로 합산. 위 출력 참조)
```
### 최종 검증
```bash
$ bin/test
(output)
00:00 +0: loading /config/workspace/nexo/packages/messaging_flutter/test/nexo_messaging_test.dart
...
00:00 +44: All tests passed!
00:00 +0: loading /config/workspace/nexo/apps/flutter-test/test/widget_test.dart
...
00:00 +9: All tests passed!
skip services/core/server: set NEXO_CORE_GO_TEST=1 to run the full Go test suite
```
```bash
$ bin/lint
(output)
Analyzing messaging_flutter...
No issues found! (ran in 3.1s)
Analyzing flutter-test...
No issues found! (ran in 3.3s)
skip services/core/server: set NEXO_CORE_GO_LINT=1 to run go vet
```
---
@ -150,3 +177,18 @@ Sections and their ownership:
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan |
| 검증 결과 (section headings + commands) | Fixed at stub creation | Implementing agent fills in command output only; command changes require a `계획 대비 변경 사항` entry |
| 코드리뷰 결과 | Review agent appends | Not included in stub |
## 코드리뷰 결과
- 종합 판정: PASS
- 차원별 평가:
- correctness: Pass
- completeness: Pass
- test coverage: Pass
- API contract: Pass
- code quality: Pass
- plan deviation: Pass
- verification trust: Pass
- 발견된 문제:
- Nit (fixed): `apps/flutter-test/README.md:27`의 thin-host 설명이 web smoke controls 추가 후에도 "no product UI controls" 계약처럼 읽혀 최신 구현과 어긋났다. 리뷰 중 web notification permission/display smoke controls를 포함하도록 문구를 보정했다.
- 다음 단계: PASS - `complete.log` 작성 후 active task directory를 archive로 이동한다.

View file

@ -0,0 +1,42 @@
# Complete - m-flutter-web-notification
## 완료 일시
2026-06-07
## 요약
TEST_HOST 계획 1회 루프로 `apps/flutter-test` web notification smoke host UI와 widget test 계약을 완료했고 최종 판정은 PASS다.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_local_G04_0.log` | `code_review_local_G04_0.log` | PASS | web permission/display/click routing smoke scaffold 구현과 검증 통과 |
## 구현/정리 내용
- `apps/flutter-test/lib/main.dart`에 web notification support/permission/display 상태와 refresh/request/show smoke controls를 추가했다.
- `apps/flutter-test/test/widget_test.dart`가 permission request, unsupported VM display result, opened/click routing 표시를 검증하도록 갱신했다.
- 리뷰 중 `apps/flutter-test/README.md`의 thin-host contract 설명을 web smoke controls 포함 상태로 보정했다.
## 최종 검증
- `cd apps/flutter-test && flutter test` - PASS; 9개 widget test 통과.
- `bin/test` - PASS; `packages/messaging_flutter` 44개 test와 `apps/flutter-test` 9개 test 통과, `services/core/server` Go test는 `NEXO_CORE_GO_TEST=1` 미설정으로 skip.
- `bin/lint` - PASS; `messaging_flutter`와 `flutter-test` analyze 이슈 없음, `services/core/server` Go vet은 `NEXO_CORE_GO_LINT=1` 미설정으로 skip.
## Roadmap Completion
- Milestone: `agent-roadmap/phase/messaging-runtime/milestones/flutter-web-notification.md`
- Completed task ids:
- `test-host`: PASS; evidence=`agent-task/archive/2026/06/m-flutter-web-notification_1/plan_local_G04_0.log`, `agent-task/archive/2026/06/m-flutter-web-notification_1/code_review_local_G04_0.log`; verification=`cd apps/flutter-test && flutter test`, `bin/test`, `bin/lint`
- Not completed task ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -25,9 +25,11 @@ dismiss handling remain owned by `packages/messaging_flutter`.
## Thin Host Contract
The host UI should expose only the plugin integration state needed by tests:
device token, raw notification event, opened notification event, and routing
callback state. `test/widget_test.dart` protects this contract by asserting the
four status lines remain present and product-style controls are not added.
device token, raw notification event, opened notification event, routing
callback state, and web notification smoke controls for permission/display
checks. `test/widget_test.dart` protects this contract by asserting the status
lines and web smoke controls remain present without adding product-style input
flows.
The host app also proves the required SDK integration hooks stay wired without
becoming product logic:

View file

@ -24,6 +24,8 @@ class _MyAppState extends State<MyApp> {
String? _lastNotification;
String? _lastOpened;
String? _lastNavigation;
NexoMessagingWebNotificationStatus? _webNotificationStatus;
NexoMessagingWebNotificationDisplayResult? _lastWebDisplay;
StreamSubscription<Map<String, dynamic>>? _notificationSubscription;
StreamSubscription<NotificationOpenedEvent>? _openedSubscription;
@ -99,8 +101,58 @@ class _MyAppState extends State<MyApp> {
return 'server_url: ${event.serverUrl}, channel_id: ${event.channelId}, root_id: ${event.rootId}, is_crt_enabled: ${event.isCRTEnabled}';
}
String _formatWebSupport() {
if (_webNotificationStatus == null) return 'unknown';
return _webNotificationStatus!.isSupported ? 'yes' : 'no';
}
String _formatWebPermission() {
if (_webNotificationStatus == null) return 'unknown';
return _webNotificationStatus!.permission.name;
}
String _formatWebDisplay() {
if (_lastWebDisplay == null) return 'none';
if (!_lastWebDisplay!.isSupported) return 'unsupported';
if (!_lastWebDisplay!.permissionGranted) return 'permission denied';
return _lastWebDisplay!.success ? 'shown' : 'failed';
}
Future<void> _refreshWebNotificationStatus(NexoMessagingPlugin plugin) async {
try {
final status = await plugin.getWebNotificationPermissionStatus();
if (!mounted) return;
setState(() => _webNotificationStatus = status);
} catch (e) {
debugPrint('[NexoClient] Web permission refresh failed: $e');
}
}
Future<void> _requestWebNotificationPermission(
NexoMessagingPlugin plugin,
) async {
try {
final status = await plugin.requestWebNotificationPermission();
if (!mounted) return;
setState(() => _webNotificationStatus = status);
} catch (e) {
debugPrint('[NexoClient] Web permission request failed: $e');
}
}
void _showWebSmokeNotification(NexoMessagingPlugin plugin) {
final result = plugin.showWebForegroundNotification({
'title': 'Nexo Smoke',
'body': 'Web notification smoke test',
'server_url': 'https://nexo.example.com',
'channel_id': 'smoke-channel',
});
setState(() => _lastWebDisplay = result);
}
@override
Widget build(BuildContext context) {
final plugin = NexoMessagingPlugin.instance;
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text('Nexo client')),
@ -111,6 +163,29 @@ class _MyAppState extends State<MyApp> {
Text('Last notification: ${_lastNotification ?? 'none'}'),
Text('Last opened: ${_lastOpened ?? 'none'}'),
Text('Last navigation: ${_lastNavigation ?? 'none'}'),
Text('Web notification support: ${_formatWebSupport()}'),
Text(
'Web notification permission: ${_formatWebPermission()}',
key: const Key('web-permission-status'),
),
Text(
'Last web display: ${_formatWebDisplay()}',
key: const Key('web-display-status'),
),
ElevatedButton(
onPressed: () =>
unawaited(_refreshWebNotificationStatus(plugin)),
child: const Text('Refresh web permission'),
),
ElevatedButton(
onPressed: () =>
unawaited(_requestWebNotificationPermission(plugin)),
child: const Text('Request web permission'),
),
ElevatedButton(
onPressed: () => _showWebSmokeNotification(plugin),
child: const Text('Show web smoke notification'),
),
],
),
),

View file

@ -0,0 +1,11 @@
//
// Generated file. Do not edit.
//
// clang-format off
#include "generated_plugin_registrant.h"
void fl_register_plugins(FlPluginRegistry* registry) {
}

View file

@ -0,0 +1,15 @@
//
// Generated file. Do not edit.
//
// clang-format off
#ifndef GENERATED_PLUGIN_REGISTRANT_
#define GENERATED_PLUGIN_REGISTRANT_
#include <flutter_linux/flutter_linux.h>
// Registers Flutter plugins.
void fl_register_plugins(FlPluginRegistry* registry);
#endif // GENERATED_PLUGIN_REGISTRANT_

View file

@ -0,0 +1,23 @@
#
# Generated file, do not edit.
#
list(APPEND FLUTTER_PLUGIN_LIST
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
)
set(PLUGIN_BUNDLED_LIBRARIES)
foreach(plugin ${FLUTTER_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin})
target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
list(APPEND PLUGIN_BUNDLED_LIBRARIES $<TARGET_FILE:${plugin}_plugin>)
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
endforeach(plugin)
foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin})
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
endforeach(ffi_plugin)

View file

@ -12,22 +12,93 @@ void main() {
expect(find.text('Last opened: none'), findsOneWidget);
expect(find.text('Last navigation: none'), findsOneWidget);
// Thin host contract: body contains exactly 4 status Text widgets
// and no product UI controls.
final listViewFinder = find.byType(ListView);
expect(listViewFinder, findsOneWidget);
final statusTexts = find.descendant(
of: listViewFinder,
matching: find.byType(Text),
expect(find.byKey(const Key('web-permission-status')), findsOneWidget);
expect(find.byKey(const Key('web-display-status')), findsOneWidget);
expect(
find.widgetWithText(ElevatedButton, 'Refresh web permission'),
findsOneWidget,
);
expect(
find.widgetWithText(ElevatedButton, 'Request web permission'),
findsOneWidget,
);
expect(
find.widgetWithText(ElevatedButton, 'Show web smoke notification'),
findsOneWidget,
);
expect(statusTexts, findsNWidgets(4));
expect(find.byType(ElevatedButton), findsNothing);
expect(find.byType(TextField), findsNothing);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('updates opened and navigation state from plugin events (channel)', (
testWidgets('renders web smoke controls', (WidgetTester tester) async {
await tester.pumpWidget(const MyApp(initializePlugin: false));
expect(find.byKey(const Key('web-permission-status')), findsOneWidget);
expect(find.byKey(const Key('web-display-status')), findsOneWidget);
expect(
find.widgetWithText(ElevatedButton, 'Refresh web permission'),
findsOneWidget,
);
expect(
find.widgetWithText(ElevatedButton, 'Request web permission'),
findsOneWidget,
);
expect(
find.widgetWithText(ElevatedButton, 'Show web smoke notification'),
findsOneWidget,
);
final permissionStatus = tester.widget<Text>(
find.byKey(const Key('web-permission-status')),
);
expect(permissionStatus.data, contains('unknown'));
final displayStatus = tester.widget<Text>(
find.byKey(const Key('web-display-status')),
);
expect(displayStatus.data, contains('none'));
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('updates web permission status from request', (
WidgetTester tester,
) async {
await tester.pumpWidget(const MyApp(initializePlugin: false));
await tester.tap(
find.widgetWithText(ElevatedButton, 'Request web permission'),
);
await tester.pumpAndSettle();
final statusWidget = tester.widget<Text>(
find.byKey(const Key('web-permission-status')),
);
expect(statusWidget.data, contains('unsupported'));
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('updates web display result on unsupported VM', (
WidgetTester tester,
) async {
await tester.pumpWidget(const MyApp(initializePlugin: false));
await tester.tap(
find.widgetWithText(ElevatedButton, 'Show web smoke notification'),
);
await tester.pump();
final displayWidget = tester.widget<Text>(
find.byKey(const Key('web-display-status')),
);
expect(displayWidget.data, contains('unsupported'));
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('opened event still drives click routing status', (
WidgetTester tester,
) async {
await tester.pumpWidget(const MyApp(initializePlugin: false));
@ -40,12 +111,6 @@ void main() {
});
await tester.pump();
expect(
find.text(
'Last notification: type: opened, server_url: https://nexo.example.com, channel_id: channel-123, is_crt_enabled: false',
),
findsOneWidget,
);
expect(
find.text(
'Last opened: server_url: https://nexo.example.com, channel_id: channel-123, root_id: null, is_crt_enabled: false',
@ -53,15 +118,49 @@ void main() {
findsOneWidget,
);
expect(
find.text(
'Last navigation: channel:https://nexo.example.com/channel-123',
),
find.text('Last navigation: channel:https://nexo.example.com/channel-123'),
findsOneWidget,
);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets(
'updates opened and navigation state from plugin events (channel)',
(WidgetTester tester) async {
await tester.pumpWidget(const MyApp(initializePlugin: false));
NexoMessagingPlugin.instance.handleNativeEvent(const {
'type': PushNotificationType.opened,
'server_url': 'https://nexo.example.com',
'channel_id': 'channel-123',
'is_crt_enabled': 'false',
});
await tester.pump();
expect(
find.text(
'Last notification: type: opened, server_url: https://nexo.example.com, channel_id: channel-123, is_crt_enabled: false',
),
findsOneWidget,
);
expect(
find.text(
'Last opened: server_url: https://nexo.example.com, channel_id: channel-123, root_id: null, is_crt_enabled: false',
),
findsOneWidget,
);
expect(
find.text(
'Last navigation: channel:https://nexo.example.com/channel-123',
),
findsOneWidget,
);
await tester.pumpWidget(const SizedBox.shrink());
},
);
testWidgets(
'updates opened and navigation state from plugin events (thread/CRT)',
(WidgetTester tester) async {