yaver-feedback-react-native 0.9.0 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/README.md +102 -1
  2. package/app.plugin.js +126 -9
  3. package/dist/AuthOverlay.d.ts +1 -14
  4. package/dist/AuthOverlay.js +9 -62
  5. package/dist/BlackBox.js +9 -1
  6. package/dist/DeployPanel.js +65 -0
  7. package/dist/Discovery.js +13 -8
  8. package/dist/DogfoodRuntime.d.ts +124 -0
  9. package/dist/DogfoodRuntime.js +273 -0
  10. package/dist/FeedbackModal.js +505 -65
  11. package/dist/LoginScreen.d.ts +1 -5
  12. package/dist/LoginScreen.js +2 -6
  13. package/dist/MachinePickerScreen.d.ts +1 -3
  14. package/dist/MachinePickerScreen.js +6 -18
  15. package/dist/P2PClient.d.ts +178 -2
  16. package/dist/P2PClient.js +493 -3
  17. package/dist/P2PDogfoodDriver.d.ts +12 -0
  18. package/dist/P2PDogfoodDriver.js +118 -0
  19. package/dist/PairDeviceModal.d.ts +2 -3
  20. package/dist/ShakeDetector.js +2 -0
  21. package/dist/VibeChatScreen.d.ts +11 -1
  22. package/dist/VibeChatScreen.js +200 -41
  23. package/dist/YaverFeedback.d.ts +132 -0
  24. package/dist/YaverFeedback.js +633 -65
  25. package/dist/YaverModeBadge.d.ts +22 -0
  26. package/dist/YaverModeBadge.js +219 -0
  27. package/dist/__tests__/AuthDevices.test.js +1 -48
  28. package/dist/__tests__/BlackBox.relayPassword.test.d.ts +1 -0
  29. package/dist/__tests__/BlackBox.relayPassword.test.js +105 -0
  30. package/dist/__tests__/BlackBoxAutoStart.test.d.ts +1 -0
  31. package/dist/__tests__/BlackBoxAutoStart.test.js +91 -0
  32. package/dist/__tests__/BlackBoxAutoStartColdStart.test.d.ts +1 -0
  33. package/dist/__tests__/BlackBoxAutoStartColdStart.test.js +156 -0
  34. package/dist/__tests__/BrowserLaneIcon.test.d.ts +3 -0
  35. package/dist/__tests__/BrowserLaneIcon.test.js +80 -0
  36. package/dist/__tests__/DogfoodRuntime.test.d.ts +1 -0
  37. package/dist/__tests__/DogfoodRuntime.test.js +116 -0
  38. package/dist/__tests__/P2PClient.test.js +2 -2
  39. package/dist/__tests__/P2PDogfoodDriver.test.d.ts +1 -0
  40. package/dist/__tests__/P2PDogfoodDriver.test.js +64 -0
  41. package/dist/__tests__/ReportIdentity.test.d.ts +25 -0
  42. package/dist/__tests__/ReportIdentity.test.js +180 -0
  43. package/dist/__tests__/SDKToken.test.js +1 -1
  44. package/dist/__tests__/ShakeToggle.test.d.ts +1 -0
  45. package/dist/__tests__/ShakeToggle.test.js +121 -0
  46. package/dist/__tests__/YaverFeedback.test.js +13 -3
  47. package/dist/__tests__/deviceDogfood.test.d.ts +1 -0
  48. package/dist/__tests__/deviceDogfood.test.js +86 -0
  49. package/dist/__tests__/dogfoodPolicy.test.d.ts +1 -0
  50. package/dist/__tests__/dogfoodPolicy.test.js +33 -0
  51. package/dist/__tests__/pickTargetDevice.test.d.ts +1 -0
  52. package/dist/__tests__/pickTargetDevice.test.js +89 -0
  53. package/dist/__tests__/reloadActions.test.d.ts +1 -0
  54. package/dist/__tests__/reloadActions.test.js +129 -0
  55. package/dist/__tests__/reloadActionsParity.test.d.ts +1 -0
  56. package/dist/__tests__/reloadActionsParity.test.js +42 -0
  57. package/dist/__tests__/types.test.js +5 -5
  58. package/dist/_core/ansi.d.ts +117 -0
  59. package/dist/_core/ansi.js +468 -0
  60. package/dist/_core/ansi.test.d.ts +1 -0
  61. package/dist/_core/ansi.test.js +225 -0
  62. package/dist/_core/buildFeedbackPrompt.d.ts +4 -9
  63. package/dist/_core/buildFeedbackPrompt.js +18 -72
  64. package/dist/_core/constants.d.ts +19 -6
  65. package/dist/_core/constants.js +20 -7
  66. package/dist/_core/device.d.ts +1 -5
  67. package/dist/_core/device.js +5 -14
  68. package/dist/_core/endpoints.d.ts +0 -7
  69. package/dist/_core/endpoints.js +0 -7
  70. package/dist/_core/index.d.ts +4 -0
  71. package/dist/_core/index.js +4 -0
  72. package/dist/_core/remoteless.d.ts +44 -0
  73. package/dist/_core/remoteless.js +75 -0
  74. package/dist/_core/trace.d.ts +47 -0
  75. package/dist/_core/trace.js +38 -0
  76. package/dist/_core/trace.test.d.ts +1 -0
  77. package/dist/_core/trace.test.js +60 -0
  78. package/dist/auth.d.ts +3 -60
  79. package/dist/auth.js +6 -88
  80. package/dist/deviceDogfood.d.ts +53 -0
  81. package/dist/deviceDogfood.js +137 -0
  82. package/dist/dogfoodPolicy.d.ts +25 -0
  83. package/dist/dogfoodPolicy.js +24 -0
  84. package/dist/index.d.ts +17 -4
  85. package/dist/index.js +33 -7
  86. package/dist/reloadActions.d.ts +88 -0
  87. package/dist/reloadActions.js +200 -0
  88. package/dist/storeShots.d.ts +67 -0
  89. package/dist/storeShots.js +137 -0
  90. package/dist/types.d.ts +208 -8
  91. package/package.json +13 -4
  92. package/src/AuthOverlay.tsx +20 -106
  93. package/src/BlackBox.ts +10 -1
  94. package/src/DeployPanel.tsx +74 -0
  95. package/src/Discovery.ts +13 -8
  96. package/src/DogfoodRuntime.ts +373 -0
  97. package/src/FeedbackModal.tsx +619 -79
  98. package/src/LoginScreen.tsx +2 -22
  99. package/src/MachinePickerScreen.tsx +6 -21
  100. package/src/P2PClient.ts +580 -4
  101. package/src/P2PDogfoodDriver.ts +132 -0
  102. package/src/PairDeviceModal.tsx +2 -3
  103. package/src/ShakeDetector.ts +1 -0
  104. package/src/VibeChatScreen.tsx +232 -42
  105. package/src/YaverFeedback.ts +631 -68
  106. package/src/YaverModeBadge.tsx +234 -0
  107. package/src/__tests__/AuthDevices.test.ts +1 -52
  108. package/src/__tests__/BlackBox.relayPassword.test.ts +129 -0
  109. package/src/__tests__/BlackBoxAutoStart.test.ts +111 -0
  110. package/src/__tests__/BlackBoxAutoStartColdStart.test.ts +191 -0
  111. package/src/__tests__/BrowserLaneIcon.test.ts +85 -0
  112. package/src/__tests__/DogfoodRuntime.test.ts +135 -0
  113. package/src/__tests__/P2PClient.test.ts +2 -2
  114. package/src/__tests__/P2PDogfoodDriver.test.ts +80 -0
  115. package/src/__tests__/ReportIdentity.test.ts +212 -0
  116. package/src/__tests__/SDKToken.test.ts +1 -1
  117. package/src/__tests__/ShakeToggle.test.ts +153 -0
  118. package/src/__tests__/YaverFeedback.test.ts +15 -3
  119. package/src/__tests__/deviceDogfood.test.ts +77 -0
  120. package/src/__tests__/dogfoodPolicy.test.ts +34 -0
  121. package/src/__tests__/pickTargetDevice.test.ts +101 -0
  122. package/src/__tests__/reloadActions.test.ts +171 -0
  123. package/src/__tests__/reloadActionsParity.test.ts +49 -0
  124. package/src/__tests__/types.test.ts +5 -5
  125. package/src/_core/ansi.test.ts +250 -0
  126. package/src/_core/ansi.ts +475 -0
  127. package/src/_core/buildFeedbackPrompt.ts +18 -95
  128. package/src/_core/constants.ts +20 -6
  129. package/src/_core/device.ts +5 -16
  130. package/src/_core/endpoints.ts +0 -7
  131. package/src/_core/index.ts +4 -0
  132. package/src/_core/remoteless.ts +110 -0
  133. package/src/_core/trace.test.ts +58 -0
  134. package/src/_core/trace.ts +75 -0
  135. package/src/auth.ts +7 -156
  136. package/src/deviceDogfood.ts +171 -0
  137. package/src/dogfoodPolicy.ts +40 -0
  138. package/src/index.ts +61 -11
  139. package/src/reloadActions.ts +273 -0
  140. package/src/storeShots.ts +189 -0
  141. package/src/types.ts +209 -8
  142. package/dist/GuestOnboardingScreen.d.ts +0 -8
  143. package/dist/GuestOnboardingScreen.js +0 -282
  144. package/src/GuestOnboardingScreen.tsx +0 -307
@@ -0,0 +1,156 @@
1
+ "use strict";
2
+ // BlackBox auto-start on a COLD start — the case that decides whether Hermes
3
+ // hot reload works at all.
4
+ //
5
+ // The command channel that carries the agent's `reload_bundle` is BlackBox's
6
+ // SSE stream. If BlackBox never starts, a fix pushed from the dev machine has
7
+ // nowhere to land and the phone just sits there.
8
+ //
9
+ // Auto-start used to be a single 500ms timeout gated on `agentUrl && token`.
10
+ // A real app passes NEITHER to init() — they're restored from storage — so on
11
+ // a cold start the sequence is:
12
+ //
13
+ // init() -> void hydrateSession()
14
+ // -> AsyncStorage read (token)
15
+ // -> AsyncStorage read (deviceId)
16
+ // -> await discoverAgent() <- Convex round trip + LAN probe
17
+ //
18
+ // which does not complete in 500ms. The one-shot check found no agentUrl, gave
19
+ // up, and hot reload was silently dead until the user shook the device and
20
+ // drove discovery by hand. These tests pin the retry that fixes it, and the
21
+ // bounds that keep it from becoming a forever-poll or a 401 storm.
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ const BlackBox_1 = require("../BlackBox");
24
+ const YaverFeedback_1 = require("../YaverFeedback");
25
+ jest.mock('react-native', () => ({
26
+ Platform: { OS: 'ios' },
27
+ DeviceEventEmitter: { emit: jest.fn(), addListener: jest.fn() },
28
+ NativeModules: {},
29
+ }));
30
+ jest.mock('../ShakeDetector', () => ({
31
+ ShakeDetector: jest.fn().mockImplementation(() => ({
32
+ start: jest.fn(),
33
+ stop: jest.fn(),
34
+ })),
35
+ }));
36
+ // Discovery is the slow step we're modelling. Resolves only after the test
37
+ // flips `discovered`, standing in for "the Convex round trip finally landed".
38
+ let discovered = null;
39
+ jest.mock('../Discovery', () => ({
40
+ YaverDiscovery: {
41
+ discover: jest.fn(() => Promise.resolve(discovered ? { url: discovered } : null)),
42
+ },
43
+ }));
44
+ // The token cache hydrateSession() reads.
45
+ let storedToken = null;
46
+ jest.mock('../auth', () => ({
47
+ ...jest.requireActual('../auth'),
48
+ getToken: jest.fn(() => Promise.resolve(storedToken)),
49
+ getSelectedDeviceId: jest.fn(() => Promise.resolve(null)),
50
+ configureAuthEndpoints: jest.fn(),
51
+ setStrictNativeAuth: jest.fn(),
52
+ }));
53
+ jest.useFakeTimers();
54
+ const mockFetch = jest.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({}), body: null }));
55
+ class MockAbortController {
56
+ constructor() {
57
+ this.signal = { aborted: false };
58
+ this.abort = jest.fn();
59
+ }
60
+ }
61
+ // fetch and AbortController are process-globals, not per-file module state:
62
+ // jest gives each test FILE its own module registry but they share one worker
63
+ // process. Assigning them at module scope (as the sibling auto-start suite
64
+ // does) leaves them mocked for every file that runs afterwards in the same
65
+ // worker. Save and restore instead, so this suite can't be the reason some
66
+ // unrelated file sees a stubbed fetch.
67
+ const realFetch = global.fetch;
68
+ const realAbortController = global.AbortController;
69
+ beforeAll(() => {
70
+ global.fetch = mockFetch;
71
+ global.AbortController = MockAbortController;
72
+ });
73
+ afterAll(() => {
74
+ global.fetch = realFetch;
75
+ global.AbortController = realAbortController;
76
+ // Leave no timer chain behind for the next file in this worker.
77
+ YaverFeedback_1.YaverFeedback.destroy();
78
+ jest.useRealTimers();
79
+ });
80
+ beforeEach(() => {
81
+ jest.clearAllMocks();
82
+ discovered = null;
83
+ storedToken = null;
84
+ YaverFeedback_1.YaverFeedback.destroy();
85
+ });
86
+ afterEach(() => {
87
+ BlackBox_1.BlackBox.stop();
88
+ YaverFeedback_1.YaverFeedback.destroy();
89
+ });
90
+ describe('BlackBox auto-start on a cold start', () => {
91
+ it('starts once discovery resolves well after the old 500ms window', async () => {
92
+ const startSpy = jest.spyOn(BlackBox_1.BlackBox, 'start').mockImplementation(() => { });
93
+ storedToken = 'tok-from-storage';
94
+ // Nothing passed in — the real cold-start shape.
95
+ YaverFeedback_1.YaverFeedback.init({ trigger: 'shake', blackBox: { appName: 'talos-mobile' } });
96
+ // The window the old implementation checked, and only that window.
97
+ await jest.advanceTimersByTimeAsync(600);
98
+ expect(startSpy).not.toHaveBeenCalled();
99
+ // Discovery lands late, as it does on a real cold, off-LAN start.
100
+ discovered = 'http://10.0.0.5:18080';
101
+ await jest.advanceTimersByTimeAsync(4000);
102
+ expect(startSpy).toHaveBeenCalledWith(expect.objectContaining({ appName: 'talos-mobile' }));
103
+ startSpy.mockRestore();
104
+ });
105
+ it('never starts without a token, however long it waits', async () => {
106
+ // The guard that matters: starting tokenless makes the SSE channel 401 and
107
+ // retry with backoff — the string-concat + JSON-parse loop that SIGSEGV'd
108
+ // Hermes on iOS 18.3.1. Retrying must not erode it.
109
+ const startSpy = jest.spyOn(BlackBox_1.BlackBox, 'start').mockImplementation(() => { });
110
+ storedToken = null;
111
+ discovered = 'http://10.0.0.5:18080';
112
+ YaverFeedback_1.YaverFeedback.init({ trigger: 'shake' });
113
+ await jest.advanceTimersByTimeAsync(90000);
114
+ expect(startSpy).not.toHaveBeenCalled();
115
+ startSpy.mockRestore();
116
+ });
117
+ it('gives up rather than polling forever', async () => {
118
+ const startSpy = jest.spyOn(BlackBox_1.BlackBox, 'start').mockImplementation(() => { });
119
+ storedToken = null;
120
+ YaverFeedback_1.YaverFeedback.init({ trigger: 'shake' });
121
+ // Past the ~60s bound.
122
+ await jest.advanceTimersByTimeAsync(90000);
123
+ // Credentials arrive after the window closed — the poll is done.
124
+ storedToken = 'late';
125
+ discovered = 'http://10.0.0.5:18080';
126
+ await jest.advanceTimersByTimeAsync(10000);
127
+ expect(startSpy).not.toHaveBeenCalled();
128
+ startSpy.mockRestore();
129
+ });
130
+ it('signing in re-arms after the bound has passed', async () => {
131
+ // Giving up is only acceptable because this path exists: a user who signs
132
+ // in later gets the channel without restarting the app.
133
+ const startSpy = jest.spyOn(BlackBox_1.BlackBox, 'start').mockImplementation(() => { });
134
+ YaverFeedback_1.YaverFeedback.init({ trigger: 'shake' });
135
+ await jest.advanceTimersByTimeAsync(90000);
136
+ expect(startSpy).not.toHaveBeenCalled();
137
+ discovered = 'http://10.0.0.5:18080';
138
+ await YaverFeedback_1.YaverFeedback.setAuthToken('tok-from-login');
139
+ await jest.advanceTimersByTimeAsync(2000);
140
+ expect(startSpy).toHaveBeenCalled();
141
+ startSpy.mockRestore();
142
+ });
143
+ it('destroy() cancels a pending retry', async () => {
144
+ // A retry chain firing against a torn-down config would start the recorder
145
+ // for an SDK the host believes is off.
146
+ const startSpy = jest.spyOn(BlackBox_1.BlackBox, 'start').mockImplementation(() => { });
147
+ storedToken = 'tok';
148
+ YaverFeedback_1.YaverFeedback.init({ trigger: 'shake' });
149
+ await jest.advanceTimersByTimeAsync(600);
150
+ YaverFeedback_1.YaverFeedback.destroy();
151
+ discovered = 'http://10.0.0.5:18080';
152
+ await jest.advanceTimersByTimeAsync(10000);
153
+ expect(startSpy).not.toHaveBeenCalled();
154
+ startSpy.mockRestore();
155
+ });
156
+ });
@@ -0,0 +1,3 @@
1
+ declare function installFakeDom(): {
2
+ els: Record<string, any>;
3
+ };
@@ -0,0 +1,80 @@
1
+ "use strict";
2
+ /**
3
+ * Browser-lane DOM icon (feedback-sdk-lanes audit 2026-07-28). RN-web inside
4
+ * Yaver's fullScreen preview WebView: native shake can't fire and the container
5
+ * overlay is occluded, so the SDK mounts an occlusion-proof DOM icon. Node env,
6
+ * so we stub Platform.OS='web' + a minimal DOM.
7
+ */
8
+ jest.mock('react-native', () => ({
9
+ Platform: { OS: 'web' },
10
+ NativeModules: {},
11
+ }));
12
+ // Minimal DOM stub — enough for mountBrowserLaneIcon's createElement/appendChild.
13
+ function installFakeDom() {
14
+ const els = {};
15
+ const makeEl = () => ({
16
+ id: '',
17
+ textContent: '',
18
+ title: '',
19
+ style: {},
20
+ _handlers: {},
21
+ setAttribute() { },
22
+ setPointerCapture() { },
23
+ releasePointerCapture() { },
24
+ getBoundingClientRect: () => ({ left: 0, top: 0, width: 44, height: 44 }),
25
+ addEventListener(k, fn) { this._handlers[k] = fn; },
26
+ });
27
+ globalThis.window = {
28
+ innerWidth: 390, innerHeight: 844,
29
+ addEventListener() { },
30
+ __yaverLane: 'browser',
31
+ };
32
+ globalThis.localStorage = { getItem: () => null, setItem() { } };
33
+ globalThis.document = {
34
+ body: { appendChild(el) { els[el.id] = el; } },
35
+ getElementById: (id) => els[id] || null,
36
+ createElement: () => makeEl(),
37
+ };
38
+ return { els };
39
+ }
40
+ describe('RN SDK browser-lane icon', () => {
41
+ afterEach(() => {
42
+ delete globalThis.window;
43
+ delete globalThis.document;
44
+ delete globalThis.localStorage;
45
+ jest.resetModules();
46
+ });
47
+ it('detectWebLane reads window.__yaverLane on web', () => {
48
+ installFakeDom();
49
+ const { YaverFeedback } = require('../YaverFeedback');
50
+ expect(YaverFeedback.detectWebLane()).toBe('browser');
51
+ });
52
+ it('mounts a fixed, max-z-index, draggable DOM icon in the browser lane', () => {
53
+ const { els } = installFakeDom();
54
+ const { YaverFeedback } = require('../YaverFeedback');
55
+ YaverFeedback.mountBrowserLaneIcon();
56
+ const btn = els['yaver-feedback-btn'];
57
+ expect(btn).toBeTruthy();
58
+ expect(btn.textContent).toBe('Y');
59
+ expect(btn.style.cssText).toContain('position:fixed');
60
+ expect(btn.style.cssText).toContain('z-index:99999');
61
+ // Draggable: pointer handlers wired.
62
+ expect(typeof btn._handlers['pointerdown']).toBe('function');
63
+ expect(typeof btn._handlers['pointerup']).toBe('function');
64
+ });
65
+ it('is idempotent — a second mount does not stack a second icon', () => {
66
+ const { els } = installFakeDom();
67
+ const { YaverFeedback } = require('../YaverFeedback');
68
+ YaverFeedback.mountBrowserLaneIcon();
69
+ const first = els['yaver-feedback-btn'];
70
+ YaverFeedback.mountBrowserLaneIcon();
71
+ expect(els['yaver-feedback-btn']).toBe(first);
72
+ });
73
+ it('no-ops when not in a browser lane', () => {
74
+ const { els } = installFakeDom();
75
+ globalThis.window.__yaverLane = undefined;
76
+ const { YaverFeedback } = require('../YaverFeedback');
77
+ YaverFeedback.mountBrowserLaneIcon();
78
+ expect(els['yaver-feedback-btn']).toBeUndefined();
79
+ });
80
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,116 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const DogfoodRuntime_1 = require("../DogfoodRuntime");
4
+ const expo = {
5
+ name: 'Example', workDir: '/workspace/example', framework: 'expo', lane: 'browser',
6
+ };
7
+ describe('DogfoodController', () => {
8
+ test('does no work before the explicit trigger', () => {
9
+ const driver = { start: jest.fn(async () => ({ lane: 'browser' })) };
10
+ const controller = new DogfoodRuntime_1.DogfoodController(expo, driver);
11
+ expect(driver.start).not.toHaveBeenCalled();
12
+ expect(controller.snapshot().phase).toBe('idle');
13
+ });
14
+ test('preserves raw npm output and hands a live session to the host', async () => {
15
+ const stop = jest.fn();
16
+ const closeLogs = jest.fn();
17
+ const controller = new DogfoodRuntime_1.DogfoodController(expo, {
18
+ async start(ctx) {
19
+ ctx.registerCleanup(stop, 'session');
20
+ ctx.registerCleanup(closeLogs, 'transient');
21
+ ctx.log('$ npm install --legacy-peer-deps');
22
+ ctx.log('npm warn deprecated example@1.0.0');
23
+ return { lane: 'browser', sessionId: 's1', url: 'http://agent/dev/' };
24
+ },
25
+ });
26
+ await controller.trigger();
27
+ expect(controller.snapshot().logs.map((line) => line.text)).toEqual([
28
+ '$ npm install --legacy-peer-deps',
29
+ 'npm warn deprecated example@1.0.0',
30
+ ]);
31
+ expect(await controller.handoff()).toMatchObject({ sessionId: 's1' });
32
+ expect(closeLogs).toHaveBeenCalledTimes(1);
33
+ expect(stop).not.toHaveBeenCalled();
34
+ });
35
+ test('cleans a partial session before exposing a structured failure', async () => {
36
+ const stop = jest.fn();
37
+ const controller = new DogfoodRuntime_1.DogfoodController(expo, {
38
+ async start(ctx) {
39
+ ctx.registerCleanup(stop);
40
+ throw new DogfoodRuntime_1.DogfoodRuntimeError({
41
+ code: 'DOGFOOD_RENDER_FAILED', error: 'Metro exited', remedy: 'Fix Metro and retry.', retryable: true,
42
+ });
43
+ },
44
+ });
45
+ await expect(controller.trigger()).rejects.toThrow('Metro exited');
46
+ expect(stop).toHaveBeenCalledTimes(1);
47
+ expect(controller.snapshot()).toMatchObject({ phase: 'failed', failure: { code: 'DOGFOOD_RENDER_FAILED' } });
48
+ });
49
+ test('an obsolete attempt cannot clean up the replacement attempt', async () => {
50
+ let releaseFirst;
51
+ const firstGate = new Promise((resolve) => { releaseFirst = resolve; });
52
+ let firstStarted;
53
+ const firstStartedGate = new Promise((resolve) => { firstStarted = resolve; });
54
+ const stopFirst = jest.fn();
55
+ const stopSecond = jest.fn();
56
+ let starts = 0;
57
+ const controller = new DogfoodRuntime_1.DogfoodController(expo, {
58
+ async start(ctx) {
59
+ starts += 1;
60
+ if (starts === 1) {
61
+ ctx.registerCleanup(stopFirst);
62
+ firstStarted();
63
+ await firstGate;
64
+ return { lane: 'browser', sessionId: 'old' };
65
+ }
66
+ ctx.registerCleanup(stopSecond);
67
+ return { lane: 'browser', sessionId: 'new' };
68
+ },
69
+ });
70
+ const old = controller.trigger();
71
+ await firstStartedGate;
72
+ await controller.stop();
73
+ await expect(controller.trigger()).resolves.toMatchObject({ sessionId: 'new' });
74
+ releaseFirst();
75
+ await expect(old).rejects.toMatchObject({ failure: { code: 'DOGFOOD_ATTEMPT_REPLACED' } });
76
+ expect(stopFirst).toHaveBeenCalledTimes(1);
77
+ expect(stopSecond).not.toHaveBeenCalled();
78
+ await controller.stop();
79
+ expect(stopSecond).toHaveBeenCalledTimes(1);
80
+ });
81
+ });
82
+ describe('Dogfood lanes and console events', () => {
83
+ test('Flutter is first-class on browser but cannot be mislabeled Hermes', () => {
84
+ expect((0, DogfoodRuntime_1.validateDogfoodProject)({ ...expo, framework: 'flutter', lane: 'browser' })).toBeNull();
85
+ expect((0, DogfoodRuntime_1.validateDogfoodProject)({ ...expo, framework: 'flutter', lane: 'hermes' })?.code)
86
+ .toBe('DOGFOOD_HERMES_FRAMEWORK_UNSUPPORTED');
87
+ });
88
+ test('uses one three-lane matrix with browser default for React Native and Flutter', () => {
89
+ const rn = (0, DogfoodRuntime_1.dogfoodLaneOptions)('expo', { nativeRuntimeAvailable: true });
90
+ expect((0, DogfoodRuntime_1.defaultDogfoodLane)('expo')).toBe('browser');
91
+ expect(rn.map((option) => [option.lane, option.supported])).toEqual([
92
+ ['browser', true], ['hermes', true], ['webrtc', true],
93
+ ]);
94
+ const flutter = (0, DogfoodRuntime_1.dogfoodLaneOptions)('flutter', { nativeRuntimeAvailable: true });
95
+ expect((0, DogfoodRuntime_1.defaultDogfoodLane)('flutter')).toBe('browser');
96
+ expect(flutter.find((option) => option.lane === 'hermes')?.supported).toBe(false);
97
+ });
98
+ test('keeps Yaver self-development on the same RN three-lane contract', () => {
99
+ const options = (0, DogfoodRuntime_1.dogfoodLaneOptions)('expo', { nativeRuntimeAvailable: true, selfDevelopment: true });
100
+ expect(options).toHaveLength(3);
101
+ expect(options.find((option) => option.lane === 'hermes')).toMatchObject({ supported: true });
102
+ expect(options.find((option) => option.lane === 'webrtc')).toMatchObject({ supported: true });
103
+ });
104
+ test('native-only projects use WebRTC rather than fake browser or Hermes lanes', () => {
105
+ const swift = (0, DogfoodRuntime_1.dogfoodLaneOptions)('swift', { nativeRuntimeAvailable: true });
106
+ expect(swift.map((option) => [option.lane, option.supported])).toEqual([
107
+ ['browser', false], ['hermes', false], ['webrtc', true],
108
+ ]);
109
+ });
110
+ test('reads raw and replayed package-manager logs from /dev/events', () => {
111
+ expect((0, DogfoodRuntime_1.dogfoodLogLinesFromDevEvent)({ type: 'log', logLine: '$ npm ci\u001b[0m' }))
112
+ .toEqual(['$ npm ci\u001b[0m']);
113
+ expect((0, DogfoodRuntime_1.dogfoodLogLinesFromDevEvent)({ type: 'snapshot', snapshot: { recentLogs: ['npm warn x', 'Metro ready'] } }))
114
+ .toEqual(['npm warn x', 'Metro ready']);
115
+ });
116
+ });
@@ -119,7 +119,7 @@ describe('P2PClient', () => {
119
119
  const bundle = {
120
120
  metadata: {
121
121
  timestamp: '2026-03-24T00:00:00Z',
122
- device: {
122
+ deviceInfo: {
123
123
  platform: 'ios',
124
124
  osVersion: '18.0',
125
125
  model: 'iPhone 16',
@@ -145,7 +145,7 @@ describe('P2PClient', () => {
145
145
  const bundle = {
146
146
  metadata: {
147
147
  timestamp: '2026-03-24T00:00:00Z',
148
- device: { platform: 'ios', osVersion: '18', model: 'iPhone', screenWidth: 393, screenHeight: 852 },
148
+ deviceInfo: { platform: 'ios', osVersion: '18', model: 'iPhone', screenWidth: 393, screenHeight: 852 },
149
149
  app: {},
150
150
  },
151
151
  screenshots: [],
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const DogfoodRuntime_1 = require("../DogfoodRuntime");
4
+ const P2PDogfoodDriver_1 = require("../P2PDogfoodDriver");
5
+ describe('createP2PDogfoodDriver', () => {
6
+ it('keeps the browser lane compiling until the ordinary Projects status reports a URL', async () => {
7
+ const stop = jest.fn(async () => { });
8
+ const status = jest.fn()
9
+ .mockResolvedValueOnce({ building: true, framework: 'flutter' })
10
+ .mockResolvedValueOnce({ running: true, serving: true, framework: 'flutter', bundleUrl: '/dev/' });
11
+ const client = {
12
+ subscribeDogfoodDevEvents: (onEvent) => {
13
+ onEvent({ type: 'log', logLine: '$ flutter run -d web-server' });
14
+ return jest.fn();
15
+ },
16
+ startDogfoodDevServer: jest.fn(async () => ({ starting: true, framework: 'flutter' })),
17
+ getDogfoodDevServerStatus: status,
18
+ stopDogfoodDevServer: stop,
19
+ resolveDogfoodUrl: (path) => `http://agent.test${path}`,
20
+ };
21
+ const snapshots = [];
22
+ const controller = new DogfoodRuntime_1.DogfoodController({ name: 'Flutter app', framework: 'flutter', workDir: '/workspace/app', lane: 'browser' }, (0, P2PDogfoodDriver_1.createP2PDogfoodDriver)(client, { pollIntervalMs: 1, startupTimeoutMs: 100 }), { onChange: (snapshot) => snapshots.push(snapshot.logs.map((line) => line.text)) });
23
+ const result = await controller.trigger();
24
+ expect(result.url).toBe('http://agent.test/dev/');
25
+ expect(status).toHaveBeenCalledTimes(2);
26
+ expect(snapshots.flat()).toContain('$ flutter run -d web-server');
27
+ expect(stop).not.toHaveBeenCalled();
28
+ await controller.stop();
29
+ expect(stop).toHaveBeenCalledTimes(1);
30
+ });
31
+ it('starts and cleans up an available native WebRTC runtime', async () => {
32
+ const closeRuntime = jest.fn(async () => { });
33
+ const client = {
34
+ subscribeDogfoodDevEvents: () => jest.fn(),
35
+ getDogfoodRemoteRuntimeCapabilities: jest.fn(async () => ({
36
+ targets: [
37
+ { id: 'browser-window', label: 'Browser', enabled: true },
38
+ { id: 'ios-simulator', label: 'iPhone simulator', enabled: true },
39
+ ],
40
+ })),
41
+ startDogfoodRemoteRuntime: jest.fn(async () => ({
42
+ id: 'runtime-1', status: 'starting', targetId: 'ios-simulator',
43
+ })),
44
+ stopDogfoodRemoteRuntime: closeRuntime,
45
+ };
46
+ const controller = new DogfoodRuntime_1.DogfoodController({ name: 'Native app', framework: 'flutter', workDir: '/workspace/app', lane: 'webrtc' }, (0, P2PDogfoodDriver_1.createP2PDogfoodDriver)(client));
47
+ await expect(controller.trigger()).resolves.toMatchObject({ lane: 'webrtc', sessionId: 'runtime-1' });
48
+ expect(closeRuntime).not.toHaveBeenCalled();
49
+ await controller.stop();
50
+ expect(closeRuntime).toHaveBeenCalledWith('runtime-1');
51
+ });
52
+ it('delivers Hermes without stopping an unrelated dev server on exit', async () => {
53
+ const stop = jest.fn(async () => { });
54
+ const client = {
55
+ subscribeDogfoodDevEvents: () => jest.fn(),
56
+ startDogfoodDevServer: jest.fn(async () => ({ running: true, framework: 'expo', workDir: '/workspace/app' })),
57
+ stopDogfoodDevServer: stop,
58
+ };
59
+ const controller = new DogfoodRuntime_1.DogfoodController({ name: 'RN app', framework: 'expo', workDir: '/workspace/app', lane: 'hermes' }, (0, P2PDogfoodDriver_1.createP2PDogfoodDriver)(client));
60
+ await expect(controller.trigger()).resolves.toMatchObject({ lane: 'hermes', metadata: { delivered: true } });
61
+ await controller.stop();
62
+ expect(stop).not.toHaveBeenCalled();
63
+ });
64
+ });
@@ -0,0 +1,25 @@
1
+ declare const mockReactNative: {
2
+ Platform: {
3
+ OS: string;
4
+ };
5
+ NativeModules: {};
6
+ };
7
+ declare const mockExpoModule: {
8
+ default: Record<string, unknown>;
9
+ };
10
+ /** Mirrors mobile/app.json in the Talos repo — the SDK's first external consumer. */
11
+ declare const EXPO_CONFIG: {
12
+ name: string;
13
+ slug: string;
14
+ version: string;
15
+ ios: {
16
+ bundleIdentifier: string;
17
+ buildNumber: string;
18
+ };
19
+ android: {
20
+ package: string;
21
+ versionCode: number;
22
+ };
23
+ };
24
+ declare function mockExpoConstants(expoConfig: unknown, extra?: Record<string, unknown>): void;
25
+ declare function loadResolve(): typeof import('../P2PClient').resolveReportIdentity;
@@ -0,0 +1,180 @@
1
+ "use strict";
2
+ // resolveAppIdentity() has always fed /vibing/execute and /dev/reload-app so
3
+ // the agent could route "vibe on THIS app" to the right repo, but nothing fed
4
+ // /feedback — reports carried no identity at all, so the agent's fix router
5
+ // had nothing to resolve and fell back to whatever directory it was sitting
6
+ // in. resolveReportIdentity() closes that gap for the feedback path.
7
+ const mockReactNative = {
8
+ Platform: { OS: 'ios' },
9
+ NativeModules: {},
10
+ };
11
+ const mockExpoModule = { default: {} };
12
+ jest.mock('react-native', () => mockReactNative);
13
+ jest.mock('expo-constants', () => mockExpoModule, { virtual: true });
14
+ /** Mirrors mobile/app.json in the Talos repo — the SDK's first external consumer. */
15
+ const EXPO_CONFIG = {
16
+ name: 'Talos',
17
+ slug: 'talos-mobile',
18
+ version: '1.9.157',
19
+ ios: { bundleIdentifier: 'works.talos.mobile', buildNumber: '427' },
20
+ android: { package: 'works.talos.mobile', versionCode: 423 },
21
+ };
22
+ function mockExpoConstants(expoConfig, extra = {}) {
23
+ mockExpoModule.default = { expoConfig, ...extra };
24
+ }
25
+ function loadResolve() {
26
+ let resolve;
27
+ jest.isolateModules(() => {
28
+ jest.doMock('react-native', () => mockReactNative);
29
+ jest.doMock('expo-constants', () => mockExpoModule, { virtual: true });
30
+ resolve = require('../P2PClient').resolveReportIdentity;
31
+ });
32
+ return resolve;
33
+ }
34
+ beforeEach(() => {
35
+ // P2PClient is imported by many suites with different virtual RN/Expo
36
+ // modules. Re-evaluate it against this suite's mutable modules every time;
37
+ // an order-dependent green test is not an identity-routing guard.
38
+ jest.resetModules();
39
+ mockReactNative.Platform.OS = 'ios';
40
+ mockReactNative.NativeModules = {};
41
+ mockExpoModule.default = {};
42
+ });
43
+ describe('resolveReportIdentity', () => {
44
+ it('resolves app name and bundle id from the Expo config', () => {
45
+ mockExpoConstants(EXPO_CONFIG);
46
+ const resolve = loadResolve();
47
+ const identity = resolve();
48
+ // The agent's fix router reads DeviceInfo.AppName first.
49
+ expect(identity.appName).toBe('Talos');
50
+ // ...and resolves the project by bundle id, the only unambiguous key.
51
+ expect(identity.project?.bundleId).toBe('works.talos.mobile');
52
+ expect(identity.project?.projectName).toBe('Talos');
53
+ expect(identity.project?.appName).toBe('Talos');
54
+ expect(identity.project?.surface).toBe('mobile');
55
+ expect(identity.app.bundleId).toBe('works.talos.mobile');
56
+ });
57
+ it('never reports a project path', () => {
58
+ mockExpoConstants(EXPO_CONFIG);
59
+ const resolve = loadResolve();
60
+ // The agent ignores client-supplied paths on feedback reports (an
61
+ // untrusted guest could otherwise aim the fix task's CWD at ~/.ssh).
62
+ // Sending one would be misleading at best.
63
+ expect(resolve().project?.projectPath).toBeUndefined();
64
+ });
65
+ it('prefers the native runtime version over the manifest version', () => {
66
+ mockExpoConstants(EXPO_CONFIG, { nativeAppVersion: '1.9.158', nativeBuildVersion: '428' });
67
+ const resolve = loadResolve();
68
+ const identity = resolve();
69
+ // A stale manifest shouldn't misreport which build is actually running.
70
+ expect(identity.app.version).toBe('1.9.158');
71
+ expect(identity.app.buildNumber).toBe('428');
72
+ });
73
+ it('falls back to the manifest version when no native version is exposed', () => {
74
+ mockExpoConstants(EXPO_CONFIG);
75
+ const resolve = loadResolve();
76
+ const identity = resolve();
77
+ expect(identity.app.version).toBe('1.9.157');
78
+ expect(identity.app.buildNumber).toBe('427');
79
+ });
80
+ // Inside Yaver's container the ambient runtime IS Yaver. These are the
81
+ // cases where getting it wrong routes a fix task into yaver.io and edits
82
+ // the SDK instead of the app under test.
83
+ describe('inside the Yaver container (Hermes guest)', () => {
84
+ const YAVER_CONFIG = {
85
+ name: 'Yaver',
86
+ version: '1.99.306',
87
+ ios: { bundleIdentifier: 'io.yaver.mobile' },
88
+ android: { package: 'io.yaver.mobile' },
89
+ };
90
+ function mockContainer(yaverInfo) {
91
+ mockReactNative.NativeModules = { YaverInfo: { isYaver: true, ...yaverInfo } };
92
+ // The host's manifest — what expo-constants answers for a guest bundle.
93
+ mockExpoConstants(YAVER_CONFIG);
94
+ }
95
+ it("never reports the host's bundle id as the guest's", () => {
96
+ mockContainer({ inheritedGuestProjectName: 'talos / mobile' });
97
+ const resolve = loadResolve();
98
+ const identity = resolve();
99
+ // The agent routes on bundle id FIRST. Leaking io.yaver.mobile here
100
+ // would resolve to yaver.io and the project name would never be read.
101
+ expect(identity.project?.bundleId).toBeUndefined();
102
+ expect(identity.app.bundleId).toBeUndefined();
103
+ // Nor should the host's version masquerade as the guest's build.
104
+ expect(identity.app.version).toBeUndefined();
105
+ });
106
+ it('reports the guest project Yaver pinned', () => {
107
+ mockContainer({ inheritedGuestProjectName: 'talos / mobile' });
108
+ const resolve = loadResolve();
109
+ const identity = resolve();
110
+ expect(identity.appName).toBe('talos / mobile');
111
+ expect(identity.project?.projectName).toBe('talos / mobile');
112
+ });
113
+ it("lets the app's own declaration win over the pinned project", () => {
114
+ mockContainer({ inheritedGuestProjectName: 'something-else' });
115
+ const resolve = loadResolve();
116
+ // A bundle knows its own identity; nothing ambient should override it.
117
+ const identity = resolve({ projectName: 'Talos', bundleId: 'works.talos.mobile' });
118
+ expect(identity.appName).toBe('Talos');
119
+ expect(identity.project?.bundleId).toBe('works.talos.mobile');
120
+ });
121
+ it('reports nothing rather than something wrong when no project is pinned', () => {
122
+ mockContainer({});
123
+ const resolve = loadResolve();
124
+ // Better for the agent to reject/fall back than to confidently edit
125
+ // the wrong repo.
126
+ const identity = resolve();
127
+ expect(identity.project).toBeUndefined();
128
+ expect(identity.appName).toBeUndefined();
129
+ expect(identity.app).toEqual({});
130
+ });
131
+ });
132
+ it('lets an explicit declaration override the ambient identity', () => {
133
+ mockExpoConstants(EXPO_CONFIG);
134
+ const resolve = loadResolve();
135
+ const identity = resolve({ projectName: 'Override', bundleId: 'com.override.app' });
136
+ expect(identity.appName).toBe('Override');
137
+ expect(identity.project?.bundleId).toBe('com.override.app');
138
+ });
139
+ it('passes declared surfaces stacks and voice metadata through', () => {
140
+ mockExpoConstants(EXPO_CONFIG);
141
+ const resolve = loadResolve();
142
+ const identity = resolve({
143
+ projectName: 'Omni',
144
+ bundleId: 'io.example.omni',
145
+ surface: 'vision',
146
+ surfaces: ['mobile', 'web', 'watch', 'tv', 'car', 'vision'],
147
+ stack: 'react-native-expo',
148
+ stacks: ['react-native-expo', 'nextjs', 'yaver-xml'],
149
+ testSurfaces: ['rn-hermes', 'browser', 'visionos-simulator'],
150
+ feedbackSdk: 'yaver-feedback-react-native',
151
+ feedbackTransport: 'device-sdk',
152
+ voiceCapabilities: ['stt', 'tts', 'device-mic'],
153
+ sttProvider: 'deepgram',
154
+ ttsProvider: 'local',
155
+ });
156
+ expect(identity.project).toMatchObject({
157
+ surface: 'vision',
158
+ surfaces: ['mobile', 'web', 'watch', 'tv', 'car', 'vision'],
159
+ stack: 'react-native-expo',
160
+ stacks: ['react-native-expo', 'nextjs', 'yaver-xml'],
161
+ testSurfaces: ['rn-hermes', 'browser', 'visionos-simulator'],
162
+ feedbackSdk: 'yaver-feedback-react-native',
163
+ feedbackTransport: 'device-sdk',
164
+ voiceCapabilities: ['stt', 'tts', 'device-mic'],
165
+ sttProvider: 'deepgram',
166
+ ttsProvider: 'local',
167
+ });
168
+ expect(identity.project?.projectPath).toBeUndefined();
169
+ });
170
+ it('omits the project block when nothing identifies the app', () => {
171
+ // Bare RN with no expo-constants and no native modules. The report must
172
+ // still upload — the agent just resolves it by its own means.
173
+ mockExpoModule.default = {};
174
+ const resolve = loadResolve();
175
+ const identity = resolve();
176
+ expect(identity.project).toBeUndefined();
177
+ expect(identity.appName).toBeUndefined();
178
+ expect(identity.app).toEqual({});
179
+ });
180
+ });
@@ -32,7 +32,7 @@ describe('SDK Token Auth', () => {
32
32
  const bundle = {
33
33
  metadata: {
34
34
  timestamp: '2026-03-25T00:00:00Z',
35
- device: { platform: 'ios', osVersion: '18', model: 'iPhone', screenWidth: 393, screenHeight: 852 },
35
+ deviceInfo: { platform: 'ios', osVersion: '18', model: 'iPhone', screenWidth: 393, screenHeight: 852 },
36
36
  app: {},
37
37
  },
38
38
  screenshots: [],