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 .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": ["r2-explorer-dashboard", "r2-explorer-github-action", "docs-new"]
"ignore": ["r2-explorer-dashboard", "r2-explorer-github-action", "docs-new", "r2-explorer-dev-worker"]
}
2 changes: 0 additions & 2 deletions .changeset/fair-apples-attack.md

This file was deleted.

2 changes: 0 additions & 2 deletions .changeset/few-breads-push.md

This file was deleted.

5 changes: 0 additions & 5 deletions .changeset/fix-auth-download.md

This file was deleted.

2 changes: 0 additions & 2 deletions .changeset/sharp-lizards-trade.md

This file was deleted.

2 changes: 0 additions & 2 deletions .changeset/swift-ads-beg.md

This file was deleted.

28 changes: 28 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ jobs:
run: pnpm lint
- name: Build everything
run: pnpm build
- name: Run Dashboard Tests
run: cd packages/dashboard && pnpm test
- name: Run Worker Tests
run: cd packages/worker && pnpm test
- name: Package artifact
Expand All @@ -42,3 +44,29 @@ jobs:
with:
name: r2-explorer-npm-package
path: packages/worker/r2-explorer-*

e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '20.x'
- name: Install pnpm
run: npm install -g pnpm
- name: Install dependencies
run: pnpm install
- name: Build dashboard
run: pnpm build-dashboard
- name: Install Playwright browsers
run: pnpm exec playwright install --with-deps chromium
- name: Run E2E tests
run: pnpm test:e2e
- name: Upload Playwright report
uses: actions/upload-artifact@v6
if: ${{ !cancelled() }}
with:
name: playwright-report
path: packages/dashboard/test-results/
retention-days: 7
13 changes: 13 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,16 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_CONFIG_PROVENANCE: true

- name: Create GitHub Release
if: steps.changesets.outputs.published == 'true'
run: |
VERSION=$(echo '${{ steps.changesets.outputs.publishedPackages }}' | jq -r '.[] | select(.name == "r2-explorer") | .version')
if [ -n "$VERSION" ]; then
gh release create "v${VERSION}" \
--title "v${VERSION}" \
--generate-notes \
--target main
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ my-r2-explorer
packages/worker/bin
packages/worker/README.md
packages/worker/LICENSE
packages/worker/CHANGELOG.md
packages/worker/dist
packages/worker/docs
packages/worker/dashboard
packages/worker/r2-explorer-*

Expand All @@ -40,6 +42,7 @@ packages/github-action/src

packages/dashboard/.env
packages/dashboard/.quasar
packages/dashboard/test-results
.dev.vars

template/package-lock.json
Expand Down
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,9 @@ Every PR must include a changeset. Run `pnpm changeset` to create one.

- If the PR includes user-facing changes, write a changelog entry describing what changed with examples.
- If the PR is internal-only (refactoring, CI, docs, tooling), add an empty changeset with `pnpm changeset --empty`.

## Testing

- **Any UI changes must be covered by E2E tests.** Playwright E2E tests live in `packages/dashboard/e2e/`. Run them with `pnpm test:e2e`.
- Run component tests with `pnpm --filter r2-explorer-dashboard test`.
- Run all tests (worker + dashboard) with `pnpm test`.
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"lint": "npx @biomejs/biome check packages/dashboard/src packages/worker/src || (npx @biomejs/biome check --write packages/dashboard/src packages/worker/src/; exit 1)",
"build-dashboard": "pnpm run --filter r2-explorer-dashboard build",
"build-worker": "pnpm run --filter r2-explorer build",
"test": "pnpm run --filter r2-explorer test",
"test": "pnpm run --filter r2-explorer test && pnpm run --filter r2-explorer-dashboard test",
"test:e2e": "pnpm build-dashboard && pnpm exec playwright test --config packages/dashboard/playwright.config.ts",
"build": "pnpm build-dashboard && pnpm build-worker",
"deploy-dashboard": "pnpm run --filter r2-explorer-dashboard deploy",
"deploy-dashboard-dev": "pnpm run --filter r2-explorer-dashboard deploy-dev",
Expand All @@ -20,6 +21,7 @@
"@biomejs/biome": "1.9.4",
"@changesets/changelog-github": "^0.5.2",
"@changesets/cli": "^2.29.8",
"@playwright/test": "^1.58.2",
"wrangler": "^4.20.1"
}
}
28 changes: 28 additions & 0 deletions packages/dashboard/e2e/app-loads.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { test, expect } from "@playwright/test";
import { BUCKET } from "./helpers";

test.describe("App loads", () => {
test("renders the app shell with header and sidebar", async ({ page }) => {
await page.goto("/");

// Header with app title should be visible
await expect(
page.locator(".q-toolbar").locator("text=R2-Explorer"),
).toBeVisible({
timeout: 10_000,
});

// Sidebar navigation buttons should be visible
await expect(page.getByRole("button", { name: "Files" })).toBeVisible();
await expect(page.getByRole("button", { name: "Info" })).toBeVisible();
});

test("shows the file table when navigating to a bucket", async ({
page,
}) => {
await page.goto(`/${BUCKET}/files`);

// The file listing table should render
await expect(page.locator(".q-table")).toBeVisible({ timeout: 10_000 });
});
});
83 changes: 83 additions & 0 deletions packages/dashboard/e2e/context-menu.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { test, expect } from "@playwright/test";
import { uploadFile, createFolder, deleteObject, BUCKET } from "./helpers";

test.describe("Context menu", () => {
test.beforeAll(async ({ request }) => {
await uploadFile(request, "e2e-ctx-file.txt", "context menu test");
await createFolder(request, "e2e-ctx-folder");
});

test.afterAll(async ({ request }) => {
await deleteObject(request, "e2e-ctx-file.txt");
await deleteObject(request, "e2e-ctx-folder/");
});

test("shows file menu items on right-click", async ({ page }) => {
await page.goto(`/${BUCKET}/files`);
await expect(page.locator("text=e2e-ctx-file.txt")).toBeVisible({
timeout: 10_000,
});

await page.locator("text=e2e-ctx-file.txt").click({ button: "right" });

// File context menu should show all file-specific items (scoped to menu)
const menu = page.locator(".q-menu");
await expect(menu.getByText("Open")).toBeVisible();
await expect(menu.getByText("Download")).toBeVisible();
await expect(menu.getByText("Rename")).toBeVisible();
await expect(menu.getByText("Update Metadata")).toBeVisible();
await expect(menu.getByText("Create Share Link")).toBeVisible();
await expect(menu.getByText("Copy Internal Link")).toBeVisible();
await expect(menu.getByText("Delete")).toBeVisible();
});

test("shows folder menu items on right-click (no Download/Rename)", async ({
page,
}) => {
await page.goto(`/${BUCKET}/files`);
await expect(page.locator("text=e2e-ctx-folder/")).toBeVisible({
timeout: 10_000,
});

await page.locator("text=e2e-ctx-folder/").click({ button: "right" });

const menu = page.locator(".q-menu");

// Folder-specific: should have Open and Delete
await expect(menu.getByText("Open")).toBeVisible();
await expect(menu.getByText("Delete")).toBeVisible();

// Should NOT have file-only items
await expect(menu.getByText("Download")).not.toBeVisible();
await expect(menu.getByText("Rename")).not.toBeVisible();
await expect(menu.getByText("Update Metadata")).not.toBeVisible();
});

test("opens file via context menu Open", async ({ page }) => {
await page.goto(`/${BUCKET}/files`);
await expect(page.locator("text=e2e-ctx-file.txt")).toBeVisible({
timeout: 10_000,
});

await page.locator("text=e2e-ctx-file.txt").click({ button: "right" });
await page.locator(".q-menu").getByText("Open").click();

// File preview should open
await expect(page.locator("text=context menu test")).toBeVisible({
timeout: 10_000,
});
});

test("opens folder via context menu Open", async ({ page }) => {
await page.goto(`/${BUCKET}/files`);
await expect(page.locator("text=e2e-ctx-folder/")).toBeVisible({
timeout: 10_000,
});

await page.locator("text=e2e-ctx-folder/").click({ button: "right" });
await page.locator(".q-menu").getByText("Open").click();

// Should navigate into the folder (URL changes)
await expect(page).toHaveURL(/\/files\//, { timeout: 5_000 });
});
});
143 changes: 143 additions & 0 deletions packages/dashboard/e2e/email.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { test, expect } from "@playwright/test";
import { seedEmail, cleanupPrefix, BUCKET } from "./helpers";

test.describe("Email", () => {
test.beforeAll(async ({ request }) => {
await seedEmail(request, "1000000000000-e2e-email-1", {
subject: "Welcome to E2E Testing",
fromName: "Alice Sender",
fromAddress: "alice@example.com",
body: "This is the first test email body.",
read: false,
hasAttachments: false,
});
await seedEmail(request, "1000000000001-e2e-email-2", {
subject: "Second Test Email",
fromName: "Bob Tester",
fromAddress: "bob@example.com",
body: "This is the second test email.",
read: true,
hasAttachments: false,
});
});

test.afterAll(async ({ request }) => {
await cleanupPrefix(request, ".r2-explorer/emails/");
});

test("shows email list with sender and subject", async ({ page }) => {
await page.goto(`/${BUCKET}/email`);

// Wait for emails to load — use td.email-subject (visible desktop cell)
await expect(
page.locator("td.email-subject", { hasText: "Welcome to E2E Testing" }),
).toBeVisible({ timeout: 15_000 });

await expect(
page.locator("td.email-sender", { hasText: "Alice Sender" }),
).toBeVisible();

// Second email should also be visible
await expect(
page.locator("td.email-subject", { hasText: "Second Test Email" }),
).toBeVisible();
await expect(
page.locator("td.email-sender", { hasText: "Bob Tester" }),
).toBeVisible();
});

test("opens email detail view when clicking an email", async ({ page }) => {
await page.goto(`/${BUCKET}/email`);
await expect(
page.locator("td.email-subject", { hasText: "Welcome to E2E Testing" }),
).toBeVisible({ timeout: 15_000 });

await page
.locator("td.email-subject", { hasText: "Welcome to E2E Testing" })
.click();

// Should navigate to email detail view showing sender info
await expect(page.locator("text=alice@example.com")).toBeVisible({
timeout: 10_000,
});
// Should show the subject
await expect(page.locator("text=Welcome to E2E Testing")).toBeVisible();
// Should show the recipient
await expect(page.locator("text=test@example.com")).toBeVisible();
});

test("shows email body content in detail view", async ({ page }) => {
await page.goto(`/${BUCKET}/email`);
await expect(
page.locator("td.email-subject", { hasText: "Welcome to E2E Testing" }),
).toBeVisible({ timeout: 15_000 });

await page
.locator("td.email-subject", { hasText: "Welcome to E2E Testing" })
.click();

// Wait for detail to load
await expect(page.locator("text=alice@example.com")).toBeVisible({
timeout: 10_000,
});

// Email body should be displayed (HTML renders in iframe, text as div)
// Our seeded email has HTML: <p>This is the first test email body.</p>
// Check the iframe or text fallback contains the body
await expect(page.locator("iframe, div").first()).toBeVisible({
timeout: 10_000,
});
});

test("marks email as unread from detail view", async ({ page }) => {
await page.goto(`/${BUCKET}/email`);
await expect(
page.locator("td.email-subject", { hasText: "Welcome to E2E Testing" }),
).toBeVisible({ timeout: 15_000 });

// Open the email (this auto-marks as read)
await page
.locator("td.email-subject", { hasText: "Welcome to E2E Testing" })
.click();

await expect(page.locator("text=alice@example.com")).toBeVisible({
timeout: 10_000,
});

// After opening, the "mark as unread" button should appear
// (because the email was auto-marked as read)
const unreadBtn = page.locator(
'button:has(.q-icon:text-is("mark_email_unread"))',
);
await expect(unreadBtn).toBeVisible({ timeout: 10_000 });

// Click "mark as unread"
await unreadBtn.click();

// After marking as unread, the "mark as read" button should appear
await expect(
page.locator('button:has(.q-icon:text-is("mark_email_read"))'),
).toBeVisible({ timeout: 5_000 });
});

test("navigates between email list and Files", async ({ page }) => {
await page.goto(`/${BUCKET}/email`);
await expect(
page.locator("td.email-subject", { hasText: "Welcome to E2E Testing" }),
).toBeVisible({ timeout: 15_000 });

// Click Files button in sidebar
await page.getByRole("button", { name: "Files" }).click();

// Should be on the files page
await expect(page.locator(".q-table")).toBeVisible({ timeout: 10_000 });

// Click Email button to go back
await page.getByRole("button", { name: "Email" }).click();

// Should be back on email page
await expect(
page.locator("td.email-subject", { hasText: "Welcome to E2E Testing" }),
).toBeVisible({ timeout: 15_000 });
});
});
Loading
Loading