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.
- package/README.md +102 -1
- package/app.plugin.js +126 -9
- package/dist/AuthOverlay.d.ts +1 -14
- package/dist/AuthOverlay.js +9 -62
- package/dist/BlackBox.js +9 -1
- package/dist/DeployPanel.js +65 -0
- package/dist/Discovery.js +13 -8
- package/dist/DogfoodRuntime.d.ts +124 -0
- package/dist/DogfoodRuntime.js +273 -0
- package/dist/FeedbackModal.js +505 -65
- 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 +178 -2
- package/dist/P2PClient.js +493 -3
- package/dist/P2PDogfoodDriver.d.ts +12 -0
- package/dist/P2PDogfoodDriver.js +118 -0
- package/dist/PairDeviceModal.d.ts +2 -3
- package/dist/ShakeDetector.js +2 -0
- package/dist/VibeChatScreen.d.ts +11 -1
- package/dist/VibeChatScreen.js +200 -41
- package/dist/YaverFeedback.d.ts +132 -0
- package/dist/YaverFeedback.js +633 -65
- package/dist/YaverModeBadge.d.ts +22 -0
- package/dist/YaverModeBadge.js +219 -0
- package/dist/__tests__/AuthDevices.test.js +1 -48
- package/dist/__tests__/BlackBox.relayPassword.test.d.ts +1 -0
- package/dist/__tests__/BlackBox.relayPassword.test.js +105 -0
- package/dist/__tests__/BlackBoxAutoStart.test.d.ts +1 -0
- package/dist/__tests__/BlackBoxAutoStart.test.js +91 -0
- package/dist/__tests__/BlackBoxAutoStartColdStart.test.d.ts +1 -0
- package/dist/__tests__/BlackBoxAutoStartColdStart.test.js +156 -0
- package/dist/__tests__/BrowserLaneIcon.test.d.ts +3 -0
- package/dist/__tests__/BrowserLaneIcon.test.js +80 -0
- package/dist/__tests__/DogfoodRuntime.test.d.ts +1 -0
- package/dist/__tests__/DogfoodRuntime.test.js +116 -0
- package/dist/__tests__/P2PClient.test.js +2 -2
- 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 -0
- package/dist/__tests__/ReportIdentity.test.js +180 -0
- package/dist/__tests__/SDKToken.test.js +1 -1
- package/dist/__tests__/ShakeToggle.test.d.ts +1 -0
- package/dist/__tests__/ShakeToggle.test.js +121 -0
- 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/__tests__/pickTargetDevice.test.d.ts +1 -0
- package/dist/__tests__/pickTargetDevice.test.js +89 -0
- package/dist/__tests__/reloadActions.test.d.ts +1 -0
- package/dist/__tests__/reloadActions.test.js +129 -0
- package/dist/__tests__/reloadActionsParity.test.d.ts +1 -0
- package/dist/__tests__/reloadActionsParity.test.js +42 -0
- package/dist/__tests__/types.test.js +5 -5
- 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 +1 -5
- package/dist/_core/device.js +5 -14
- 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 +17 -4
- package/dist/index.js +33 -7
- package/dist/reloadActions.d.ts +88 -0
- package/dist/reloadActions.js +200 -0
- package/dist/storeShots.d.ts +67 -0
- package/dist/storeShots.js +137 -0
- package/dist/types.d.ts +208 -8
- package/package.json +13 -4
- package/src/AuthOverlay.tsx +20 -106
- package/src/BlackBox.ts +10 -1
- package/src/DeployPanel.tsx +74 -0
- package/src/Discovery.ts +13 -8
- package/src/DogfoodRuntime.ts +373 -0
- package/src/FeedbackModal.tsx +619 -79
- package/src/LoginScreen.tsx +2 -22
- package/src/MachinePickerScreen.tsx +6 -21
- package/src/P2PClient.ts +580 -4
- package/src/P2PDogfoodDriver.ts +132 -0
- package/src/PairDeviceModal.tsx +2 -3
- package/src/ShakeDetector.ts +1 -0
- package/src/VibeChatScreen.tsx +232 -42
- package/src/YaverFeedback.ts +631 -68
- package/src/YaverModeBadge.tsx +234 -0
- package/src/__tests__/AuthDevices.test.ts +1 -52
- package/src/__tests__/BlackBox.relayPassword.test.ts +129 -0
- package/src/__tests__/BlackBoxAutoStart.test.ts +111 -0
- package/src/__tests__/BlackBoxAutoStartColdStart.test.ts +191 -0
- package/src/__tests__/BrowserLaneIcon.test.ts +85 -0
- package/src/__tests__/DogfoodRuntime.test.ts +135 -0
- package/src/__tests__/P2PClient.test.ts +2 -2
- package/src/__tests__/P2PDogfoodDriver.test.ts +80 -0
- package/src/__tests__/ReportIdentity.test.ts +212 -0
- package/src/__tests__/SDKToken.test.ts +1 -1
- package/src/__tests__/ShakeToggle.test.ts +153 -0
- 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/__tests__/pickTargetDevice.test.ts +101 -0
- package/src/__tests__/reloadActions.test.ts +171 -0
- package/src/__tests__/reloadActionsParity.test.ts +49 -0
- package/src/__tests__/types.test.ts +5 -5
- 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 +5 -16
- 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 +61 -11
- package/src/reloadActions.ts +273 -0
- package/src/storeShots.ts +189 -0
- package/src/types.ts +209 -8
- package/dist/GuestOnboardingScreen.d.ts +0 -8
- package/dist/GuestOnboardingScreen.js +0 -282
- package/src/GuestOnboardingScreen.tsx +0 -307
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import {
|
|
2
|
+
describeReloadFailure,
|
|
3
|
+
reloadActions,
|
|
4
|
+
reloadFrameworkFamily,
|
|
5
|
+
reloadRequest,
|
|
6
|
+
RELOAD_APP_PATH,
|
|
7
|
+
RELOAD_PATH,
|
|
8
|
+
} from '../reloadActions';
|
|
9
|
+
|
|
10
|
+
const DEV = { isDevBuild: true, connected: true };
|
|
11
|
+
|
|
12
|
+
describe('reloadActions — the production guard', () => {
|
|
13
|
+
// THIS IS THE GUARD. Prove it by breaking it: flip `if (!opts.isDevBuild)`
|
|
14
|
+
// in reloadActions.ts to `if (opts.isDevBuild)` and this test fails while
|
|
15
|
+
// every other test in this file still passes.
|
|
16
|
+
it('returns NOTHING in a production build, even with a healthy dev server', () => {
|
|
17
|
+
const actions = reloadActions(
|
|
18
|
+
{ running: true, framework: 'vite' },
|
|
19
|
+
{ isDevBuild: false, connected: true, includeRebuild: true },
|
|
20
|
+
);
|
|
21
|
+
expect(actions).toEqual([]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('returns actions in a dev build', () => {
|
|
25
|
+
const actions = reloadActions({ running: true, framework: 'vite' }, DEV);
|
|
26
|
+
expect(actions.map((a) => a.id)).toEqual(['hot', 'full']);
|
|
27
|
+
expect(actions.every((a) => a.enabled)).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('reloadFrameworkFamily', () => {
|
|
32
|
+
it.each([
|
|
33
|
+
['flutter', 'flutter'],
|
|
34
|
+
['expo', 'react-native'],
|
|
35
|
+
['react-native', 'react-native'],
|
|
36
|
+
['vite', 'web'],
|
|
37
|
+
['nextjs', 'web'],
|
|
38
|
+
['', 'unknown'],
|
|
39
|
+
['godot', 'unknown'],
|
|
40
|
+
])('maps %s → %s', (framework, family) => {
|
|
41
|
+
expect(reloadFrameworkFamily(framework)).toBe(family);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('per-stack labels', () => {
|
|
46
|
+
it('calls the full action a Hot Restart on Flutter (stdin R), not a Full Reload', () => {
|
|
47
|
+
const actions = reloadActions({ running: true, framework: 'flutter' }, DEV);
|
|
48
|
+
expect(actions.find((a) => a.id === 'hot')!.label).toBe('Hot Reload');
|
|
49
|
+
expect(actions.find((a) => a.id === 'full')!.label).toBe('Hot Restart');
|
|
50
|
+
expect(actions.find((a) => a.id === 'full')!.hint).toContain('(R)');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('calls it a Full Reload everywhere else', () => {
|
|
54
|
+
for (const framework of ['expo', 'vite', 'nextjs']) {
|
|
55
|
+
const actions = reloadActions({ running: true, framework }, DEV);
|
|
56
|
+
expect(actions.find((a) => a.id === 'full')!.label).toBe('Full Reload');
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('URL / payload construction', () => {
|
|
62
|
+
it('sends fast for hot and full for full, both to /dev/reload', () => {
|
|
63
|
+
const actions = reloadActions({ running: true, framework: 'flutter' }, DEV);
|
|
64
|
+
expect(reloadRequest(actions[0])).toEqual({
|
|
65
|
+
method: 'POST',
|
|
66
|
+
path: RELOAD_PATH,
|
|
67
|
+
body: { mode: 'fast' },
|
|
68
|
+
});
|
|
69
|
+
expect(reloadRequest(actions[1])).toEqual({
|
|
70
|
+
method: 'POST',
|
|
71
|
+
path: RELOAD_PATH,
|
|
72
|
+
body: { mode: 'full' },
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('routes the RN bundle rebuild to /dev/reload-app', () => {
|
|
77
|
+
const actions = reloadActions(
|
|
78
|
+
{ running: false },
|
|
79
|
+
{ ...DEV, includeRebuild: true },
|
|
80
|
+
);
|
|
81
|
+
const rebuild = actions.find((a) => a.id === 'rebuild')!;
|
|
82
|
+
expect(reloadRequest(rebuild)).toEqual({
|
|
83
|
+
method: 'POST',
|
|
84
|
+
path: RELOAD_APP_PATH,
|
|
85
|
+
body: { mode: 'bundle' },
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('a blocked action NAMES the blocker', () => {
|
|
91
|
+
it('names the missing dev server and the command that starts it', () => {
|
|
92
|
+
const actions = reloadActions({ running: false }, { ...DEV, machineLabel: 'primary' });
|
|
93
|
+
expect(actions).toHaveLength(2);
|
|
94
|
+
for (const action of actions) {
|
|
95
|
+
expect(action.enabled).toBe(false);
|
|
96
|
+
expect(action.disabledReason).toContain('primary');
|
|
97
|
+
expect(action.disabledReason).toContain('yaver dev start');
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('names "still building" rather than pretending nothing is running', () => {
|
|
102
|
+
const actions = reloadActions({ running: true, building: true, framework: 'expo' }, DEV);
|
|
103
|
+
expect(actions[0].disabledReason).toContain('still building');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('names the missing machine when disconnected', () => {
|
|
107
|
+
const actions = reloadActions(
|
|
108
|
+
{ running: true, framework: 'expo' },
|
|
109
|
+
{ isDevBuild: true, connected: false },
|
|
110
|
+
);
|
|
111
|
+
expect(actions[0].disabledReason).toContain('Not connected');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('keeps Rebuild Bundle ENABLED with no dev server — that is its whole point', () => {
|
|
115
|
+
const actions = reloadActions({ running: false }, { ...DEV, includeRebuild: true });
|
|
116
|
+
const rebuild = actions.find((a) => a.id === 'rebuild')!;
|
|
117
|
+
expect(rebuild.enabled).toBe(true);
|
|
118
|
+
expect(rebuild.disabledReason).toBeUndefined();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('disables Rebuild Bundle when there is no machine', () => {
|
|
122
|
+
const actions = reloadActions(
|
|
123
|
+
{ running: true },
|
|
124
|
+
{ isDevBuild: true, connected: false, includeRebuild: true },
|
|
125
|
+
);
|
|
126
|
+
expect(actions.find((a) => a.id === 'rebuild')!.enabled).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('describeReloadFailure names a cause, never "failed"', () => {
|
|
131
|
+
it('503 → no dev server', () => {
|
|
132
|
+
expect(describeReloadFailure(503, 'dev server not available')).toContain(
|
|
133
|
+
'No dev server is running',
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('framework cannot hot reload → says so and points at the alternative', () => {
|
|
138
|
+
const msg = describeReloadFailure(500, 'unity does not support hot reload', {
|
|
139
|
+
running: true,
|
|
140
|
+
framework: 'unity',
|
|
141
|
+
});
|
|
142
|
+
expect(msg).toContain('unity');
|
|
143
|
+
expect(msg).toContain('Rebuild Bundle');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('loopback connection refused → the dev server is not listening', () => {
|
|
147
|
+
const msg = describeReloadFailure(
|
|
148
|
+
502,
|
|
149
|
+
'Get "http://127.0.0.1:8081/reload": dial tcp 127.0.0.1:8081: connect: connection refused',
|
|
150
|
+
);
|
|
151
|
+
expect(msg).toContain('not listening');
|
|
152
|
+
expect(msg).toContain('yaver dev start');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('401/403 → session, not server', () => {
|
|
156
|
+
expect(describeReloadFailure(401, '')).toContain('sign in again');
|
|
157
|
+
expect(describeReloadFailure(403, '')).toContain('sign in again');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('404 → the agent is too old, and says how to update it', () => {
|
|
161
|
+
expect(describeReloadFailure(404, 'not found')).toContain('yaver-cli@latest');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('5xx → points at the agent log', () => {
|
|
165
|
+
expect(describeReloadFailure(500, 'boom')).toContain('yaver logs');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('status 0 (transport never answered) → machine reachability', () => {
|
|
169
|
+
expect(describeReloadFailure(0, '')).toContain('yaver serve');
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `reloadActions.ts` is duplicated into yaver-feedback-react-native and
|
|
6
|
+
* yaver-feedback-web on purpose — they are two independently published npm
|
|
7
|
+
* packages, and neither may depend on the other.
|
|
8
|
+
*
|
|
9
|
+
* Duplication without a guard is DRIFT, and drift in this file is the worst
|
|
10
|
+
* kind: it is a *policy* file. If the web copy stops returning [] for a
|
|
11
|
+
* production build and the RN copy still does, the two SDKs disagree about
|
|
12
|
+
* whether a shipped app may show a reload button — and nothing in `tsc`
|
|
13
|
+
* notices, because they are separate compilation units.
|
|
14
|
+
*
|
|
15
|
+
* This is the same shape as beaconParity.test.ts in mobile/: read both
|
|
16
|
+
* sources, assert they agree. Prove it by breaking it — change one word in
|
|
17
|
+
* either copy and this test fails.
|
|
18
|
+
*/
|
|
19
|
+
const RN_COPY = join(__dirname, '..', 'reloadActions.ts');
|
|
20
|
+
const WEB_COPY = join(
|
|
21
|
+
__dirname,
|
|
22
|
+
'..', '..', '..', // sdk/feedback/react-native
|
|
23
|
+
'web', 'src', 'reloadActions.ts',
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
describe('reloadActions parity between the RN and web SDKs', () => {
|
|
27
|
+
it('both copies exist', () => {
|
|
28
|
+
expect(() => readFileSync(RN_COPY, 'utf8')).not.toThrow();
|
|
29
|
+
expect(() => readFileSync(WEB_COPY, 'utf8')).not.toThrow();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('is byte-identical across the two packages', () => {
|
|
33
|
+
const rn = readFileSync(RN_COPY, 'utf8');
|
|
34
|
+
const web = readFileSync(WEB_COPY, 'utf8');
|
|
35
|
+
expect(rn).toEqual(web);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('still carries the production guard in BOTH copies', () => {
|
|
39
|
+
// Named separately from the byte comparison so that if someone
|
|
40
|
+
// legitimately reformats both files the failure still points at the one
|
|
41
|
+
// line that actually matters.
|
|
42
|
+
for (const [name, path] of [['react-native', RN_COPY], ['web', WEB_COPY]] as const) {
|
|
43
|
+
const source = readFileSync(path, 'utf8');
|
|
44
|
+
expect(`${name}: ${source.includes('if (!opts.isDevBuild) return [];')}`).toEqual(
|
|
45
|
+
`${name}: true`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -51,7 +51,7 @@ describe('React Native SDK types', () => {
|
|
|
51
51
|
const bundle: FeedbackBundle = {
|
|
52
52
|
metadata: {
|
|
53
53
|
timestamp: '2026-03-24T12:00:00Z',
|
|
54
|
-
|
|
54
|
+
deviceInfo: {
|
|
55
55
|
platform: 'ios',
|
|
56
56
|
osVersion: '18.0',
|
|
57
57
|
model: 'iPhone 16 Pro',
|
|
@@ -68,7 +68,7 @@ describe('React Native SDK types', () => {
|
|
|
68
68
|
};
|
|
69
69
|
|
|
70
70
|
expect(bundle.metadata.timestamp).toBe('2026-03-24T12:00:00Z');
|
|
71
|
-
expect(bundle.metadata.
|
|
71
|
+
expect(bundle.metadata.deviceInfo.platform).toBe('ios');
|
|
72
72
|
expect(bundle.screenshots).toEqual([]);
|
|
73
73
|
expect(bundle.video).toBeUndefined();
|
|
74
74
|
});
|
|
@@ -77,7 +77,7 @@ describe('React Native SDK types', () => {
|
|
|
77
77
|
const bundle: FeedbackBundle = {
|
|
78
78
|
metadata: {
|
|
79
79
|
timestamp: '2026-03-24T12:00:00Z',
|
|
80
|
-
|
|
80
|
+
deviceInfo: {
|
|
81
81
|
platform: 'android',
|
|
82
82
|
osVersion: '15',
|
|
83
83
|
model: 'Pixel 9',
|
|
@@ -168,7 +168,7 @@ describe('React Native SDK types', () => {
|
|
|
168
168
|
bundle: {
|
|
169
169
|
metadata: {
|
|
170
170
|
timestamp: 'now',
|
|
171
|
-
|
|
171
|
+
deviceInfo: { platform: 'ios', osVersion: '18', model: 'iPhone', screenWidth: 393, screenHeight: 852 },
|
|
172
172
|
app: {},
|
|
173
173
|
},
|
|
174
174
|
screenshots: [],
|
|
@@ -187,7 +187,7 @@ describe('React Native SDK types', () => {
|
|
|
187
187
|
bundle: {
|
|
188
188
|
metadata: {
|
|
189
189
|
timestamp: 'now',
|
|
190
|
-
|
|
190
|
+
deviceInfo: { platform: 'ios', osVersion: '18', model: 'iPhone', screenWidth: 393, screenHeight: 852 },
|
|
191
191
|
app: {},
|
|
192
192
|
},
|
|
193
193
|
screenshots: [],
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// AUTO-SYNCED from shared/client-core/src/ansi.test.ts.
|
|
2
|
+
// DO NOT EDIT IN PLACE. Edit the source and re-run
|
|
3
|
+
// scripts/sync-client-core.sh. CI checks drift via `--check`.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* ansi.test.ts — guards for the shared ANSI tokenizer.
|
|
7
|
+
*
|
|
8
|
+
* The bug this exists for (2026-08-09): the dashboard and mobile app both
|
|
9
|
+
* flattened opencode's raw ANSI stream to plain text with stripAnsi, losing
|
|
10
|
+
* every colour the console has, while the xterm Terminal view kept the
|
|
11
|
+
* bytes — two surfaces, two looks, one product. The tokenizer below is the
|
|
12
|
+
* single classifier both chat renderers consume; these tests pin the tokens
|
|
13
|
+
* so a renderer drift (web spans vs mobile nested Text) can never silently
|
|
14
|
+
* change what a user sees.
|
|
15
|
+
*
|
|
16
|
+
* Run: npx tsx shared/client-core/src/ansi.test.ts
|
|
17
|
+
*/
|
|
18
|
+
import {
|
|
19
|
+
ANSI_16_RGB,
|
|
20
|
+
tokenizeAnsi,
|
|
21
|
+
ansiToPlain,
|
|
22
|
+
tokenStreamToLines,
|
|
23
|
+
paletteRgb,
|
|
24
|
+
classifyAnsiLine,
|
|
25
|
+
styleAnsiLines,
|
|
26
|
+
summarizeRawConsole,
|
|
27
|
+
} from "./ansi";
|
|
28
|
+
|
|
29
|
+
let failures = 0;
|
|
30
|
+
const eq = (got: unknown, want: unknown, label: string) => {
|
|
31
|
+
if (JSON.stringify(got) === JSON.stringify(want)) console.log(`ok ${label}`);
|
|
32
|
+
else { console.error(`FAIL ${label}:\n got ${JSON.stringify(got)}\n want ${JSON.stringify(want)}`); failures++; }
|
|
33
|
+
};
|
|
34
|
+
const ok = (c: unknown, label: string) => eq(Boolean(c), true, label);
|
|
35
|
+
|
|
36
|
+
// ── plain text passes through untouched ─────────────────────────────────
|
|
37
|
+
{
|
|
38
|
+
const t = tokenizeAnsi("hello world");
|
|
39
|
+
eq(t, [{ text: "hello world" }], "plain text → single unstyled token");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── SGR colours ──────────────────────────────────────────────────────────
|
|
43
|
+
{
|
|
44
|
+
const t = tokenizeAnsi("\x1b[31mred\x1b[0m plain");
|
|
45
|
+
eq(t, [
|
|
46
|
+
{ text: "red", fg: { kind: "named", index: 1 } },
|
|
47
|
+
{ text: " plain" },
|
|
48
|
+
], "red text then reset → two tokens");
|
|
49
|
+
}
|
|
50
|
+
{
|
|
51
|
+
const t = tokenizeAnsi("\x1b[32m+\x1b[0m added");
|
|
52
|
+
eq(t, [
|
|
53
|
+
{ text: "+", fg: { kind: "named", index: 2 } },
|
|
54
|
+
{ text: " added" },
|
|
55
|
+
], "green + line (git patch)");
|
|
56
|
+
}
|
|
57
|
+
{
|
|
58
|
+
const t = tokenizeAnsi("\x1b[1m\x1b[33m$ ls\x1b[0m");
|
|
59
|
+
eq(t, [
|
|
60
|
+
{ text: "$ ls", fg: { kind: "named", index: 3 }, bold: true },
|
|
61
|
+
], "bold yellow $ prompt");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── 256-colour palette ──────────────────────────────────────────────────
|
|
65
|
+
{
|
|
66
|
+
const t = tokenizeAnsi("\x1b[38;5;208morange\x1b[0m");
|
|
67
|
+
eq(t, [
|
|
68
|
+
{ text: "orange", fg: { kind: "palette", index: 208 } },
|
|
69
|
+
], "xterm-256 orange (208)");
|
|
70
|
+
}
|
|
71
|
+
{
|
|
72
|
+
const [r, g, b] = paletteRgb(208);
|
|
73
|
+
ok(r > 200 && g > 80 && g < 160 && b < 60, "palette 208 is orange-ish RGB");
|
|
74
|
+
}
|
|
75
|
+
{
|
|
76
|
+
const [r, g, b] = paletteRgb(2);
|
|
77
|
+
eq([r, g, b], [...ANSI_16_RGB[2]], "palette index <16 maps to the standard 16");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── truecolor ───────────────────────────────────────────────────────────
|
|
81
|
+
{
|
|
82
|
+
const t = tokenizeAnsi("\x1b[38;2;255;120;50mtrue\x1b[0m");
|
|
83
|
+
eq(t, [
|
|
84
|
+
{ text: "true", fg: { kind: "rgb", rgb: [255, 120, 50] } },
|
|
85
|
+
], "truecolor SGR 38;2;r;g;b");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── background ──────────────────────────────────────────────────────────
|
|
89
|
+
{
|
|
90
|
+
const t = tokenizeAnsi("\x1b[48;5;236mgray bg\x1b[0m");
|
|
91
|
+
eq(t, [
|
|
92
|
+
{ text: "gray bg", bg: { kind: "palette", index: 236 } },
|
|
93
|
+
], "xterm-256 background (patch gray)");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── bold / dim / underline / strike ─────────────────────────────────────
|
|
97
|
+
{
|
|
98
|
+
const t = tokenizeAnsi("\x1b[1mbold\x1b[22m \x1b[2mdim\x1b[0m \x1b[4munder\x1b[0m \x1b[9mstrike\x1b[0m");
|
|
99
|
+
eq(t, [
|
|
100
|
+
{ text: "bold", bold: true },
|
|
101
|
+
{ text: " " },
|
|
102
|
+
{ text: "dim", dim: true },
|
|
103
|
+
{ text: " " },
|
|
104
|
+
{ text: "under", underline: true },
|
|
105
|
+
{ text: " " },
|
|
106
|
+
{ text: "strike", strike: true },
|
|
107
|
+
], "bold/dim/underline/strike attributes");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── bare \x1b[m resets (the opencode banner emits this constantly) ─────
|
|
111
|
+
{
|
|
112
|
+
const t = tokenizeAnsi("\x1b[0m\n> build · deepseek-v4-flash\n\x1b[0m\n");
|
|
113
|
+
eq(t, [
|
|
114
|
+
{ text: "\n> build · deepseek-v4-flash\n\n" },
|
|
115
|
+
], "bare [0m sequences are dropped, text survives");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── $ prompt lines in a real opencode run ───────────────────────────────
|
|
119
|
+
{
|
|
120
|
+
const raw = "\x1b[0m\n> build · deepseek-v4-flash\n\x1b[0m\n\x1b[0m$ \x1b[0mls -la\n";
|
|
121
|
+
const t = tokenizeAnsi(raw);
|
|
122
|
+
eq(ansiToPlain(raw), "\n> build · deepseek-v4-flash\n\n$ ls -la\n", "plain extraction matches the visible console text");
|
|
123
|
+
const lines = tokenStreamToLines(t);
|
|
124
|
+
ok(lines.length >= 4, "token stream splits into lines at newlines");
|
|
125
|
+
const last = lines[lines.length - 1];
|
|
126
|
+
ok(last.tokens.some((tk) => tk.text.includes("$ ls -la")), "last line carries the $ prompt text");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── OSC-8 hyperlink extraction ──────────────────────────────────────────
|
|
130
|
+
{
|
|
131
|
+
const t = tokenizeAnsi("\x1b]8;;https://example.com\x07link text\x1b]8;;\x07 rest");
|
|
132
|
+
eq(t[0]?.href, "https://example.com", "OSC-8 link URL carried on the token");
|
|
133
|
+
eq(t[0]?.text, "link text", "OSC-8 link inner text kept");
|
|
134
|
+
eq(t[1]?.text, " rest", "text after link kept unstyled");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── OSC-8 scheme allowlist (the audit XSS finding, §6.1) ───────────────
|
|
138
|
+
// A prompt-injected \x1b]8;;javascript:…\x07 link used to reach the renderer
|
|
139
|
+
// verbatim and become an <a href> in the dashboard origin. The tokenizer now
|
|
140
|
+
// drops every scheme except http(s):// and mailto:// — the link becomes plain
|
|
141
|
+
// text, never a clickable/executable URL.
|
|
142
|
+
{
|
|
143
|
+
const evil = "\x1b]8;;javascript:alert(1)\x07click\x1b]8;;\x07";
|
|
144
|
+
const t = tokenizeAnsi(evil);
|
|
145
|
+
eq(t[0]?.href, undefined, "javascript: OSC-8 link is not carried as href");
|
|
146
|
+
eq(t[0]?.text, "click", "the inner text survives as plain text");
|
|
147
|
+
|
|
148
|
+
const data = tokenizeAnsi("\x1b]8;;data:text/html,<script>1</script>\x07x\x1b]8;;\x07");
|
|
149
|
+
eq(data[0]?.href, undefined, "data: OSC-8 link is dropped");
|
|
150
|
+
|
|
151
|
+
const proto = tokenizeAnsi("\x1b]8;;//evil.example/path\x07x\x1b]8;;\x07");
|
|
152
|
+
eq(proto[0]?.href, undefined, "protocol-relative OSC-8 link is dropped");
|
|
153
|
+
|
|
154
|
+
const bare = tokenizeAnsi("\x1b]8;;alert(1)\x07x\x1b]8;;\x07");
|
|
155
|
+
eq(bare[0]?.href, undefined, "scheme-less OSC-8 string is dropped");
|
|
156
|
+
|
|
157
|
+
const mail = tokenizeAnsi("\x1b]8;;mailto:a@b.co\x07mail\x1b]8;;\x07");
|
|
158
|
+
eq(mail[0]?.href, "mailto:a@b.co", "mailto: OSC-8 link is allowed");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── cursor/erase sequences become line breaks (TUI repaint) ─────────────
|
|
162
|
+
{
|
|
163
|
+
const t = tokenizeAnsi("line1\x1b[2Kline2");
|
|
164
|
+
const plain = t.map((x) => x.text).join("");
|
|
165
|
+
ok(plain.includes("\n"), "erase-line (K) leaves a line boundary for a repaint");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── unknown SGR codes are ignored, text survives ────────────────────────
|
|
169
|
+
{
|
|
170
|
+
const t = tokenizeAnsi("\x1b[999mweird\x1b[0m ok");
|
|
171
|
+
eq(t, [
|
|
172
|
+
{ text: "weird ok" },
|
|
173
|
+
], "unknown SGR code ignored, adjacent runs merge (no visible style change)");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (failures > 0) {
|
|
177
|
+
console.error(`\n${failures} FAILURE(s)`);
|
|
178
|
+
process.exit(1);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── structural console-line classification ──────────────────────────────
|
|
182
|
+
{
|
|
183
|
+
eq(classifyAnsiLine("> build · deepseek-v4-flash"), "banner", "opencode build banner");
|
|
184
|
+
eq(classifyAnsiLine("> plan · gpt-5.4"), "banner", "opencode plan banner");
|
|
185
|
+
eq(classifyAnsiLine("$ ls -la"), "prompt", "$ prompt");
|
|
186
|
+
eq(classifyAnsiLine("$"), "plain", "lone $ is plain (no command)");
|
|
187
|
+
eq(classifyAnsiLine("diff --git a/x.ts b/x.ts"), "diff-header", "git diff header");
|
|
188
|
+
eq(classifyAnsiLine("+++ b/src/x.ts"), "diff-file", "+++ file line");
|
|
189
|
+
eq(classifyAnsiLine("--- a/src/x.ts"), "diff-file", "--- file line");
|
|
190
|
+
eq(classifyAnsiLine("@@ -1,2 +1,3 @@"), "diff-hunk", "hunk header");
|
|
191
|
+
eq(classifyAnsiLine("+const x = 1;"), "diff-add", "patch add");
|
|
192
|
+
eq(classifyAnsiLine("-const y = 2;"), "diff-del", "patch del");
|
|
193
|
+
eq(classifyAnsiLine(" + indented keeps leading"), "plain", "indented + is not a patch add");
|
|
194
|
+
eq(classifyAnsiLine("⎿ run npx tsc"), "tool-call", "opencode tool tail");
|
|
195
|
+
eq(classifyAnsiLine("✓ 3 files changed"), "tool-call", "checkmark status");
|
|
196
|
+
eq(classifyAnsiLine("normal output line"), "plain", "ordinary line");
|
|
197
|
+
}
|
|
198
|
+
{
|
|
199
|
+
const styled = styleAnsiLines("\x1b[0m\n> build · deepseek-v4-flash\n\x1b[0m\n$ \x1b[0mls\n");
|
|
200
|
+
ok(styled.length >= 4, "styleAnsiLines splits into lines");
|
|
201
|
+
const hints = styled.map((l) => l.hint);
|
|
202
|
+
ok(hints.includes("banner"), "banner line classified through the one-stop helper");
|
|
203
|
+
ok(hints.includes("prompt"), "$ prompt classified through the one-stop helper");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── summarizeRawConsole (shared noisy-console reducer) ──────────────────
|
|
207
|
+
{
|
|
208
|
+
const raw = [
|
|
209
|
+
"> build · deepseek-v4-flash", // banner — kept
|
|
210
|
+
"$ npm run build", // prompt echo — dropped
|
|
211
|
+
"workdir: /repo", // runner config banner — dropped
|
|
212
|
+
"compiled 42 files", // real output — kept
|
|
213
|
+
"compiled 42 files", // repeat
|
|
214
|
+
"compiled 42 files", // 3rd repeat — collapsed
|
|
215
|
+
"compiled 42 files", // 4th — dropped
|
|
216
|
+
"diff --git a/x.ts b/x.ts", // diff hunk — dropped
|
|
217
|
+
"──────────", // TUI redraw edge — dropped
|
|
218
|
+
"✓ done", // status — kept
|
|
219
|
+
].join("\n");
|
|
220
|
+
const out = summarizeRawConsole(raw, false);
|
|
221
|
+
ok(out.includes("> build · deepseek-v4-flash"), "banner survives");
|
|
222
|
+
ok(!out.includes("$ npm run build"), "$ echo dropped");
|
|
223
|
+
ok(!out.includes("workdir:"), "config banner dropped");
|
|
224
|
+
ok(out.includes("compiled 42 files"), "real output kept");
|
|
225
|
+
ok(!out.includes("diff --git"), "diff hunk dropped");
|
|
226
|
+
ok(!out.includes("──────────"), "TUI redraw edge dropped");
|
|
227
|
+
ok(out.includes("✓ done"), "status kept");
|
|
228
|
+
ok(out.includes("noisy lines collapsed"), "collapse count reported");
|
|
229
|
+
// Running budget is tighter than finished.
|
|
230
|
+
const big = Array.from({ length: 200 }, (_, i) => `line ${i}`).join("\n");
|
|
231
|
+
const running = summarizeRawConsole(big, true).split("\n").length;
|
|
232
|
+
const finished = summarizeRawConsole(big, false).split("\n").length;
|
|
233
|
+
ok(running < finished, "running budget tighter than finished budget");
|
|
234
|
+
// KEEP THE TAIL (2026-08-13): the summarizer evicts the OLDEST kept line
|
|
235
|
+
// when the budget is full, so a live console follows the newest output —
|
|
236
|
+
// never drops it. The old head-keeping bug froze the web task console at
|
|
237
|
+
// the START of a long run while the header claimed it was live.
|
|
238
|
+
const tail = summarizeRawConsole(big, true).split("\n");
|
|
239
|
+
ok(tail.some((l) => l.startsWith("line 199")), "newest output survives the budget (tail kept)");
|
|
240
|
+
ok(!tail.some((l) => l.startsWith("line 0")), "oldest output evicted to make room");
|
|
241
|
+
// The tail must still be recognizably the END of the stream, and the
|
|
242
|
+
// collapse marker must not masquerade as a kept line.
|
|
243
|
+
const noMarker = tail.filter((l) => !l.startsWith("… "));
|
|
244
|
+
ok(noMarker[0].startsWith("line 160"), "first kept line is the oldest evicted window start (budget 40 of 200)");
|
|
245
|
+
// ANSI on kept lines survives (so AnsiConsoleText can still paint them).
|
|
246
|
+
const ansiLine = "\x1b[31mred text\x1b[0m";
|
|
247
|
+
ok(summarizeRawConsole(ansiLine, false).includes("\x1b[31m"), "kept lines keep their escapes");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
console.log("\nall ansi tests pass");
|