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
54 changes: 54 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,57 @@ jobs:

- name: Test
run: pnpm test

e2e:
name: Playwright E2E
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24.13.0"

- name: Setup pnpm
uses: pnpm/action-setup@v4

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Install Playwright browsers
run: pnpm exec playwright install --with-deps chromium

- name: Build app for E2E
run: pnpm build
env:
VITE_PUBLIC_TURNSTILE_SITE_KEY: ""
VITE_PUBLIC_POSTHOG_HOST: ""
VITE_PUBLIC_POSTHOG_KEY: ""

- name: Run Playwright tests
run: pnpm test:e2e
env:
ENV: dev
TURNSTILE_SECRET_KEY: dummy-turnstile-secret
BETTER_AUTH_SECRET: dummy-better-auth-secret-32chars
BETTER_AUTH_URL: http://127.0.0.1:4173
GITHUB_CLIENT_ID: dummy-github-client-id
GITHUB_CLIENT_SECRET: dummy-github-client-secret

- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report
if-no-files-found: ignore

- name: Upload Playwright test results
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-test-results
path: test-results
if-no-files-found: ignore
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,9 @@ dist
# Vite bundled output
dist
.output
playwright-report/
test-results/

.DS_Store

.omx
.omx
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,20 @@ If you encounter type errors, generate Cloudflare Worker types:
pnpm cf-typegen
```

### End-to-End Tests

Install the Chromium browser used by Playwright:

```bash
pnpm test:e2e:install
```

Run the Playwright smoke tests:

```bash
pnpm test:e2e
```

### Building

Build the production bundle:
Expand Down
1 change: 1 addition & 0 deletions client/features/upload-form/components/PreviewPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export function PreviewPane({
</div>

<div
data-testid="preview-iframe-wrapper"
className={cn(
"flex-1 relative bg-white transition-opacity duration-200",
loading ? "opacity-0" : "opacity-100",
Expand Down
2 changes: 2 additions & 0 deletions client/features/upload-form/components/SuccessView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export function SuccessView({ url, onReset }: SuccessViewProps) {
<div className="flex w-full gap-2 mb-6">
<input
type="text"
data-testid="success-url-input"
className="min-w-0 flex-1 bg-background border border-border rounded-md px-3 font-sans text-[13px] text-text-secondary outline-none h-9 transition-colors duration-200 focus:border-text-tertiary focus:text-text-primary"
readOnly
value={url}
Expand All @@ -60,6 +61,7 @@ export function SuccessView({ url, onReset }: SuccessViewProps) {
</button>
<a
href={url}
data-testid="success-open-link"
target="_blank"
rel="noopener noreferrer"
className="w-9 h-9 p-0 shrink-0 flex items-center justify-center bg-surface-highlight border border-border rounded-md text-text-secondary cursor-pointer transition-all duration-200 no-underline hover:bg-[#25262a] hover:border-text-tertiary hover:text-text-primary"
Expand Down
24 changes: 14 additions & 10 deletions client/features/upload-form/components/TurnstileWidget.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import Turnstile from "react-turnstile";
import { lazy, Suspense } from "react";

const Turnstile = lazy(() => import("react-turnstile"));

interface TurnstileWidgetProps {
onVerify: (token: string) => void;
Expand All @@ -12,14 +14,16 @@ export function TurnstileWidget({
onError,
}: TurnstileWidgetProps) {
return (
<Turnstile
sitekey={import.meta.env.VITE_PUBLIC_TURNSTILE_SITE_KEY}
appearance="interaction-only"
theme="dark"
onVerify={onVerify}
onExpire={onExpire}
onError={onError}
fixedSize
/>
<Suspense fallback={null}>
<Turnstile
sitekey={import.meta.env.VITE_PUBLIC_TURNSTILE_SITE_KEY}
appearance="interaction-only"
theme="dark"
onVerify={onVerify}
onExpire={onExpire}
onError={onError}
fixedSize
/>
</Suspense>
);
}
3 changes: 3 additions & 0 deletions client/features/upload-form/components/UploadView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ export function UploadView({
<input
type="file"
ref={fileInputRef}
data-testid="upload-file-input"
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"
accept=".md,.markdown,.txt"
onChange={handleInputChange}
Expand Down Expand Up @@ -403,6 +404,7 @@ export function UploadView({
<div className="p-1">
<button
type="button"
data-testid="upload-preview-button"
className="w-full bg-transparent border border-border text-text-secondary py-2.5 h-10 rounded-lg text-[13px] font-medium cursor-pointer transition-all duration-200 shadow-none flex items-center justify-center gap-2 mb-2 hover:enabled:bg-surface-highlight hover:enabled:border-text-tertiary hover:enabled:text-text-primary hover:enabled:shadow-[0_2px_4px_rgba(0,0,0,0.1)] disabled:bg-surface disabled:border-border disabled:text-text-tertiary disabled:cursor-not-allowed disabled:opacity-100"
disabled={!selectedFile}
onClick={onPreview}
Expand All @@ -421,6 +423,7 @@ export function UploadView({

<button
type="button"
data-testid="upload-create-button"
className={cn(
"w-full bg-linear-to-b from-primary to-[#4e5ac0] border border-white/8 border-t-white/15 text-white py-2.5 h-10 rounded-lg text-[13px] font-medium cursor-pointer transition-all duration-200 shadow-btn flex items-center justify-center gap-2",
"hover:enabled:-translate-y-px hover:enabled:shadow-btn-hover active:enabled:translate-y-0 active:enabled:opacity-90",
Expand Down
9 changes: 8 additions & 1 deletion client/features/upload-form/components/WarningDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,12 @@ export function WarningDialog({
<Dialog.Backdrop className="fixed inset-0 bg-black/80 z-1000 backdrop-blur-sm animate-fade-in" />
<Dialog.Viewport className="fixed inset-0 z-1000 flex items-center justify-center p-2.5">
<Dialog.Popup className="w-full max-w-[400px]">
<div className="bg-surface border border-border rounded-xl w-full flex flex-col shadow-dialog relative p-6 animate-fade-in">
<div
className="bg-surface border border-border rounded-xl w-full flex flex-col shadow-dialog relative p-6 animate-fade-in"
data-testid={
onSecondary ? "anonymous-warning-dialog" : "warning-dialog"
}
>
<div className="flex items-center justify-center mb-4">
<div
className={cn(
Expand Down Expand Up @@ -80,6 +85,7 @@ export function WarningDialog({
<button
{...rest}
type="button"
data-testid="warning-secondary-button"
onClick={(e) => {
onClick?.(e);
onSecondary();
Expand All @@ -104,6 +110,7 @@ export function WarningDialog({
<button
{...rest}
type="button"
data-testid="warning-confirm-button"
onClick={(e) => {
onClick?.(e);
onConfirm();
Expand Down
12 changes: 7 additions & 5 deletions client/features/upload-form/hooks/useUpload.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { resolveThemeId, type ThemeId } from "@shared/themes/theme-registry";
import { useMutation } from "@tanstack/react-query";
import { useCallback, useState } from "react";
import { useTurnstile } from "react-turnstile";
import { trpc } from "@/utils/trpc";

const DAY_MS = 24 * 60 * 60 * 1000;
Expand Down Expand Up @@ -55,8 +54,6 @@ export function useUpload({
const [uploadErrorStatus, setUploadErrorStatus] = useState<number | null>(
null,
);
const turnstile = useTurnstile();

const publicCreateMutation = useMutation(
trpc.upload.publicCreate.mutationOptions(),
);
Expand Down Expand Up @@ -109,7 +106,13 @@ export function useUpload({
}, 2000);
} finally {
setIsUploading(false);
if (import.meta.env.PROD) turnstile.reset();
if (import.meta.env.PROD) {
(
window as typeof window & {
turnstile?: { reset?: () => void };
}
).turnstile?.reset?.();
}
}
}, [
file,
Expand All @@ -120,7 +123,6 @@ export function useUpload({
onSuccess,
publicCreateMutation,
userCreateMutation,
turnstile,
]);

const handleReset = useCallback(() => {
Expand Down
10 changes: 10 additions & 0 deletions e2e/fixtures/sample.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Sample Title

This is a Playwright smoke test document.

- alpha
- beta

```ts
console.log("hello from playwright");
```
113 changes: 113 additions & 0 deletions e2e/public-upload.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { fileURLToPath } from "node:url";
import { expect, type Page, test } from "@playwright/test";

const fixturePath = fileURLToPath(
new URL("./fixtures/sample.md", import.meta.url),
);
const rawMarkdown = `# Sample Title

This is a Playwright smoke test document.

- alpha
- beta

\`\`\`ts
console.log("hello from playwright");
\`\`\`
`;

test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem("mdto.lang", "en");
});
});

async function attachMarkdownFile(page: Page) {
for (let attempt = 0; attempt < 3; attempt++) {
await page.getByTestId("upload-file-input").setInputFiles(fixturePath);

try {
await expect(page.getByText("sample.md")).toBeVisible({ timeout: 5000 });
await expect(page.getByTestId("upload-preview-button")).toBeEnabled();
await expect(page.getByTestId("upload-create-button")).toBeEnabled();
return;
} catch (error) {
if (attempt === 2) {
throw error;
}
await page.waitForTimeout(500);
}
}
}

test("home smoke shows upload controls in the initial state", async ({
page,
}) => {
await page.goto("/");

await expect(page.getByTestId("upload-file-input")).toBeAttached();
await expect(page.getByTestId("upload-preview-button")).toBeDisabled();
await expect(page.getByTestId("upload-create-button")).toBeDisabled();
});

test("preview opens and closes for an uploaded markdown file", async ({
page,
}) => {
await page.goto("/");
await attachMarkdownFile(page);

await page.getByTestId("upload-preview-button").click();

const previewFrame = page.frameLocator(
'[data-testid="preview-iframe-wrapper"] iframe',
);
await expect(page.getByTestId("preview-iframe-wrapper")).toBeVisible();
await expect(previewFrame.locator("h1")).toHaveText("Sample Title");
await expect(
previewFrame.getByText("This is a Playwright smoke test document."),
).toBeVisible();

await page.getByTestId("upload-preview-button").click();
await expect(page.getByTestId("preview-iframe-wrapper")).not.toBeVisible();
});

test("anonymous upload creates a public page and exposes the raw markdown", async ({
page,
}) => {
await page.goto("/");
await attachMarkdownFile(page);
await page.getByRole("button", { name: "GitHub" }).click();

await page.getByTestId("upload-create-button").click();
await expect(page.getByTestId("anonymous-warning-dialog")).toBeVisible();

await page.getByTestId("warning-confirm-button").click();

const successUrlInput = page.getByTestId("success-url-input");
await expect(successUrlInput).toBeVisible();
const successUrl = await successUrlInput.inputValue();

const url = new URL(successUrl);
expect(url.origin).toBe(new URL(page.url()).origin);
expect(url.pathname).toMatch(/^\/(1|7|E|1E)\/[A-Za-z0-9_-]{5}$/);
await expect(page.getByTestId("success-open-link")).toHaveAttribute(
"href",
successUrl,
);

await page.goto(successUrl);
await expect(page.locator("body")).toHaveClass(/theme-github/);
await expect(page.locator('link[href="/themes/github.css"]')).toHaveCount(1);
await expect(
page.getByRole("heading", { level: 1, name: "Sample Title" }),
).toBeVisible();
await expect(
page.getByText("This is a Playwright smoke test document."),
).toBeVisible();

const rawResponse = await page.request.get(`${successUrl}.md`);
expect(rawResponse.ok()).toBeTruthy();
expect(rawResponse.headers()["content-type"]).toContain("text/markdown");
const rawBody = await rawResponse.text();
expect(rawBody).toBe(rawMarkdown);
});
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
"lint": "biome check",
"lint:fix": "biome check --write",
"test": "vitest run",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:e2e:ui": "playwright test --ui",
"test:e2e:install": "playwright install --with-deps chromium",
"cf-typegen": "wrangler types",
"lifecycle:apply": "wrangler r2 bucket lifecycle set mdto --file r2-lifecycle.json",
"db:generate": "drizzle-kit generate",
Expand All @@ -26,6 +30,7 @@
"@biomejs/biome": "2.3.11",
"@cloudflare/vitest-pool-workers": "^0.8.19",
"@libsql/client": "^0.17.0",
"@playwright/test": "^1.55.0",
"@tailwindcss/vite": "^4.1.18",
"@types/js-yaml": "^4.0.9",
"@types/mdast": "^4.0.4",
Expand Down
Loading
Loading