Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 66 additions & 12 deletions packages/cli/src/__tests__/pi-tui-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import { projectRuntimeHostModelChoices } from '../runtime-host-onboarding.js';
import {
getTuiPickerCopy,
modelChoiceConnectionLabels,
OnboardingWizard,
SessionSearchOverlay,
} from '../pi-tui-pickers.js';
import type {
Expand Down Expand Up @@ -102,6 +103,7 @@ import {
waitForTuiPaint,
} from './tui-terminal-mock.js';
import { waitFor as pollFor } from '@maka/core/test-only/async-primitives';
import { encodeExpectedRows } from './tui-render-expectations.js';

// Deadline for `Promise.race([run, …])` close watchdogs. A passing race
// resolves the moment `run` settles, so this only bounds how long a FAILING
Expand Down Expand Up @@ -1769,11 +1771,16 @@ describe('Maka Pi TUI runner', () => {
assert.equal(terminal.stopCalls, 1);
});

test('wizard identity step sends a caller-chosen slug and name on the create target', async () => {
test('wizard moves focus from Name to Slug and submits the edited identity', async (t) => {
const ENTER = '\r';
const CLEAR_LINE = '\x15'; // Ctrl+U.

const terminal = new FakeTerminal();
const driver = new SlashCommandDriver();
const verifyCalls: OnboardingVerifyInput[] = [];
const saveCalls: OnboardingSaveInput[] = [];
// Record real renders before TUI consumes the IME marker.
const wizardRenderSpy = t.mock.method(OnboardingWizard.prototype, 'render');
const run = runMakaPiTui({
title: 'Maka',
driver,
Expand Down Expand Up @@ -1804,23 +1811,70 @@ describe('Maka Pi TUI runner', () => {
}),
});

const assertIdentityFields = async (expectedScene: string) => {
await waitFor(() => wizardRenderSpy.mock.callCount() > 0, 'wizard redraw');
const latestRender = wizardRenderSpy.mock.calls.at(-1);
assert.ok(latestRender?.result, 'the wizard must render its fields');
const [width] = latestRender.arguments;
// Name, the separating blank row, and Slug in this identity layout.
const actualIdentityRows = latestRender.result.slice(3, 6);
assert.deepEqual(actualIdentityRows, encodeExpectedRows(expectedScene, width));
// The next assertion must observe a fresh render after the next input.
wizardRenderSpy.mock.resetCalls();
};

await waitForTuiPaint(terminal);
terminal.input('/setup');
terminal.input('\r');
terminal.input(ENTER);
await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('Set Up Provider'));
terminal.input('\r'); // pick the only row -> identity step, name focused
terminal.input(ENTER); // Select OpenAI; Name receives focus.
await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('2/4'));
// Replace the prefilled provider label with a display name.
for (let i = 0; i < 'OpenAI'.length; i++) terminal.input('\x7f');
await assertIdentityFields(`
Name OpenAI<cursor>

Slug openai
`);

terminal.input(CLEAR_LINE);
await assertIdentityFields(`
Name <cursor>

Slug openai
`);

terminal.input('Work OpenAI');
terminal.input('\r'); // name -> slug field
// Replace the derived suggestion with a chosen slug.
for (let i = 0; i < 'openai'.length; i++) terminal.input('\x7f');
await assertIdentityFields(`
Name Work OpenAI<cursor>

Slug openai
`);

terminal.input(ENTER);
await assertIdentityFields(`
Name Work OpenAI

Slug openai<cursor>
`);

terminal.input(CLEAR_LINE);
await assertIdentityFields(`
Name Work OpenAI

Slug <cursor>
`);

terminal.input('openai-work');
terminal.input('\r'); // slug -> key phase
await assertIdentityFields(`
Name Work OpenAI

Slug openai-work<cursor>
`);

// Continue through verification and saving with the edited Name and Slug.
terminal.input(ENTER);
await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('API key'));
terminal.input('sk-live');
terminal.input('\r');
terminal.input(ENTER);
await waitFor(() => verifyCalls.length === 1);
assert.deepEqual(verifyCalls[0]?.target, {
kind: 'create',
Expand All @@ -1830,11 +1884,11 @@ describe('Maka Pi TUI runner', () => {
});
await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('4/4'));
terminal.input(' '); // toggle the model on
terminal.input('\r'); // save
terminal.input(ENTER); // save
await waitFor(() => saveCalls.length === 1);
assert.deepEqual(saveCalls[0]?.target, verifyCalls[0]?.target);

process.emit('SIGTERM');
exitMaka(terminal);
await Promise.race([
run,
delay(CLOSE_BUDGET_MS).then(() => {
Expand Down
90 changes: 89 additions & 1 deletion packages/cli/src/__tests__/pi-tui-user-question-option.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,108 @@

import assert from 'node:assert/strict';
import { test } from 'node:test';
import { visibleWidth } from '@earendil-works/pi-tui';
import { TuiMainScreen, visibleWidth } from '@earendil-works/pi-tui';
import type { TUI } from '@earendil-works/pi-tui';
import {
clampRowsWithEllipsis,
formatUserQuestionOptionRow,
UserQuestionOverlay,
} from '../pi-tui-pickers.js';
import { ansi, stripAnsi } from '../tui-ansi.js';
import { FakeTerminal } from './tui-terminal-mock.js';
import { encodeExpectedRows } from './tui-render-expectations.js';

// SGR reverse degrades to identity when the terminal reports no color support
// (piped CI), so the highlight assertion keys off this build's actual behavior.
const REVERSE_ON = '\u001b[7m';
const COLOR_ENABLED = ansi.reverse('').length > 0;

test('Other preserves its wrapped draft and cursor position across focus changes', () => {
const WIDTH = 40;
const ARROW_UP = '\x1b[A';
const ARROW_DOWN = '\x1b[B';
const ARROW_LEFT = '\x1b[D';
const ENTER = '\r';

const submittedAnswers: string[] = [];
const tui = new TuiMainScreen(new FakeTerminal(WIDTH));
const question = new UserQuestionOverlay(tui, {
title: 'Pick one',
rightLabel: '1 / 1',
hint: '↑↓ move · type to answer',
placeholder: 'Other: type answer',
options: [{ label: 'Preset' }],
onSelectOption: () => undefined,
onSubmitText: (value) => submittedAnswers.push(value),
onSkip: () => undefined,
});

const assertQuestionBody = (expectedScene: string) => {
// Keep all choices and input rows; omit the title, hint, blank row and divider.
const actualRows = question.render(WIDTH).slice(3, -1);
assert.deepEqual(actualRows, encodeExpectedRows(expectedScene, WIDTH));
};

// Refocus with the cursor at the end of a wrapped answer.
const draft =
'Please use the custom provider and keep the current model settings for this workspace';
question.handleInput(draft);
assertQuestionBody(`
Preset
→ Please use the custom provider and
keep the current model settings for
this workspace<cursor>
`);

question.handleInput(ARROW_UP);
assertQuestionBody(`
<selected>→ Preset</selected>
Please use the custom provider and
keep the current model settings for
this workspace
`);

question.handleInput(ARROW_DOWN);
assertQuestionBody(`
Preset
→ Please use the custom provider and
keep the current model settings for
this workspace<cursor>
`);

// Refocus with the cursor over an existing character.
question.handleInput(ARROW_LEFT);
assertQuestionBody(`
Preset
→ Please use the custom provider and
keep the current model settings for
this workspac<cursor>e
`);

question.handleInput(ARROW_UP);
assertQuestionBody(`
<selected>→ Preset</selected>
Please use the custom provider and
keep the current model settings for
this workspace
`);

question.handleInput(ARROW_DOWN);
assertQuestionBody(`
Preset
→ Please use the custom provider and
keep the current model settings for
this workspac<cursor>e
`);

question.handleInput(ENTER);
assertQuestionBody(`
Preset
→ <cursor>
`);
assert.deepEqual(submittedAnswers, [draft]);
});

test('long options wrap within the row width instead of truncating (#4610)', () => {
const option = {
label: '默认省略 + 优雅降级(推荐)',
Expand Down
71 changes: 70 additions & 1 deletion packages/cli/src/__tests__/tui-autocomplete-layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,77 @@

import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
import { fitAutocompleteLines } from '../tui-autocomplete-layout.js';
import { Editor, Spacer, Text, TuiMainScreen } from '@earendil-works/pi-tui';
import {
fitAutocompleteLines,
MakaAutocompleteAboveEditorComponent,
} from '../tui-autocomplete-layout.js';
import { fitPendingQueueLines } from '../pi-tui-layout.js';
import { editorTheme } from '../tui-ansi.js';
import { FakeTerminal, plainTerminalOutput } from './tui-terminal-mock.js';
import { encodeExpectedRows } from './tui-render-expectations.js';

test('an overlay hides the composer cursor and preserves its draft and border colors', (t) => {
const WIDTH = 40;
const CYAN_FOREGROUND = '\x1b[36m';
const RESET_FOREGROUND = '\x1b[39m';

// Emit color even under NO_COLOR so accidental style loss remains detectable.
const borderColor = (text: string) => `${CYAN_FOREGROUND}${text}${RESET_FOREGROUND}`;
const terminal = new FakeTerminal(WIDTH, 4);
const tui = new TuiMainScreen(terminal);
t.after(() => tui.stop());
const editor = new Editor(tui, { ...editorTheme(), borderColor });
editor.setText('draft');
const composer = new MakaAutocompleteAboveEditorComponent(editor);
tui.addChild(new Spacer(1)); // Reserve the first row for the overlay.
tui.addChild(composer);
tui.setFocus(composer);
const composerRenderSpy = t.mock.method(composer, 'render');

const assertScreen = (expectedScene: string) => {
tui.renderNow(true);
const expectedRows = encodeExpectedRows(expectedScene, WIDTH);

// The terminal screen checks text and overlay placement, but omits styles.
const actualScreenRows = terminal
.screenOutput()
.split('\n')
.map((line) => line.padEnd(WIDTH));
const expectedScreenRows = expectedRows.map(plainTerminalOutput);
assert.deepEqual(actualScreenRows, expectedScreenRows);

// The real render retains the cursor, IME marker and per-character border colors.
const actualComposerRows = composerRenderSpy.mock.calls.at(-1)?.result;
const expectedComposerRows = expectedRows
.slice(1)
.map((line) => line.replaceAll('─', borderColor('─')));
assert.deepEqual(actualComposerRows, expectedComposerRows);
};

assertScreen(`

────────────────────────────────────────
draft<cursor>
────────────────────────────────────────
`);

const overlay = tui.showOverlay(new Text('Picker', 0, 0), { anchor: 'top-left' });
assertScreen(`
Picker
────────────────────────────────────────
draft
────────────────────────────────────────
`);

overlay.hide();
assertScreen(`

────────────────────────────────────────
draft<cursor>
────────────────────────────────────────
`);
});

describe('fitAutocompleteLines', () => {
test('keeps the selected item visible and reports the full command count', () => {
Expand Down
43 changes: 43 additions & 0 deletions packages/cli/src/__tests__/tui-render-expectations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import { CURSOR_MARKER, visibleWidth } from '@earendil-works/pi-tui';
import { ansi } from '../tui-ansi.js';

const REVERSE_ON = '\x1b[7m';
const RESET = '\x1b[0m';

// Encode a multiline expectation as full-width ANSI rows.
// <cursor> requires both the visible cursor and the IME marker at this position.
// These tests place the cursor on an ASCII character, or a space at row end.
// <selected>...</selected> highlights the entire padded row.
export function encodeExpectedRows(expectedScene: string, width: number): string[] {
// Remove only the template literal's framing newlines, preserving indentation.
const rows = expectedScene.split('\n').slice(1, -1);
return rows.map((row) => {
const isSelected = row.startsWith('<selected>');
const text = row.replace('<selected>', '').replace('</selected>', '');
const withCursor = text.replace(
/<cursor>(.)?/gu,
(_, character = ' ') => `${CURSOR_MARKER}${REVERSE_ON}${character}${RESET}`,
);
const paddedRow = withCursor + ' '.repeat(width - visibleWidth(withCursor));
return isSelected ? ansi.reverse(paddedRow) : paddedRow;
});
}
Loading