mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
feat(frontend): Introduce secrets prop to hide from the terminal (#4529)
This commit is contained in:
parent
997dc80d18
commit
981b05fc2b
@ -25,7 +25,7 @@ vi.mock("@xterm/xterm", async (importOriginal) => ({
|
||||
}));
|
||||
|
||||
const renderTerminal = (commands: Command[] = []) =>
|
||||
renderWithProviders(<Terminal />, {
|
||||
renderWithProviders(<Terminal secrets={[]} />, {
|
||||
preloadedState: {
|
||||
cmd: {
|
||||
commands,
|
||||
@ -121,7 +121,7 @@ describe.skip("Terminal", () => {
|
||||
|
||||
// This test fails because it expects `disposeMock` to have been called before the component is unmounted.
|
||||
it.skip("should dispose the terminal on unmount", () => {
|
||||
const { unmount } = renderWithProviders(<Terminal />);
|
||||
const { unmount } = renderWithProviders(<Terminal secrets={[]} />);
|
||||
|
||||
expect(mockTerminal.dispose).not.toHaveBeenCalled();
|
||||
|
||||
|
||||
101
frontend/__tests__/hooks/use-terminal.test.tsx
Normal file
101
frontend/__tests__/hooks/use-terminal.test.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { afterEach } from "node:test";
|
||||
import { useTerminal } from "#/hooks/useTerminal";
|
||||
import { SocketProvider } from "#/context/socket";
|
||||
import { Command } from "#/state/commandSlice";
|
||||
|
||||
interface TestTerminalComponentProps {
|
||||
commands: Command[];
|
||||
secrets: string[];
|
||||
}
|
||||
|
||||
function TestTerminalComponent({
|
||||
commands,
|
||||
secrets,
|
||||
}: TestTerminalComponentProps) {
|
||||
const ref = useTerminal(commands, secrets);
|
||||
return <div ref={ref} />;
|
||||
}
|
||||
|
||||
describe("useTerminal", () => {
|
||||
const mockTerminal = vi.hoisted(() => ({
|
||||
loadAddon: vi.fn(),
|
||||
open: vi.fn(),
|
||||
write: vi.fn(),
|
||||
writeln: vi.fn(),
|
||||
onKey: vi.fn(),
|
||||
attachCustomKeyEventHandler: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeAll(() => {
|
||||
// mock ResizeObserver
|
||||
window.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}));
|
||||
|
||||
// mock Terminal
|
||||
vi.mock("@xterm/xterm", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@xterm/xterm")>()),
|
||||
Terminal: vi.fn().mockImplementation(() => mockTerminal),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render", () => {
|
||||
render(<TestTerminalComponent commands={[]} secrets={[]} />, {
|
||||
wrapper: SocketProvider,
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the commands in the terminal", () => {
|
||||
const commands: Command[] = [
|
||||
{ content: "echo hello", type: "input" },
|
||||
{ content: "hello", type: "output" },
|
||||
];
|
||||
|
||||
render(<TestTerminalComponent commands={commands} secrets={[]} />, {
|
||||
wrapper: SocketProvider,
|
||||
});
|
||||
|
||||
expect(mockTerminal.writeln).toHaveBeenNthCalledWith(1, "echo hello");
|
||||
expect(mockTerminal.writeln).toHaveBeenNthCalledWith(2, "hello");
|
||||
});
|
||||
|
||||
it("should hide secrets in the terminal", () => {
|
||||
const secret = "super_secret_github_token";
|
||||
const anotherSecret = "super_secret_another_token";
|
||||
const commands: Command[] = [
|
||||
{
|
||||
content: `export GITHUB_TOKEN=${secret},${anotherSecret},${secret}`,
|
||||
type: "input",
|
||||
},
|
||||
{ content: secret, type: "output" },
|
||||
];
|
||||
|
||||
render(
|
||||
<TestTerminalComponent
|
||||
commands={commands}
|
||||
secrets={[secret, anotherSecret]}
|
||||
/>,
|
||||
{
|
||||
wrapper: SocketProvider,
|
||||
},
|
||||
);
|
||||
|
||||
// BUG: `vi.clearAllMocks()` does not clear the number of calls
|
||||
// therefore, we need to assume the order of the calls based
|
||||
// on the test order
|
||||
expect(mockTerminal.writeln).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
`export GITHUB_TOKEN=${"*".repeat(10)},${"*".repeat(10)},${"*".repeat(10)}`,
|
||||
);
|
||||
expect(mockTerminal.writeln).toHaveBeenNthCalledWith(4, "*".repeat(10));
|
||||
});
|
||||
});
|
||||
@ -4,9 +4,13 @@ import { useTerminal } from "../../hooks/useTerminal";
|
||||
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
|
||||
function Terminal() {
|
||||
interface TerminalProps {
|
||||
secrets: string[];
|
||||
}
|
||||
|
||||
function Terminal({ secrets }: TerminalProps) {
|
||||
const { commands } = useSelector((state: RootState) => state.cmd);
|
||||
const ref = useTerminal(commands);
|
||||
const ref = useTerminal(commands, secrets);
|
||||
|
||||
return (
|
||||
<div className="h-full p-2 min-h-0">
|
||||
|
||||
@ -11,7 +11,10 @@ import { useSocket } from "#/context/socket";
|
||||
The reason for this is that the hook exposes a ref that requires a DOM element to be rendered.
|
||||
*/
|
||||
|
||||
export const useTerminal = (commands: Command[] = []) => {
|
||||
export const useTerminal = (
|
||||
commands: Command[] = [],
|
||||
secrets: string[] = [],
|
||||
) => {
|
||||
const { send } = useSocket();
|
||||
const terminal = React.useRef<Terminal | null>(null);
|
||||
const fitAddon = React.useRef<FitAddon | null>(null);
|
||||
@ -131,10 +134,16 @@ export const useTerminal = (commands: Command[] = []) => {
|
||||
if (terminal.current && commands.length > 0) {
|
||||
// Start writing commands from the last command index
|
||||
for (let i = lastCommandIndex.current; i < commands.length; i += 1) {
|
||||
const command = commands[i];
|
||||
terminal.current?.writeln(parseTerminalOutput(command.content));
|
||||
// eslint-disable-next-line prefer-const
|
||||
let { content, type } = commands[i];
|
||||
|
||||
if (command.type === "output") {
|
||||
secrets.forEach((secret) => {
|
||||
content = content.replaceAll(secret, "*".repeat(10));
|
||||
});
|
||||
|
||||
terminal.current?.writeln(parseTerminalOutput(content));
|
||||
|
||||
if (type === "output") {
|
||||
terminal.current.write(`\n$ `);
|
||||
}
|
||||
}
|
||||
|
||||
@ -133,6 +133,11 @@ function App() {
|
||||
const fetcher = useFetcher();
|
||||
const data = useRouteLoaderData<typeof rootClientLoader>("routes/_oh");
|
||||
|
||||
const secrets = React.useMemo(
|
||||
() => [ghToken, token].filter((secret) => secret !== null),
|
||||
[ghToken, token],
|
||||
);
|
||||
|
||||
// To avoid re-rendering the component when the user object changes, we memoize the user ID.
|
||||
// We use this to ensure the github token is valid before exporting it to the terminal.
|
||||
const userId = React.useMemo(() => {
|
||||
@ -321,7 +326,7 @@ function App() {
|
||||
* that it loads only in the client-side. */}
|
||||
<Container className="h-1/3 overflow-scroll" label="Terminal">
|
||||
<React.Suspense fallback={<div className="h-full" />}>
|
||||
<Terminal />
|
||||
<Terminal secrets={secrets} />
|
||||
</React.Suspense>
|
||||
</Container>
|
||||
</div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user