yaver-feedback-react-native 0.9.2 → 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.
- package/README.md +102 -1
- package/dist/AuthOverlay.d.ts +1 -14
- package/dist/AuthOverlay.js +9 -62
- package/dist/Discovery.js +0 -6
- package/dist/DogfoodRuntime.d.ts +124 -0
- package/dist/DogfoodRuntime.js +273 -0
- package/dist/FeedbackModal.js +347 -61
- package/dist/LoginScreen.d.ts +1 -5
- package/dist/LoginScreen.js +2 -6
- package/dist/MachinePickerScreen.d.ts +1 -3
- package/dist/MachinePickerScreen.js +6 -18
- package/dist/P2PClient.d.ts +116 -3
- package/dist/P2PClient.js +273 -3
- package/dist/P2PDogfoodDriver.d.ts +12 -0
- package/dist/P2PDogfoodDriver.js +118 -0
- package/dist/PairDeviceModal.d.ts +2 -3
- package/dist/VibeChatScreen.d.ts +11 -1
- package/dist/VibeChatScreen.js +200 -41
- package/dist/YaverFeedback.d.ts +34 -0
- package/dist/YaverFeedback.js +124 -19
- package/dist/YaverModeBadge.d.ts +22 -0
- package/dist/YaverModeBadge.js +219 -0
- package/dist/__tests__/AuthDevices.test.js +1 -48
- package/dist/__tests__/DogfoodRuntime.test.d.ts +1 -0
- package/dist/__tests__/DogfoodRuntime.test.js +116 -0
- package/dist/__tests__/P2PDogfoodDriver.test.d.ts +1 -0
- package/dist/__tests__/P2PDogfoodDriver.test.js +64 -0
- package/dist/__tests__/ReportIdentity.test.d.ts +25 -1
- package/dist/__tests__/ReportIdentity.test.js +34 -22
- package/dist/__tests__/YaverFeedback.test.js +13 -3
- package/dist/__tests__/deviceDogfood.test.d.ts +1 -0
- package/dist/__tests__/deviceDogfood.test.js +86 -0
- package/dist/__tests__/dogfoodPolicy.test.d.ts +1 -0
- package/dist/__tests__/dogfoodPolicy.test.js +33 -0
- package/dist/_core/ansi.d.ts +117 -0
- package/dist/_core/ansi.js +468 -0
- package/dist/_core/ansi.test.d.ts +1 -0
- package/dist/_core/ansi.test.js +225 -0
- package/dist/_core/buildFeedbackPrompt.d.ts +4 -9
- package/dist/_core/buildFeedbackPrompt.js +18 -72
- package/dist/_core/constants.d.ts +19 -6
- package/dist/_core/constants.js +20 -7
- package/dist/_core/device.d.ts +9 -23
- package/dist/_core/device.js +13 -28
- package/dist/_core/endpoints.d.ts +0 -7
- package/dist/_core/endpoints.js +0 -7
- package/dist/_core/index.d.ts +4 -0
- package/dist/_core/index.js +4 -0
- package/dist/_core/remoteless.d.ts +44 -0
- package/dist/_core/remoteless.js +75 -0
- package/dist/_core/trace.d.ts +47 -0
- package/dist/_core/trace.js +38 -0
- package/dist/_core/trace.test.d.ts +1 -0
- package/dist/_core/trace.test.js +60 -0
- package/dist/auth.d.ts +3 -60
- package/dist/auth.js +6 -88
- package/dist/deviceDogfood.d.ts +53 -0
- package/dist/deviceDogfood.js +137 -0
- package/dist/dogfoodPolicy.d.ts +25 -0
- package/dist/dogfoodPolicy.js +24 -0
- package/dist/index.d.ts +13 -4
- package/dist/index.js +24 -8
- package/dist/reloadActions.js +2 -2
- package/dist/types.d.ts +50 -7
- package/package.json +12 -3
- package/src/AuthOverlay.tsx +20 -106
- package/src/Discovery.ts +0 -6
- package/src/DogfoodRuntime.ts +373 -0
- package/src/FeedbackModal.tsx +445 -67
- package/src/LoginScreen.tsx +2 -22
- package/src/MachinePickerScreen.tsx +6 -21
- package/src/P2PClient.ts +347 -4
- package/src/P2PDogfoodDriver.ts +132 -0
- package/src/PairDeviceModal.tsx +2 -3
- package/src/VibeChatScreen.tsx +232 -42
- package/src/YaverFeedback.ts +133 -22
- package/src/YaverModeBadge.tsx +234 -0
- package/src/__tests__/AuthDevices.test.ts +1 -52
- package/src/__tests__/DogfoodRuntime.test.ts +135 -0
- package/src/__tests__/P2PDogfoodDriver.test.ts +80 -0
- package/src/__tests__/ReportIdentity.test.ts +36 -27
- package/src/__tests__/YaverFeedback.test.ts +15 -3
- package/src/__tests__/deviceDogfood.test.ts +77 -0
- package/src/__tests__/dogfoodPolicy.test.ts +34 -0
- package/src/_core/ansi.test.ts +250 -0
- package/src/_core/ansi.ts +475 -0
- package/src/_core/buildFeedbackPrompt.ts +18 -95
- package/src/_core/constants.ts +20 -6
- package/src/_core/device.ts +13 -30
- package/src/_core/endpoints.ts +0 -7
- package/src/_core/index.ts +4 -0
- package/src/_core/remoteless.ts +110 -0
- package/src/_core/trace.test.ts +58 -0
- package/src/_core/trace.ts +75 -0
- package/src/auth.ts +7 -156
- package/src/deviceDogfood.ts +171 -0
- package/src/dogfoodPolicy.ts +40 -0
- package/src/index.ts +40 -11
- package/src/reloadActions.ts +2 -2
- package/src/types.ts +45 -7
- package/dist/GuestOnboardingScreen.d.ts +0 -8
- package/dist/GuestOnboardingScreen.js +0 -282
- package/src/GuestOnboardingScreen.tsx +0 -307
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DogfoodController,
|
|
3
|
+
DogfoodRuntimeError,
|
|
4
|
+
defaultDogfoodLane,
|
|
5
|
+
dogfoodLaneOptions,
|
|
6
|
+
dogfoodLogLinesFromDevEvent,
|
|
7
|
+
validateDogfoodProject,
|
|
8
|
+
type DogfoodDriver,
|
|
9
|
+
type DogfoodProject,
|
|
10
|
+
} from '../DogfoodRuntime';
|
|
11
|
+
|
|
12
|
+
const expo: DogfoodProject = {
|
|
13
|
+
name: 'Example', workDir: '/workspace/example', framework: 'expo', lane: 'browser',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
describe('DogfoodController', () => {
|
|
17
|
+
test('does no work before the explicit trigger', () => {
|
|
18
|
+
const driver: DogfoodDriver = { start: jest.fn(async () => ({ lane: 'browser' as const })) };
|
|
19
|
+
const controller = new DogfoodController(expo, driver);
|
|
20
|
+
expect(driver.start).not.toHaveBeenCalled();
|
|
21
|
+
expect(controller.snapshot().phase).toBe('idle');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('preserves raw npm output and hands a live session to the host', async () => {
|
|
25
|
+
const stop = jest.fn();
|
|
26
|
+
const closeLogs = jest.fn();
|
|
27
|
+
const controller = new DogfoodController(expo, {
|
|
28
|
+
async start(ctx) {
|
|
29
|
+
ctx.registerCleanup(stop, 'session');
|
|
30
|
+
ctx.registerCleanup(closeLogs, 'transient');
|
|
31
|
+
ctx.log('$ npm install --legacy-peer-deps');
|
|
32
|
+
ctx.log('npm warn deprecated example@1.0.0');
|
|
33
|
+
return { lane: 'browser', sessionId: 's1', url: 'http://agent/dev/' };
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
await controller.trigger();
|
|
38
|
+
expect(controller.snapshot().logs.map((line) => line.text)).toEqual([
|
|
39
|
+
'$ npm install --legacy-peer-deps',
|
|
40
|
+
'npm warn deprecated example@1.0.0',
|
|
41
|
+
]);
|
|
42
|
+
expect(await controller.handoff()).toMatchObject({ sessionId: 's1' });
|
|
43
|
+
expect(closeLogs).toHaveBeenCalledTimes(1);
|
|
44
|
+
expect(stop).not.toHaveBeenCalled();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('cleans a partial session before exposing a structured failure', async () => {
|
|
48
|
+
const stop = jest.fn();
|
|
49
|
+
const controller = new DogfoodController(expo, {
|
|
50
|
+
async start(ctx) {
|
|
51
|
+
ctx.registerCleanup(stop);
|
|
52
|
+
throw new DogfoodRuntimeError({
|
|
53
|
+
code: 'DOGFOOD_RENDER_FAILED', error: 'Metro exited', remedy: 'Fix Metro and retry.', retryable: true,
|
|
54
|
+
});
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
await expect(controller.trigger()).rejects.toThrow('Metro exited');
|
|
58
|
+
expect(stop).toHaveBeenCalledTimes(1);
|
|
59
|
+
expect(controller.snapshot()).toMatchObject({ phase: 'failed', failure: { code: 'DOGFOOD_RENDER_FAILED' } });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('an obsolete attempt cannot clean up the replacement attempt', async () => {
|
|
63
|
+
let releaseFirst!: () => void;
|
|
64
|
+
const firstGate = new Promise<void>((resolve) => { releaseFirst = resolve; });
|
|
65
|
+
let firstStarted!: () => void;
|
|
66
|
+
const firstStartedGate = new Promise<void>((resolve) => { firstStarted = resolve; });
|
|
67
|
+
const stopFirst = jest.fn();
|
|
68
|
+
const stopSecond = jest.fn();
|
|
69
|
+
let starts = 0;
|
|
70
|
+
const controller = new DogfoodController(expo, {
|
|
71
|
+
async start(ctx) {
|
|
72
|
+
starts += 1;
|
|
73
|
+
if (starts === 1) {
|
|
74
|
+
ctx.registerCleanup(stopFirst);
|
|
75
|
+
firstStarted();
|
|
76
|
+
await firstGate;
|
|
77
|
+
return { lane: 'browser', sessionId: 'old' };
|
|
78
|
+
}
|
|
79
|
+
ctx.registerCleanup(stopSecond);
|
|
80
|
+
return { lane: 'browser', sessionId: 'new' };
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const old = controller.trigger();
|
|
85
|
+
await firstStartedGate;
|
|
86
|
+
await controller.stop();
|
|
87
|
+
await expect(controller.trigger()).resolves.toMatchObject({ sessionId: 'new' });
|
|
88
|
+
releaseFirst();
|
|
89
|
+
await expect(old).rejects.toMatchObject({ failure: { code: 'DOGFOOD_ATTEMPT_REPLACED' } });
|
|
90
|
+
expect(stopFirst).toHaveBeenCalledTimes(1);
|
|
91
|
+
expect(stopSecond).not.toHaveBeenCalled();
|
|
92
|
+
await controller.stop();
|
|
93
|
+
expect(stopSecond).toHaveBeenCalledTimes(1);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('Dogfood lanes and console events', () => {
|
|
98
|
+
test('Flutter is first-class on browser but cannot be mislabeled Hermes', () => {
|
|
99
|
+
expect(validateDogfoodProject({ ...expo, framework: 'flutter', lane: 'browser' })).toBeNull();
|
|
100
|
+
expect(validateDogfoodProject({ ...expo, framework: 'flutter', lane: 'hermes' })?.code)
|
|
101
|
+
.toBe('DOGFOOD_HERMES_FRAMEWORK_UNSUPPORTED');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('uses one three-lane matrix with browser default for React Native and Flutter', () => {
|
|
105
|
+
const rn = dogfoodLaneOptions('expo', { nativeRuntimeAvailable: true });
|
|
106
|
+
expect(defaultDogfoodLane('expo')).toBe('browser');
|
|
107
|
+
expect(rn.map((option) => [option.lane, option.supported])).toEqual([
|
|
108
|
+
['browser', true], ['hermes', true], ['webrtc', true],
|
|
109
|
+
]);
|
|
110
|
+
const flutter = dogfoodLaneOptions('flutter', { nativeRuntimeAvailable: true });
|
|
111
|
+
expect(defaultDogfoodLane('flutter')).toBe('browser');
|
|
112
|
+
expect(flutter.find((option) => option.lane === 'hermes')?.supported).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('keeps Yaver self-development on the same RN three-lane contract', () => {
|
|
116
|
+
const options = dogfoodLaneOptions('expo', { nativeRuntimeAvailable: true, selfDevelopment: true });
|
|
117
|
+
expect(options).toHaveLength(3);
|
|
118
|
+
expect(options.find((option) => option.lane === 'hermes')).toMatchObject({ supported: true });
|
|
119
|
+
expect(options.find((option) => option.lane === 'webrtc')).toMatchObject({ supported: true });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('native-only projects use WebRTC rather than fake browser or Hermes lanes', () => {
|
|
123
|
+
const swift = dogfoodLaneOptions('swift', { nativeRuntimeAvailable: true });
|
|
124
|
+
expect(swift.map((option) => [option.lane, option.supported])).toEqual([
|
|
125
|
+
['browser', false], ['hermes', false], ['webrtc', true],
|
|
126
|
+
]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('reads raw and replayed package-manager logs from /dev/events', () => {
|
|
130
|
+
expect(dogfoodLogLinesFromDevEvent({ type: 'log', logLine: '$ npm ci\u001b[0m' }))
|
|
131
|
+
.toEqual(['$ npm ci\u001b[0m']);
|
|
132
|
+
expect(dogfoodLogLinesFromDevEvent({ type: 'snapshot', snapshot: { recentLogs: ['npm warn x', 'Metro ready'] } }))
|
|
133
|
+
.toEqual(['npm warn x', 'Metro ready']);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { DogfoodController } from '../DogfoodRuntime';
|
|
2
|
+
import { createP2PDogfoodDriver } from '../P2PDogfoodDriver';
|
|
3
|
+
import type { P2PClient } from '../P2PClient';
|
|
4
|
+
|
|
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: (event: unknown) => void) => {
|
|
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: string) => `http://agent.test${path}`,
|
|
20
|
+
} as unknown as P2PClient;
|
|
21
|
+
const snapshots: string[][] = [];
|
|
22
|
+
const controller = new DogfoodController(
|
|
23
|
+
{ name: 'Flutter app', framework: 'flutter', workDir: '/workspace/app', lane: 'browser' },
|
|
24
|
+
createP2PDogfoodDriver(client, { pollIntervalMs: 1, startupTimeoutMs: 100 }),
|
|
25
|
+
{ onChange: (snapshot) => snapshots.push(snapshot.logs.map((line) => line.text)) },
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const result = await controller.trigger();
|
|
29
|
+
|
|
30
|
+
expect(result.url).toBe('http://agent.test/dev/');
|
|
31
|
+
expect(status).toHaveBeenCalledTimes(2);
|
|
32
|
+
expect(snapshots.flat()).toContain('$ flutter run -d web-server');
|
|
33
|
+
expect(stop).not.toHaveBeenCalled();
|
|
34
|
+
await controller.stop();
|
|
35
|
+
expect(stop).toHaveBeenCalledTimes(1);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('starts and cleans up an available native WebRTC runtime', async () => {
|
|
39
|
+
const closeRuntime = jest.fn(async () => {});
|
|
40
|
+
const client = {
|
|
41
|
+
subscribeDogfoodDevEvents: () => jest.fn(),
|
|
42
|
+
getDogfoodRemoteRuntimeCapabilities: jest.fn(async () => ({
|
|
43
|
+
targets: [
|
|
44
|
+
{ id: 'browser-window', label: 'Browser', enabled: true },
|
|
45
|
+
{ id: 'ios-simulator', label: 'iPhone simulator', enabled: true },
|
|
46
|
+
],
|
|
47
|
+
})),
|
|
48
|
+
startDogfoodRemoteRuntime: jest.fn(async () => ({
|
|
49
|
+
id: 'runtime-1', status: 'starting', targetId: 'ios-simulator',
|
|
50
|
+
})),
|
|
51
|
+
stopDogfoodRemoteRuntime: closeRuntime,
|
|
52
|
+
} as unknown as P2PClient;
|
|
53
|
+
const controller = new DogfoodController(
|
|
54
|
+
{ name: 'Native app', framework: 'flutter', workDir: '/workspace/app', lane: 'webrtc' },
|
|
55
|
+
createP2PDogfoodDriver(client),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
await expect(controller.trigger()).resolves.toMatchObject({ lane: 'webrtc', sessionId: 'runtime-1' });
|
|
59
|
+
expect(closeRuntime).not.toHaveBeenCalled();
|
|
60
|
+
await controller.stop();
|
|
61
|
+
expect(closeRuntime).toHaveBeenCalledWith('runtime-1');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('delivers Hermes without stopping an unrelated dev server on exit', async () => {
|
|
65
|
+
const stop = jest.fn(async () => {});
|
|
66
|
+
const client = {
|
|
67
|
+
subscribeDogfoodDevEvents: () => jest.fn(),
|
|
68
|
+
startDogfoodDevServer: jest.fn(async () => ({ running: true, framework: 'expo', workDir: '/workspace/app' })),
|
|
69
|
+
stopDogfoodDevServer: stop,
|
|
70
|
+
} as unknown as P2PClient;
|
|
71
|
+
const controller = new DogfoodController(
|
|
72
|
+
{ name: 'RN app', framework: 'expo', workDir: '/workspace/app', lane: 'hermes' },
|
|
73
|
+
createP2PDogfoodDriver(client),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
await expect(controller.trigger()).resolves.toMatchObject({ lane: 'hermes', metadata: { delivered: true } });
|
|
77
|
+
await controller.stop();
|
|
78
|
+
expect(stop).not.toHaveBeenCalled();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -1,15 +1,17 @@
|
|
|
1
|
-
import { resolveReportIdentity } from '../P2PClient';
|
|
2
|
-
|
|
3
1
|
// resolveAppIdentity() has always fed /vibing/execute and /dev/reload-app so
|
|
4
2
|
// the agent could route "vibe on THIS app" to the right repo, but nothing fed
|
|
5
3
|
// /feedback — reports carried no identity at all, so the agent's fix router
|
|
6
4
|
// had nothing to resolve and fell back to whatever directory it was sitting
|
|
7
5
|
// in. resolveReportIdentity() closes that gap for the feedback path.
|
|
8
6
|
|
|
9
|
-
|
|
7
|
+
const mockReactNative = {
|
|
10
8
|
Platform: { OS: 'ios' },
|
|
11
9
|
NativeModules: {},
|
|
12
|
-
}
|
|
10
|
+
};
|
|
11
|
+
const mockExpoModule: { default: Record<string, unknown> } = { default: {} };
|
|
12
|
+
|
|
13
|
+
jest.mock('react-native', () => mockReactNative);
|
|
14
|
+
jest.mock('expo-constants', () => mockExpoModule, { virtual: true });
|
|
13
15
|
|
|
14
16
|
/** Mirrors mobile/app.json in the Talos repo — the SDK's first external consumer. */
|
|
15
17
|
const EXPO_CONFIG = {
|
|
@@ -21,17 +23,33 @@ const EXPO_CONFIG = {
|
|
|
21
23
|
};
|
|
22
24
|
|
|
23
25
|
function mockExpoConstants(expoConfig: unknown, extra: Record<string, unknown> = {}) {
|
|
24
|
-
|
|
26
|
+
mockExpoModule.default = { expoConfig, ...extra };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function loadResolve(): typeof import('../P2PClient').resolveReportIdentity {
|
|
30
|
+
let resolve!: typeof import('../P2PClient').resolveReportIdentity;
|
|
31
|
+
jest.isolateModules(() => {
|
|
32
|
+
jest.doMock('react-native', () => mockReactNative);
|
|
33
|
+
jest.doMock('expo-constants', () => mockExpoModule, { virtual: true });
|
|
34
|
+
resolve = require('../P2PClient').resolveReportIdentity;
|
|
35
|
+
});
|
|
36
|
+
return resolve;
|
|
25
37
|
}
|
|
26
38
|
|
|
27
39
|
beforeEach(() => {
|
|
40
|
+
// P2PClient is imported by many suites with different virtual RN/Expo
|
|
41
|
+
// modules. Re-evaluate it against this suite's mutable modules every time;
|
|
42
|
+
// an order-dependent green test is not an identity-routing guard.
|
|
28
43
|
jest.resetModules();
|
|
44
|
+
mockReactNative.Platform.OS = 'ios';
|
|
45
|
+
mockReactNative.NativeModules = {};
|
|
46
|
+
mockExpoModule.default = {};
|
|
29
47
|
});
|
|
30
48
|
|
|
31
49
|
describe('resolveReportIdentity', () => {
|
|
32
50
|
it('resolves app name and bundle id from the Expo config', () => {
|
|
33
51
|
mockExpoConstants(EXPO_CONFIG);
|
|
34
|
-
const
|
|
52
|
+
const resolve = loadResolve();
|
|
35
53
|
|
|
36
54
|
const identity = resolve();
|
|
37
55
|
|
|
@@ -47,7 +65,7 @@ describe('resolveReportIdentity', () => {
|
|
|
47
65
|
|
|
48
66
|
it('never reports a project path', () => {
|
|
49
67
|
mockExpoConstants(EXPO_CONFIG);
|
|
50
|
-
const
|
|
68
|
+
const resolve = loadResolve();
|
|
51
69
|
|
|
52
70
|
// The agent ignores client-supplied paths on feedback reports (an
|
|
53
71
|
// untrusted guest could otherwise aim the fix task's CWD at ~/.ssh).
|
|
@@ -57,7 +75,7 @@ describe('resolveReportIdentity', () => {
|
|
|
57
75
|
|
|
58
76
|
it('prefers the native runtime version over the manifest version', () => {
|
|
59
77
|
mockExpoConstants(EXPO_CONFIG, { nativeAppVersion: '1.9.158', nativeBuildVersion: '428' });
|
|
60
|
-
const
|
|
78
|
+
const resolve = loadResolve();
|
|
61
79
|
|
|
62
80
|
const identity = resolve();
|
|
63
81
|
// A stale manifest shouldn't misreport which build is actually running.
|
|
@@ -67,7 +85,7 @@ describe('resolveReportIdentity', () => {
|
|
|
67
85
|
|
|
68
86
|
it('falls back to the manifest version when no native version is exposed', () => {
|
|
69
87
|
mockExpoConstants(EXPO_CONFIG);
|
|
70
|
-
const
|
|
88
|
+
const resolve = loadResolve();
|
|
71
89
|
|
|
72
90
|
const identity = resolve();
|
|
73
91
|
expect(identity.app.version).toBe('1.9.157');
|
|
@@ -86,17 +104,14 @@ describe('resolveReportIdentity', () => {
|
|
|
86
104
|
};
|
|
87
105
|
|
|
88
106
|
function mockContainer(yaverInfo: Record<string, unknown>) {
|
|
89
|
-
|
|
90
|
-
Platform: { OS: 'ios' },
|
|
91
|
-
NativeModules: { YaverInfo: { isYaver: true, ...yaverInfo } },
|
|
92
|
-
}));
|
|
107
|
+
mockReactNative.NativeModules = { YaverInfo: { isYaver: true, ...yaverInfo } };
|
|
93
108
|
// The host's manifest — what expo-constants answers for a guest bundle.
|
|
94
109
|
mockExpoConstants(YAVER_CONFIG);
|
|
95
110
|
}
|
|
96
111
|
|
|
97
112
|
it("never reports the host's bundle id as the guest's", () => {
|
|
98
113
|
mockContainer({ inheritedGuestProjectName: 'talos / mobile' });
|
|
99
|
-
const
|
|
114
|
+
const resolve = loadResolve();
|
|
100
115
|
|
|
101
116
|
const identity = resolve();
|
|
102
117
|
|
|
@@ -110,7 +125,7 @@ describe('resolveReportIdentity', () => {
|
|
|
110
125
|
|
|
111
126
|
it('reports the guest project Yaver pinned', () => {
|
|
112
127
|
mockContainer({ inheritedGuestProjectName: 'talos / mobile' });
|
|
113
|
-
const
|
|
128
|
+
const resolve = loadResolve();
|
|
114
129
|
|
|
115
130
|
const identity = resolve();
|
|
116
131
|
expect(identity.appName).toBe('talos / mobile');
|
|
@@ -119,7 +134,7 @@ describe('resolveReportIdentity', () => {
|
|
|
119
134
|
|
|
120
135
|
it("lets the app's own declaration win over the pinned project", () => {
|
|
121
136
|
mockContainer({ inheritedGuestProjectName: 'something-else' });
|
|
122
|
-
const
|
|
137
|
+
const resolve = loadResolve();
|
|
123
138
|
|
|
124
139
|
// A bundle knows its own identity; nothing ambient should override it.
|
|
125
140
|
const identity = resolve({ projectName: 'Talos', bundleId: 'works.talos.mobile' });
|
|
@@ -129,7 +144,7 @@ describe('resolveReportIdentity', () => {
|
|
|
129
144
|
|
|
130
145
|
it('reports nothing rather than something wrong when no project is pinned', () => {
|
|
131
146
|
mockContainer({});
|
|
132
|
-
const
|
|
147
|
+
const resolve = loadResolve();
|
|
133
148
|
|
|
134
149
|
// Better for the agent to reject/fall back than to confidently edit
|
|
135
150
|
// the wrong repo.
|
|
@@ -142,7 +157,7 @@ describe('resolveReportIdentity', () => {
|
|
|
142
157
|
|
|
143
158
|
it('lets an explicit declaration override the ambient identity', () => {
|
|
144
159
|
mockExpoConstants(EXPO_CONFIG);
|
|
145
|
-
const
|
|
160
|
+
const resolve = loadResolve();
|
|
146
161
|
|
|
147
162
|
const identity = resolve({ projectName: 'Override', bundleId: 'com.override.app' });
|
|
148
163
|
expect(identity.appName).toBe('Override');
|
|
@@ -151,7 +166,7 @@ describe('resolveReportIdentity', () => {
|
|
|
151
166
|
|
|
152
167
|
it('passes declared surfaces stacks and voice metadata through', () => {
|
|
153
168
|
mockExpoConstants(EXPO_CONFIG);
|
|
154
|
-
const
|
|
169
|
+
const resolve = loadResolve();
|
|
155
170
|
|
|
156
171
|
const identity = resolve({
|
|
157
172
|
projectName: 'Omni',
|
|
@@ -186,14 +201,8 @@ describe('resolveReportIdentity', () => {
|
|
|
186
201
|
it('omits the project block when nothing identifies the app', () => {
|
|
187
202
|
// Bare RN with no expo-constants and no native modules. The report must
|
|
188
203
|
// still upload — the agent just resolves it by its own means.
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
() => {
|
|
192
|
-
throw new Error('not installed');
|
|
193
|
-
},
|
|
194
|
-
{ virtual: true },
|
|
195
|
-
);
|
|
196
|
-
const { resolveReportIdentity: resolve } = require('../P2PClient');
|
|
204
|
+
mockExpoModule.default = {};
|
|
205
|
+
const resolve = loadResolve();
|
|
197
206
|
|
|
198
207
|
const identity = resolve();
|
|
199
208
|
expect(identity.project).toBeUndefined();
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { DeviceEventEmitter } from 'react-native';
|
|
1
2
|
import { YaverFeedback } from '../YaverFeedback';
|
|
2
3
|
|
|
3
4
|
// Mock react-native: DeviceEventEmitter for event dispatch + Platform so
|
|
@@ -34,13 +35,10 @@ jest.mock('../auth', () => ({
|
|
|
34
35
|
needsAuth: false,
|
|
35
36
|
runnerDown: false,
|
|
36
37
|
lastHeartbeat: Date.now(),
|
|
37
|
-
isGuest: false,
|
|
38
|
-
accessScope: 'owner',
|
|
39
38
|
quicHost: '127.0.0.1',
|
|
40
39
|
quicPort: 18080,
|
|
41
40
|
},
|
|
42
41
|
],
|
|
43
|
-
shared: [],
|
|
44
42
|
})),
|
|
45
43
|
DEFAULT_CONVEX_SITE_URL: 'https://example.convex.site',
|
|
46
44
|
}));
|
|
@@ -54,6 +52,20 @@ beforeEach(() => {
|
|
|
54
52
|
});
|
|
55
53
|
|
|
56
54
|
describe('YaverFeedback', () => {
|
|
55
|
+
describe('Dogfood onboarding', () => {
|
|
56
|
+
it('starts with Yaver OAuth when the host has no session', () => {
|
|
57
|
+
YaverFeedback.init({ enabled: true });
|
|
58
|
+
YaverFeedback.beginDogfoodOnboarding({ appId: 'io.example.app', label: 'Example' });
|
|
59
|
+
expect(DeviceEventEmitter.emit).toHaveBeenCalledWith('yaverFeedback:startLogin');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('asks for a machine after an existing OAuth session', () => {
|
|
63
|
+
YaverFeedback.init({ enabled: true, authToken: 'owner-token' });
|
|
64
|
+
YaverFeedback.beginDogfoodOnboarding({ appId: 'io.example.app', label: 'Example' });
|
|
65
|
+
expect(DeviceEventEmitter.emit).toHaveBeenCalledWith('yaverFeedback:startMachinePicker');
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
57
69
|
describe('init()', () => {
|
|
58
70
|
it('sets config correctly with defaults', () => {
|
|
59
71
|
YaverFeedback.init({
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import nacl from 'tweetnacl';
|
|
2
|
+
import { Buffer } from 'buffer';
|
|
3
|
+
import { webcrypto } from 'crypto';
|
|
4
|
+
import { YaverDeviceDogfood } from '../deviceDogfood';
|
|
5
|
+
|
|
6
|
+
class MemorySecureStore {
|
|
7
|
+
values = new Map<string, string>();
|
|
8
|
+
async getItemAsync(key: string) { return this.values.get(key) ?? null; }
|
|
9
|
+
async setItemAsync(key: string, value: string) { this.values.set(key, value); }
|
|
10
|
+
async deleteItemAsync(key: string) { this.values.delete(key); }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function response(body: unknown, status = 200): Response {
|
|
14
|
+
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('YaverDeviceDogfood', () => {
|
|
18
|
+
beforeAll(() => {
|
|
19
|
+
if (!(globalThis as any).crypto) (globalThis as any).crypto = webcrypto;
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(() => { jest.restoreAllMocks(); });
|
|
23
|
+
|
|
24
|
+
test('keeps identity stable and proves possession rather than trusting the UUID', async () => {
|
|
25
|
+
const store = new MemorySecureStore();
|
|
26
|
+
const client = new YaverDeviceDogfood({ appId: 'io.example.test', secureStore: store, backendUrl: 'https://dogfood.test' });
|
|
27
|
+
const first = await client.enrollmentInfo();
|
|
28
|
+
const second = await client.enrollmentInfo();
|
|
29
|
+
expect(second).toEqual(first);
|
|
30
|
+
|
|
31
|
+
let proof: any;
|
|
32
|
+
jest.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
|
|
33
|
+
const url = String(input);
|
|
34
|
+
if (url.endsWith('/dogfood/enroll/start')) return response({ status: 'pending', challenge: 'server-nonce' });
|
|
35
|
+
if (url.endsWith('/dogfood/enroll/prove')) { proof = JSON.parse(String(init?.body)); return response({ status: 'pending', proofVerified: true }); }
|
|
36
|
+
throw new Error(`unexpected URL ${url}`);
|
|
37
|
+
});
|
|
38
|
+
await client.enroll('ios');
|
|
39
|
+
const message = new TextEncoder().encode(`yaver-dogfood-enroll-v1\nio.example.test\n${first.installationId}\nserver-nonce`);
|
|
40
|
+
expect(nacl.sign.detached.verify(message, new Uint8Array(Buffer.from(proof.signature, 'base64')), new Uint8Array(Buffer.from(first.publicKey, 'base64')))).toBe(true);
|
|
41
|
+
|
|
42
|
+
const attacker = nacl.sign.keyPair();
|
|
43
|
+
expect(nacl.sign.detached.verify(message, new Uint8Array(Buffer.from(proof.signature, 'base64')), attacker.publicKey)).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('re-register rotates key and installation while preserving only the logical slot', async () => {
|
|
47
|
+
const store = new MemorySecureStore();
|
|
48
|
+
const client = new YaverDeviceDogfood({ appId: 'io.example.test', secureStore: store, backendUrl: 'https://dogfood.test' });
|
|
49
|
+
const before = await client.enrollmentInfo();
|
|
50
|
+
jest.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
|
51
|
+
if (String(input).endsWith('/dogfood/enroll/start')) return response({ status: 'pending', challenge: 'rotate-nonce' });
|
|
52
|
+
return response({ status: 'pending', proofVerified: true });
|
|
53
|
+
});
|
|
54
|
+
await client.reRegister('ios');
|
|
55
|
+
const after = await client.enrollmentInfo();
|
|
56
|
+
expect(after.registrationSlot).toBe(before.registrationSlot);
|
|
57
|
+
expect(after.installationId).not.toBe(before.installationId);
|
|
58
|
+
expect(after.publicKey).not.toBe(before.publicKey);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('exchanges an approved key proof for a short-lived scoped session', async () => {
|
|
62
|
+
const client = new YaverDeviceDogfood({ appId: 'io.example.test', secureStore: new MemorySecureStore(), backendUrl: 'https://dogfood.test' });
|
|
63
|
+
const identity = await client.enrollmentInfo();
|
|
64
|
+
let sessionProof: any;
|
|
65
|
+
jest.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
|
|
66
|
+
const url = String(input);
|
|
67
|
+
if (url.includes('/dogfood/enroll/status')) return response({ status: 'active' });
|
|
68
|
+
if (url.endsWith('/dogfood/session/challenge')) return response({ challenge: 'session-nonce' });
|
|
69
|
+
if (url.endsWith('/dogfood/session')) { sessionProof = JSON.parse(String(init?.body)); return response({ token: 'short-token', expiresAt: 123, scopes: ['feedback', 'blackbox'], projectSlug: 'example' }); }
|
|
70
|
+
throw new Error(`unexpected URL ${url}`);
|
|
71
|
+
});
|
|
72
|
+
const session = await client.session();
|
|
73
|
+
expect(session).toMatchObject({ active: true, token: 'short-token', scopes: ['feedback', 'blackbox'] });
|
|
74
|
+
const message = new TextEncoder().encode(`yaver-dogfood-session-v1\nio.example.test\n${identity.installationId}\nsession-nonce`);
|
|
75
|
+
expect(nacl.sign.detached.verify(message, new Uint8Array(Buffer.from(sessionProof.signature, 'base64')), new Uint8Array(Buffer.from(identity.publicKey, 'base64')))).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { resolveSDKDogfood } from '../dogfoodPolicy';
|
|
2
|
+
|
|
3
|
+
describe('resolveSDKDogfood', () => {
|
|
4
|
+
it('fails closed unless Dogfood is explicitly enabled', () => {
|
|
5
|
+
expect(resolveSDKDogfood({ accountIds: ['acct-1'], currentAccountId: 'acct-1' })).toMatchObject({
|
|
6
|
+
active: false,
|
|
7
|
+
code: 'SDK_DOGFOOD_DISABLED',
|
|
8
|
+
});
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('requires an exact approved app-account ID', () => {
|
|
12
|
+
expect(resolveSDKDogfood({ enabled: true, accountIds: ['acct-1'], currentAccountId: '' }).code)
|
|
13
|
+
.toBe('SDK_DOGFOOD_ACCOUNT_REQUIRED');
|
|
14
|
+
expect(resolveSDKDogfood({ enabled: true, accountIds: ['acct-1'], currentAccountId: 'acct-10' }).code)
|
|
15
|
+
.toBe('SDK_DOGFOOD_ACCOUNT_NOT_ALLOWED');
|
|
16
|
+
expect(resolveSDKDogfood({ enabled: true, accountIds: ['acct-1'], currentAccountId: 'acct-1' })).toMatchObject({
|
|
17
|
+
active: true,
|
|
18
|
+
code: 'SDK_DOGFOOD_ACTIVE',
|
|
19
|
+
accountId: 'acct-1',
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('supports a key-enrolled installation without an app account', () => {
|
|
24
|
+
expect(resolveSDKDogfood({ enabled: true, appId: 'io.example', installationStatus: 'active' }).code)
|
|
25
|
+
.toBe('SDK_DOGFOOD_INSTALLATION_REQUIRED');
|
|
26
|
+
expect(resolveSDKDogfood({ enabled: true, appId: 'io.example', installationId: 'install-1', installationStatus: 'pending' }).code)
|
|
27
|
+
.toBe('SDK_DOGFOOD_INSTALLATION_NOT_ACTIVE');
|
|
28
|
+
expect(resolveSDKDogfood({ enabled: true, appId: 'io.example', installationId: 'install-1', installationStatus: 'active' })).toMatchObject({
|
|
29
|
+
active: true,
|
|
30
|
+
code: 'SDK_DOGFOOD_ACTIVE',
|
|
31
|
+
accountId: 'install-1',
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
});
|