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
2 changes: 1 addition & 1 deletion client/src/components/apps/tabs/UpdateTab.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -678,7 +678,7 @@ export default function UpdateTab() {
)}
</div>
{status.lastUpdateResult.log && (
<pre className="text-xs text-gray-400 mt-2 font-mono">{status.lastUpdateResult.log}</pre>
<pre className="text-xs text-gray-400 mt-2 font-mono whitespace-pre-wrap break-all max-h-48 overflow-y-auto">{status.lastUpdateResult.log}</pre>
)}
</div>
</div>
Expand Down
29 changes: 29 additions & 0 deletions client/src/components/apps/tabs/UpdateTab.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -331,3 +331,32 @@ describe('UpdateTab — active CoS agent suppression', () => {
expect(screen.getByRole('button', { name: 'Reconcile Now' })).toBeTruthy();
});
});

describe('UpdateTab — last update log', () => {
beforeEach(() => {
handlers.clear();
mockCheckHealth.mockReset().mockResolvedValue({ version: '2.24.0', uptime: 120 });
mockExecutePortosUpdate.mockReset();
mockToast.mockClear();
});

it('wraps a long single-line update log so its tail stays reachable', async () => {
// Real update logs contain long unbroken paths/URLs. The app shell is
// `overflow-x-hidden`, so an unwrapped <pre> clips the tail with no
// horizontal scrollbar to recover it — the wrap pair is the fix.
const log = `error: ${'no-spaces-in-this-path/'.repeat(20)}done`;
mockGetUpdateStatus.mockReset().mockResolvedValue({
currentVersion: '2.24.0',
lastUpdateResult: { success: false, version: '2.25.0', log },
});

render(<UpdateTab />);

const pre = await screen.findByText(log);
expect(pre.tagName).toBe('PRE');
expect(pre.className).toContain('whitespace-pre-wrap');
expect(pre.className).toContain('break-all');
// A multi-thousand-line log must not push the rest of the tab off-screen.
expect(pre.className).toContain('overflow-y-auto');
});
});
6 changes: 3 additions & 3 deletions client/src/components/cos/JobCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -530,7 +530,7 @@ export default function JobCard({
) : editData.type === 'script' ? (
<div className="space-y-1">
<span className="text-xs text-gray-400">Legacy script command (read-only)</span>
<pre className="w-full px-3 py-2 bg-port-bg border border-port-border rounded-lg text-gray-400 text-sm font-mono">{editData.command || 'No command'}</pre>
<pre className="w-full px-3 py-2 bg-port-bg border border-port-border rounded-lg text-gray-400 text-sm font-mono whitespace-pre-wrap break-all">{editData.command || 'No command'}</pre>
</div>
) : (
<textarea
Expand Down Expand Up @@ -585,13 +585,13 @@ export default function JobCard({
{isShell && job.lastOutput && (
<details className="group">
<summary className="text-xs text-gray-500 cursor-pointer hover:text-gray-300 transition-colors">Last output (exit {job.lastExitCode})</summary>
<pre className="mt-2 p-3 bg-port-bg border border-port-border rounded-lg text-xs text-gray-400 font-mono whitespace-pre-wrap max-h-48 overflow-y-auto">{job.lastOutput}</pre>
<pre className="mt-2 p-3 bg-port-bg border border-port-border rounded-lg text-xs text-gray-400 font-mono whitespace-pre-wrap break-all max-h-48 overflow-y-auto">{job.lastOutput}</pre>
</details>
)}
{!isShell && !isScript && (
<details className="group">
<summary className="text-xs text-gray-500 cursor-pointer hover:text-gray-300 transition-colors">View prompt template</summary>
<pre className="mt-2 p-3 bg-port-bg border border-port-border rounded-lg text-xs text-gray-400 font-mono whitespace-pre-wrap max-h-48 overflow-y-auto">{job.promptTemplate}</pre>
<pre className="mt-2 p-3 bg-port-bg border border-port-border rounded-lg text-xs text-gray-400 font-mono whitespace-pre-wrap break-words max-h-48 overflow-y-auto">{job.promptTemplate}</pre>
</details>
)}
{isConfirming(job.id) ? (
Expand Down
71 changes: 71 additions & 0 deletions client/src/components/cos/JobCard.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';

vi.mock('../ui/Toast', () => ({
default: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn(), loading: vi.fn(), dismiss: vi.fn() })
}));
vi.mock('../../services/api', () => ({ updateCosJob: vi.fn() }));

const JobCard = (await import('./JobCard')).default;

// A legacy script command with no spaces — the shape that used to run off the
// edge of the card, unreachable because Layout's root is `overflow-x-hidden`.
const LONG_COMMAND = `node scripts/${'legacy-handler-segment-'.repeat(20)}run.js`;

const SCRIPT_JOB = {
id: 'job-legacy',
name: 'Legacy handler',
description: '',
type: 'script',
interval: '1h',
intervalMs: 3600000,
priority: 'MEDIUM',
autonomyLevel: 'standby',
enabled: true,
command: LONG_COMMAND
};

const noop = () => {};

const renderCard = (job) => render(
<JobCard
job={job}
onToggle={noop}
onTrigger={noop}
onDelete={noop}
onUpdate={noop}
/>
);

describe('JobCard machine output', () => {
it('wraps the read-only legacy command so a long single-line value stays fully readable', () => {
renderCard(SCRIPT_JOB);
fireEvent.click(screen.getByRole('button', { name: 'Edit' }));

const pre = screen.getByText(LONG_COMMAND);
expect(pre.tagName).toBe('PRE');
// Without the wrap pair the tail of the command is clipped by the app
// shell's `overflow-x-hidden` with no scrollbar to recover it.
expect(pre.className).toContain('whitespace-pre-wrap');
expect(pre.className).toContain('break-all');
});

it('breaks unbroken tokens in a shell job last-output block, not just at whitespace', () => {
// `whitespace-pre-wrap` alone only wraps at whitespace — shell output is
// full of long unbroken paths and URLs that still run past the clip edge.
const lastOutput = `fatal: ${'unbroken-token-'.repeat(30)}end`;
renderCard({
...SCRIPT_JOB,
id: 'job-shell',
type: 'shell',
command: 'echo hi',
lastExitCode: 1,
lastOutput
});
fireEvent.click(screen.getByRole('button', { name: 'Expand' }));

const pre = screen.getByText(lastOutput);
expect(pre.tagName).toBe('PRE');
expect(pre.className).toContain('break-all');
});
});