Skip to content
Open
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 app/javascript/entrypoints/s3_browser.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createRoot } from 'react-dom/client';
import App from '../../s3browser/src/app/App';
import App from '../src/app/App';

const s3BrowserAppElement = document.getElementById('s3-browser-app');
if (!s3BrowserAppElement) throw new Error('S3 Browser app root element not found');
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
208 changes: 208 additions & 0 deletions app/javascript/src/app/routes/__tests__/bucket-contents.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import { describe, it, expect } from 'vitest';
import {
buildBucketContents,
buildS3Object,
mockApi,
renderApp,
screen,
within,
waitForElementToBeRemoved,
userEvent,
} from '@/testing/test-utils';
import BucketContentsTable from '@/features/file-browser/components/bucket-contents-table';

const renderAppAndWait = async () => {
await renderApp(<BucketContentsTable />, {
url: '/browse/buckets/my-bucket',
path: '/browse/buckets/:bucketName',
});
await waitForElementToBeRemoved(() => screen.queryByRole('status'));
};

describe('BucketContentsRoute', () => {
describe('rendering bucket contents', () => {
it('renders folders and objects together, with folders typed as "Folder"', async () => {
const testBucket = buildBucketContents({
folders: ['documents/'],
objects: [buildS3Object({ key: 'notes.txt' })],
});
mockApi('get', '/buckets/my-bucket/list', testBucket);

await renderAppAndWait();

expect(screen.getByText('documents')).toBeInTheDocument();
expect(screen.getByText('notes.txt')).toBeInTheDocument();
expect(screen.getByText('Folder')).toBeInTheDocument();
expect(screen.getByText('txt')).toBeInTheDocument();
});

it('displays the expected column headers', async () => {
mockApi('get', '/buckets/my-bucket/list', buildBucketContents());

await renderAppAndWait();

const headers = await screen.findAllByRole('columnheader');
expect(headers.map((header) => header.textContent?.trim())).toEqual([
'Name',
'Last Modified',
'Type',
'Storage Class',
'Size',
]);
});

it('shows the bucket name as the current directory at the root', async () => {
mockApi('get', '/buckets/my-bucket/list', buildBucketContents());

await renderAppAndWait();

expect(await screen.findByText('my-bucket/')).toBeInTheDocument();
});

it('shows the last prefix segment as the current directory inside a folder', async () => {
mockApi('get', '/buckets/my-bucket/list', buildBucketContents());

await renderAppAndWait();

expect(await screen.findByText('my-bucket/')).toBeInTheDocument();
});

it('renders the empty state when the bucket has no contents', async () => {
mockApi('get', '/buckets/my-bucket/list', buildBucketContents());

await renderAppAndWait();

expect(screen.getByText('No entries found.')).toBeInTheDocument();
});

it('formats object size and renders "-" for folder size/storage class', async () => {
mockApi(
'get',
'/buckets/my-bucket/list',
buildBucketContents({
folders: ['archive/'],
objects: [
buildS3Object({
key: 'big.bin',
size: 1024 * 1024,
storageClass: 'INTELLIGENT_TIERING',
}),
],
}),
);

await renderAppAndWait();

const objectRow = screen.getByText('big.bin').closest('tr')!;
expect(within(objectRow).getByText('1.00 MB')).toBeInTheDocument();
expect(within(objectRow).getByText('Intelligent Tiering')).toBeInTheDocument();

// Folders have no Last Modified, Storage Class or Size so they all render "-"
const folderRow = screen.getByText('archive').closest('tr')!;
expect(within(folderRow).getAllByText('-')).toHaveLength(3);
});
});

describe('sorting bucket contents', () => {
const getNameOrder = () => {
const rows = screen.getAllByRole('row').slice(1); // drop header row
return rows.map((row) => within(row).getAllByRole('cell')[0].textContent);
};

it('reverses the name order when the Name header is clicked', async () => {
mockApi(
'get',
'/buckets/my-bucket/list',
buildBucketContents({
folders: ['mango/'],
objects: [buildS3Object({ key: 'apple.txt' }), buildS3Object({ key: 'zebra.txt' })],
}),
);

await renderAppAndWait();

// Name column is initially sorted ascending, the first click reverses the order
await userEvent.click(screen.getByRole('button', { name: 'Name' }));
expect(getNameOrder()).toEqual(['zebra.txt', 'mango', 'apple.txt']);
});

it('sorts folders before objects by type, then by extension, then by name', async () => {
mockApi(
'get',
'/buckets/my-bucket/list',
buildBucketContents({
folders: ['z-folder/', 'a-folder/'],
objects: [
buildS3Object({ key: 'delta.txt' }),
buildS3Object({ key: 'beta.txt' }),
buildS3Object({ key: 'zoo.jpg' }),
],
}),
);

await renderAppAndWait();
await userEvent.click(screen.getByRole('button', { name: 'Type' }));

// Folders first (alphabetical), then objects by extension (jpg < txt),
// then by name within the same extension (beta < delta).
expect(getNameOrder()).toEqual(['a-folder', 'z-folder', 'zoo.jpg', 'beta.txt', 'delta.txt']);
});

it('sorts objects chronologically by last modified, folders last', async () => {
mockApi(
'get',
'/buckets/my-bucket/list',
buildBucketContents({
folders: ['folder/'],
objects: [
buildS3Object({ key: 'newer.txt', lastModified: '2026-03-01T00:00:00.000Z' }),
buildS3Object({ key: 'older.txt', lastModified: '2026-01-01T00:00:00.000Z' }),
],
}),
);

await renderAppAndWait();
await userEvent.click(screen.getByRole('button', { name: 'Last Modified' }));

expect(getNameOrder()).toEqual(['older.txt', 'newer.txt', 'folder']);
});

it('sorts objects by storage class, folders last', async () => {
mockApi(
'get',
'/buckets/my-bucket/list',
buildBucketContents({
folders: ['folder/'],
objects: [
buildS3Object({ key: 'archival.txt', storageClass: 'INTELLIGENT_TIERING' }),
buildS3Object({ key: 'standard.txt', storageClass: 'STANDARD' }),
],
}),
);

await renderAppAndWait();
await userEvent.click(screen.getByRole('button', { name: 'Storage Class' }));

expect(getNameOrder()).toEqual(['archival.txt', 'standard.txt', 'folder']);
});

it('sorts objects numerically by size, folders last', async () => {
mockApi(
'get',
'/buckets/my-bucket/list',
buildBucketContents({
folders: ['folder/'],
objects: [
buildS3Object({ key: 'big.txt', size: 1024 * 1024 }),
buildS3Object({ key: 'small.txt', size: 10 }),
],
}),
);

await renderAppAndWait();
await userEvent.click(screen.getByRole('button', { name: 'Size' }));

expect(getNameOrder()).toEqual(['small.txt', 'big.txt', 'folder']);
});
});
});
49 changes: 49 additions & 0 deletions app/javascript/src/app/routes/__tests__/buckets.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest';
import { buildBucket, mockApi, renderApp, screen, within } from '@/testing/test-utils';
import BucketList from '@/features/file-browser/components/bucket-list';

describe('BucketsRoute', () => {
it('renders a list of buckets', async () => {
const bucket1 = buildBucket({ name: 'bucket-1' });
const bucket2 = buildBucket({ name: 'bucket-2' });

mockApi('get', '/buckets', { buckets: [bucket1, bucket2] });

await renderApp(<BucketList />, { url: '/buckets' });

expect(await screen.findByRole('heading', { name: 'S3 Buckets' })).toBeInTheDocument();
expect(await screen.findByText('bucket-1')).toBeInTheDocument();
expect(await screen.findByText('bucket-2')).toBeInTheDocument();
});

it('should display correct column headers', async () => {
const bucket1 = buildBucket({ name: 'bucket-1' });
const bucket2 = buildBucket({ name: 'bucket-2' });

mockApi('get', '/buckets', { buckets: [bucket1, bucket2] });

await renderApp(<BucketList />, { url: '/buckets' });

const columnHeaders = await screen.findAllByRole('columnheader');
const columnHeaderNames = columnHeaders.map((header) => header.textContent);

expect(columnHeaderNames).toEqual(['Name', 'Description']);
});

it('should display data sorted in ascending order by name', async () => {
const bucket1 = buildBucket({ name: 'z-bucket' });
const bucket2 = buildBucket({ name: 'a-bucket' });
const bucket3 = buildBucket({ name: 'm-bucket' });

mockApi('get', '/buckets', { buckets: [bucket1, bucket2, bucket3] });

await renderApp(<BucketList />, { url: '/buckets' });

await screen.findByRole('table');

const rows = screen.getAllByRole('row');
const names = rows.slice(1).map((row) => within(row).getAllByRole('cell')[0].textContent);

expect(names).toEqual(['a-bucket', 'm-bucket', 'z-bucket']);
});
});
Loading
Loading