Skip to content
Closed
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: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
# Project paths/categories belong in sourcedraft.config.json instead

SOURCEDRAFT_ADMIN_PASSWORD=
# Set to true to run Studio in demo mode (no GitHub commits)
SOURCEDRAFT_DEMO_MODE=
GITHUB_TOKEN=
GITHUB_OWNER=
GITHUB_REPO=
Expand Down
29 changes: 29 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,32 @@
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: pnpm test

studio-e2e:
runs-on: ubuntu-latest
needs: build-and-test

steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4
with:
version: 11.1.2

- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm

- run: pnpm install --frozen-lockfile
- run: pnpm build

- name: Install Playwright Chromium
working-directory: apps/studio
run: pnpm exec playwright install --with-deps chromium

- name: Run Studio smoke tests (demo mode)
working-directory: apps/studio
env:
CI: true
run: pnpm test:e2e

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {contents: read}
Comment on lines +30 to +56
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ dist
.DS_Store
.vscode
.pnpm-store
apps/studio/test-results
apps/studio/playwright-report
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,13 @@ SourceDraft began as an internal tool for [QuBrite.com](https://qubrite.com) and

## Screenshots

Screenshots are not included in the repository yet. Expected images and capture instructions: [docs/screenshots.md](docs/screenshots.md).
![Studio overview](docs/assets/studio-overview.png)

When added, they will live in `docs/assets/` (overview, editor preview, media upload, publish success).
| | |
|---|---|
| ![Editor](docs/assets/editor.png) | ![Publish simulated in demo mode](docs/assets/publish-success.png) |

More views (toolbar, autosave, media library, content quality, preview, setup health): [docs/screenshots.md](docs/screenshots.md). Regenerate with `pnpm screenshots:generate`.

## What is SourceDraft?

Expand All @@ -36,6 +40,8 @@ Your static site — Astro today, others later — still builds and deploys exac
- Upload images to GitHub (`mediaDir`) from Studio
- Configure paths, adapter, and categories in `sourcedraft.config.json`
- Protect Studio with a server-side admin password
- **Demo mode** — explore Studio with sample posts without GitHub credentials
- **Setup health** — Settings panel checks for missing config (no secrets exposed)

## What it does not do yet

Expand Down Expand Up @@ -90,6 +96,8 @@ pnpm dev

Sign in, click **New post**, preview the output, publish. The file lands at `contentDir/<slug>.mdx` or `.md` depending on your adapter (default: `src/content/blog/`).

**Try without GitHub:** set `SOURCEDRAFT_DEMO_MODE=true` in `.env`, or leave GitHub vars empty and click **Explore demo mode** on the sign-in screen. Demo content reloads from repository fixtures on each API start. See [docs/demo-mode.md](docs/demo-mode.md).

Full walkthrough: [docs/getting-started.md](docs/getting-started.md)

## Beginner path
Expand Down Expand Up @@ -137,6 +145,7 @@ Issues and pull requests are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md) fo
## Documentation

- [Getting started](docs/getting-started.md)
- [Demo mode](docs/demo-mode.md)
- [Non-technical overview](docs/non-technical-overview.md) — for writers
- [GitHub publishing](docs/github-publishing.md)
- [Media uploads](docs/media.md)
Expand All @@ -146,6 +155,7 @@ Issues and pull requests are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md) fo
- [Adapters](docs/adapters.md)
- [Project status](docs/project-status.md)
- [Manual acceptance test](docs/manual-acceptance-test.md)
- [Smoke tests (Playwright)](docs/getting-started.md#smoke-tests-playwright)
- [Release checklist](RELEASE_CHECKLIST.md)
- [Security](docs/security.md)
- [Screenshots guide](docs/screenshots.md)
Expand Down
35 changes: 35 additions & 0 deletions apps/studio/e2e/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { expect, type Page } from "@playwright/test";
import { mkdirSync } from "node:fs";
import { resolve } from "node:path";

export const REPO_ROOT = resolve(import.meta.dirname, "../../..");
export const SCREENSHOT_DIR = resolve(REPO_ROOT, "docs/assets");

export const STUDIO_VIEWPORT = { width: 1280, height: 900 };

export function attachPageErrorLogging(page: Page): void {
page.on("pageerror", (error) => {
console.error("Page error:", error.message);
});
}

export async function waitForStudioRoot(page: Page): Promise<void> {
await page.goto("/");
await expect(page.locator("#root")).not.toBeEmpty({ timeout: 30_000 });
}

export async function enterDemoMode(page: Page): Promise<void> {
attachPageErrorLogging(page);
await waitForStudioRoot(page);
await expect(page.getByRole("heading", { name: "SourceDraft Studio" })).toBeVisible();
await page.getByRole("button", { name: "Explore demo mode" }).click();
await expect(page.getByText("Demo mode — no GitHub commits are made")).toBeVisible();
}

export function ensureScreenshotDir(): void {
mkdirSync(SCREENSHOT_DIR, { recursive: true });
}

export function screenshotPath(filename: string): string {
return resolve(SCREENSHOT_DIR, filename);
}
81 changes: 81 additions & 0 deletions apps/studio/e2e/screenshots.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { expect, test } from "@playwright/test";
import {
attachPageErrorLogging,
ensureScreenshotDir,
enterDemoMode,
screenshotPath,
STUDIO_VIEWPORT,
} from "./helpers.js";

test.describe("release screenshots", () => {
test.describe.configure({ mode: "serial" });

test.beforeAll(() => {
ensureScreenshotDir();
});

test.use({ viewport: STUDIO_VIEWPORT });

test("generates docs/assets screenshots from demo mode", async ({ page }) => {
test.setTimeout(120_000);
attachPageErrorLogging(page);

await enterDemoMode(page);
await page.screenshot({
path: screenshotPath("studio-overview.png"),
fullPage: false,
});

await page.getByRole("button", { name: "Getting started with SourceDraft" }).click();
await expect(page.locator(".writing-canvas__body")).toBeVisible();

await page.screenshot({
path: screenshotPath("editor.png"),
fullPage: false,
});

await page.locator(".editor-toolbar-wrap").screenshot({
path: screenshotPath("toolbar.png"),
});

await page.getByPlaceholder("Post title").fill("Screenshot autosave example");
await expect(page.getByText("Unsaved changes", { exact: false })).toBeVisible({
timeout: 5000,
});
await page.locator(".app-bar").screenshot({
path: screenshotPath("autosave.png"),
});

await page.locator(".media-library").screenshot({
path: screenshotPath("media-library.png"),
});

await page.locator(".content-quality").screenshot({
path: screenshotPath("content-quality.png"),
});

await page.locator(".preview-panel").screenshot({
path: screenshotPath("preview.png"),
});

await page.getByRole("button", { name: "New post" }).click();
await page.getByPlaceholder("Post title").fill("Screenshot publish example");
await page.getByPlaceholder("Short description or subtitle").fill(
"Summary used for automated publish-success screenshot.",
);
await page.locator(".writing-canvas__body").fill(
"# Screenshot publish example\n\nBody for release screenshot capture.",
);
await page.getByRole("button", { name: "Simulate publish" }).click();
await expect(page.getByText("Publish simulated")).toBeVisible({ timeout: 10_000 });
await page.locator(".publish-bar").screenshot({
path: screenshotPath("publish-success.png"),
});

await page.getByRole("button", { name: "Settings" }).click();
await expect(page.getByRole("heading", { name: "Setup health" })).toBeVisible();
await page.locator(".setup-health").screenshot({
path: screenshotPath("setup-health.png"),
});
});
});
78 changes: 78 additions & 0 deletions apps/studio/e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { expect, test } from "@playwright/test";
import {
attachPageErrorLogging,
enterDemoMode,
waitForStudioRoot,
} from "./helpers.js";

test.describe("Studio smoke", () => {
test("login/demo entry renders", async ({ page }) => {
attachPageErrorLogging(page);
await waitForStudioRoot(page);
await expect(page.getByRole("heading", { name: "SourceDraft Studio" })).toBeVisible();
await expect(page.getByRole("button", { name: "Explore demo mode" })).toBeVisible();
});

test("overview/post list renders in demo mode", async ({ page }) => {
await enterDemoMode(page);
await expect(page.getByRole("heading", { name: "Posts" })).toBeVisible();
await expect(page.getByText("Getting started with SourceDraft")).toBeVisible();
});

test("new post form and editor accept text", async ({ page }) => {
await enterDemoMode(page);
await page.getByRole("button", { name: "New post" }).click();
await page.getByPlaceholder("Post title").fill("Smoke test post");
await page.getByPlaceholder("Short description or subtitle").fill(
"A short summary for the smoke test post.",
);
const body = page.locator(".writing-canvas__body");
await body.fill("## Smoke test section\n\nBody text for smoke testing.");
await expect(body).toHaveValue(/Smoke test section/u);
});

test("toolbar inserts Markdown", async ({ page }) => {
await enterDemoMode(page);
await page.getByRole("button", { name: "New post" }).click();
const body = page.locator(".writing-canvas__body");
await body.fill("Selected text");
await body.selectText();
await page.getByRole("button", { name: "Bold" }).click();
await expect(body).toHaveValue("**Selected text**");
});

test("autosave status appears after edits", async ({ page }) => {
await enterDemoMode(page);
await page.getByRole("button", { name: "New post" }).click();
await page.getByPlaceholder("Post title").fill("Autosave smoke test");
await expect(page.getByText("Unsaved changes", { exact: false })).toBeVisible({
timeout: 5000,
});
});

test("media library and content quality panels render", async ({ page }) => {
await enterDemoMode(page);
await expect(page.getByRole("heading", { name: "Media library" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Content quality" })).toBeVisible();
});

test("settings setup health renders", async ({ page }) => {
await enterDemoMode(page);
await page.getByRole("button", { name: "Settings" }).click();
await expect(page.getByRole("heading", { name: "Setup health" })).toBeVisible();
await expect(page.getByText("Admin password")).toBeVisible();
await expect(page.getByText("GitHub token (server-side)")).toBeVisible();
});

test("publish success can be simulated in demo mode", async ({ page }) => {
await enterDemoMode(page);
await page.getByRole("button", { name: "New post" }).click();
await page.getByPlaceholder("Post title").fill("Demo publish smoke test");
await page.getByPlaceholder("Short description or subtitle").fill(
"Summary for demo publish smoke test.",
);
await page.locator(".writing-canvas__body").fill("# Demo publish\n\nBody content.");
await page.getByRole("button", { name: "Simulate publish" }).click();
await expect(page.getByText("Publish simulated")).toBeVisible({ timeout: 10_000 });
});
});
5 changes: 4 additions & 1 deletion apps/studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@
"start:server": "node dist-server/index.js",
"lint": "eslint .",
"preview": "vite preview",
"test": "node --import tsx --test src/**/*.test.ts server/**/*.test.ts"
"test": "node --import tsx --test src/**/*.test.ts server/**/*.test.ts",
"test:e2e": "playwright test e2e/smoke.spec.ts",
"screenshots:generate": "playwright test e2e/screenshots.spec.ts"
},
"dependencies": {
"@fontsource/ibm-plex-mono": "^5.2.7",
Expand All @@ -33,6 +35,7 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.55.0",
"@types/busboy": "^1.5.4",
"@types/express": "^5.0.3",
"@types/node": "^24.12.3",
Expand Down
25 changes: 25 additions & 0 deletions apps/studio/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { defineConfig } from "@playwright/test";
import { resolve } from "node:path";

const repoRoot = resolve(import.meta.dirname, "../..");

export default defineConfig({
testDir: "./e2e",
fullyParallel: false,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 1 : 0,
workers: 1,
reporter: "list",
use: {
baseURL: "http://127.0.0.1:5173",
trace: "on-first-retry",
viewport: { width: 1280, height: 900 },
},
webServer: {
command: "SOURCEDRAFT_DEMO_MODE=true STUDIO_API_PORT=8787 pnpm --filter studio dev",
cwd: repoRoot,
url: "http://127.0.0.1:5173",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Wait for the API before starting smoke tests

The web server readiness check only waits for Vite on port 5173, but the configured dev script starts Vite and the Express API concurrently. If Vite serves the app before tsx watch server/index.ts is listening on 8787, the initial /api/auth/status call is proxied to a closed port, fetchAuthStatus falls back to demoModeAvailable: false, and the smoke tests cannot find the “Explore demo mode” button. Add an API readiness wait (for example /api/auth/status) before tests interact with the page, or make the webServer command block until both processes are reachable.

Useful? React with 👍 / 👎.

reuseExistingServer: !process.env.CI,
timeout: 180_000,
},
});
Loading