test: e2e smoke; docs: README; ci: release workflow

Co-Authored-By: Claude
This commit is contained in:
2026-06-19 02:22:57 +00:00
parent 53c2550097
commit ee56f8c9a2
6 changed files with 406 additions and 0 deletions

View File

@@ -0,0 +1,108 @@
name: Release
# Manually-dispatched release: build the plugin, tag it, and publish an
# installable Gitea Release with the files Obsidian needs.
on:
workflow_dispatch:
inputs:
tag:
description: "Version to release (e.g. 0.1.0 — no leading v)"
required: true
default: "0.1.0"
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install dependencies
run: npm ci
- name: Unit tests
run: npm test
- name: Build
run: npm run build
- name: Sync version to tag
env:
TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
VERSION="${TAG#v}"
echo "Releasing version $VERSION (tag $TAG)"
tmp=$(mktemp)
jq --arg v "$VERSION" '.version = $v' manifest.json > "$tmp" && mv "$tmp" manifest.json
tmp=$(mktemp)
jq --arg v "$VERSION" '.version = $v' package.json > "$tmp" && mv "$tmp" package.json
# keep the built folder in sync with the bumped manifest
cp manifest.json dist/manifest.json
if ! git diff --quiet -- manifest.json package.json; then
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.gitea"
git add manifest.json package.json
git commit -m "chore: release $VERSION"
git push origin "HEAD:${{ github.ref_name }}"
else
echo "Version already $VERSION — no commit needed"
fi
- name: Create and push tag
env:
TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.gitea"
if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then
echo "::error::Tag $TAG already exists on origin"
exit 1
fi
git tag -a "$TAG" -m "Release $TAG"
git push origin "refs/tags/$TAG"
- name: Package artifacts
env:
TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
( cd dist && zip -j "../jekyll-publish-$TAG.zip" main.js manifest.json styles.css )
ls -la dist jekyll-publish-*.zip
- name: Publish Gitea release
env:
TAG: ${{ inputs.tag }}
TOKEN: ${{ github.token }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
run: |
set -euo pipefail
body=$(printf 'Install: download main.js, manifest.json and styles.css into `<vault>/.obsidian/plugins/jekyll-publish/`, then enable **Jekyll Publish** under Settings → Community plugins.')
payload=$(jq -n --arg tag "$TAG" --arg name "$TAG" --arg body "$body" \
'{tag_name:$tag, name:$name, body:$body, draft:false, prerelease:false}')
release=$(curl -sf -X POST "$API/releases" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "$payload")
id=$(echo "$release" | jq -r '.id')
echo "Created release id=$id"
for f in dist/main.js dist/manifest.json dist/styles.css "jekyll-publish-$TAG.zip"; do
name=$(basename "$f")
echo "Uploading $name"
curl -sf -X POST "$API/releases/$id/assets?name=$name" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$f" >/dev/null
done
echo "Release $TAG published with assets"

94
README.md Normal file
View File

@@ -0,0 +1,94 @@
# Obsidian Jekyll Publish
An Obsidian plugin that publishes the active note as a Jekyll blog post via git. Images are copied alongside the post using one of two configurable strategies, and credentials are handled by your system git — nothing is stored inside Obsidian.
## What it does
1. Opens a "Publish to Jekyll" modal for the currently active markdown file.
2. Lets you confirm or adjust the slug, date, frontmatter, image strategy, and commit message.
3. Clones (or updates) your Jekyll site repository into a temporary directory.
4. Writes the post as `_posts/<date>-<slug>.md` (or your configured path).
5. Copies any embedded images according to the selected strategy.
6. Commits and pushes to your configured remote and branch.
## Installation
1. Build the plugin (see Development below) or download the release assets.
2. Copy `dist/main.js`, `dist/manifest.json`, and `dist/styles.css` into:
```
<your-vault>/.obsidian/plugins/jekyll-publish/
```
3. Reload Obsidian and enable **Jekyll Publish** under Settings → Community plugins.
## Settings
| Setting | Description | Default |
|---|---|---|
| Remote URL | Git remote URL for your Jekyll site (HTTPS or SSH) | _(empty)_ |
| Branch | Branch to push to | `main` |
| Posts directory | Destination directory inside the repo | `_posts` |
| Images directory | Where images are copied inside the repo | `assets/img` |
| Default image strategy | `flat-slug` or `per-post-folder` (see below) | `flat-slug` |
| Preset frontmatter | Key/value pairs always injected into every post | _(empty)_ |
| Commit message template | Template; `{{title}}` is replaced by the note title | `Publish: {{title}}` |
| Author name | Git author name for commits | _(empty)_ |
| Author email | Git author email for commits | _(empty)_ |
## Image strategies
**flat-slug** — all images for the post are copied flat into `<images-dir>/` and renamed to `<slug>-<original-name>`. Keeps the images directory shallow; suitable for sites with few images per post.
**per-post-folder** — images are copied into `<images-dir>/<slug>/` preserving their original filenames. Keeps each post's images grouped together; suitable for posts with many images.
## Credentials model
The plugin uses your **system git** binary for all remote operations. No passwords or tokens are stored inside Obsidian or the plugin's data files.
When git needs a credential (e.g. HTTPS password or a personal access token), an **askpass bridge** intercepts the prompt and shows a native Obsidian modal so you can type the value. The value is passed directly to git through a temporary socket and is never persisted.
For SSH remotes, the plugin relies on your existing SSH agent or `~/.ssh` key configuration — no extra steps needed.
## Development
### Requirements
- Node.js 20+
- npm
### Build
```sh
npm install
npm run build
# Output: dist/main.js dist/manifest.json dist/styles.css
```
### Unit tests
```sh
npm test
```
Runs the Vitest suite (frontmatter parsing, slug derivation, image path logic, git helpers, publish pipeline).
### E2E tests
The E2E suite boots the real Obsidian binary in a headless X display and drives it via the Chrome DevTools Protocol.
**Prerequisites:**
- `xvfb-run` on PATH (install: `sudo apt install xvfb`)
- Obsidian AppImage placed at `~/.cache/obsidian-e2e/Obsidian.AppImage`
(download from <https://obsidian.md/download>)
```sh
npm run e2e
```
The script:
1. Extracts the AppImage (once, cached).
2. Provisions a throwaway vault at `.obsidian-e2e/` with the plugin installed.
3. Launches Obsidian with `--remote-debugging-port` and connects Playwright over CDP.
4. Runs the smoke spec: plugin loads, command `jekyll-publish:publish-current-note` is registered, opening a note and executing the command shows a modal with heading "Publish to Jekyll".
The `.obsidian-e2e/` directory and Playwright `test-results/` are excluded from git.

103
e2e/harness.ts Normal file
View File

@@ -0,0 +1,103 @@
import { chromium, type Browser, type Page } from "@playwright/test";
import { spawn, type ChildProcess } from "node:child_process";
const OBSIDIAN_BIN = process.env.OBSIDIAN_BIN;
const PORT = Number(process.env.OBSIDIAN_CDP_PORT ?? 9222);
export interface ObsidianHandle {
proc: ChildProcess;
browser: Browser;
page: Page;
close: () => Promise<void>;
}
async function waitForCDP(): Promise<void> {
for (let i = 0; i < 60; i++) {
try {
const r = await fetch(`http://127.0.0.1:${PORT}/json/version`);
if (r.ok) return;
} catch {
// not up yet
}
await new Promise((res) => setTimeout(res, 1000));
}
throw new Error("Obsidian CDP endpoint never came up");
}
/** Find the renderer page that owns the Obsidian `app` and has finished layout. */
async function findReadyWindow(browser: Browser): Promise<Page> {
for (let i = 0; i < 60; i++) {
for (const ctx of browser.contexts()) {
for (const p of ctx.pages()) {
try {
const ready = await p.evaluate(
() => (window as any).app?.workspace?.layoutReady === true
);
if (ready) return p;
} catch {
// page navigating
}
}
}
await new Promise((res) => setTimeout(res, 1000));
}
throw new Error("No Obsidian window became ready");
}
/**
* Close any open Obsidian modal (first-run/update/confirmation dialogs the test
* environment may pop up) so they don't intercept pointer events.
*/
async function dismissModals(page: Page): Promise<void> {
await page.evaluate(() => {
document.querySelectorAll(".modal-container").forEach((m) => {
(m.querySelector<HTMLElement>(".modal-close-button"))?.click();
m.remove();
});
document.querySelectorAll(".modal-bg").forEach((b) => b.remove());
});
}
/** Boot the real Obsidian binary, connect over CDP. Returns a handle with .page and .close(). */
export async function launchObsidian(): Promise<ObsidianHandle> {
if (!OBSIDIAN_BIN) {
throw new Error("OBSIDIAN_BIN env var is required (set by scripts/e2e.sh)");
}
const proc = spawn(
OBSIDIAN_BIN,
[`--remote-debugging-port=${PORT}`, "--no-sandbox", "--disable-gpu"],
{ env: process.env, stdio: "ignore" }
);
await waitForCDP();
const browser = await chromium.connectOverCDP(`http://127.0.0.1:${PORT}`);
const page = await findReadyWindow(browser);
const close = async () => {
await browser.close();
proc.kill("SIGKILL");
};
return { proc, browser, page, close };
}
/**
* Dismiss modals, enable the plugin, and wait for it to be loaded.
* Call this after launchObsidian() and before assertions.
*/
export async function withPlugin(obs: ObsidianHandle, pluginId: string): Promise<void> {
await dismissModals(obs.page);
// A fresh vault boots in Restricted Mode, which blocks community plugins.
// Disable it and load our plugin via Obsidian's own API, then wait for it.
await obs.page.evaluate(async (id) => {
const plugins = (window as any).app.plugins;
if (plugins.setEnable) await plugins.setEnable(true);
await plugins.enablePlugin(id);
}, pluginId);
await obs.page.waitForFunction(
(id) => !!(window as any).app?.plugins?.plugins?.[id],
pluginId,
{ timeout: 30_000 }
);
}

27
e2e/publish.spec.ts Normal file
View File

@@ -0,0 +1,27 @@
import { test, expect } from "@playwright/test";
import { launchObsidian, withPlugin } from "./harness";
test("plugin loads, command registered, modal opens & prefills", async () => {
const obs = await launchObsidian();
try {
await withPlugin(obs, "jekyll-publish");
const hasCommand = await obs.page.evaluate(() =>
Boolean((window as any).app.commands.commands["jekyll-publish:publish-current-note"])
);
expect(hasCommand).toBe(true);
const modalOpened = await obs.page.evaluate(async () => {
const app = (window as any).app;
const file = app.vault.getFiles().find((f: any) => f.extension === "md");
await app.workspace.getLeaf(true).openFile(file);
app.commands.executeCommandById("jekyll-publish:publish-current-note");
await new Promise((r) => setTimeout(r, 500));
const heading = document.querySelector(".modal-container h2");
return heading?.textContent ?? "";
});
expect(modalOpened).toContain("Publish to Jekyll");
} finally {
await obs.close();
}
});

11
playwright.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
fullyParallel: false,
workers: 1,
retries: 0,
timeout: 120_000,
expect: { timeout: 30_000 },
reporter: [["list"]],
});

63
scripts/e2e.sh Executable file
View File

@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# End-to-end test driver: builds the plugin, provisions an isolated Obsidian
# vault + config with the plugin installed and enabled, then runs the Playwright
# spec against the real Obsidian binary under a virtual X display.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
CACHE="${OBSIDIAN_CACHE:-$HOME/.cache/obsidian-e2e}"
APPIMAGE="$CACHE/Obsidian.AppImage"
SQUASHFS="$CACHE/squashfs-root"
E2E_DIR="$ROOT/.obsidian-e2e"
VAULT="$E2E_DIR/vault"
CONFIG_HOME="$E2E_DIR/config"
if [[ ! -f "$APPIMAGE" ]]; then
echo "Obsidian AppImage not found at $APPIMAGE." >&2
echo "Download it from https://obsidian.md/download and place it there." >&2
exit 1
fi
# Extract the AppImage once so we can launch the inner Electron binary directly.
if [[ ! -x "$SQUASHFS/obsidian" ]]; then
echo "==> Extracting Obsidian AppImage"
( cd "$CACHE" && "$APPIMAGE" --appimage-extract >/dev/null )
fi
export OBSIDIAN_BIN="$SQUASHFS/obsidian"
echo "==> Building plugin"
npm run build >/dev/null
echo "==> Provisioning isolated vault + config at $E2E_DIR"
rm -rf "$E2E_DIR"
PLUGIN_DIR="$VAULT/.obsidian/plugins/jekyll-publish"
mkdir -p "$PLUGIN_DIR" "$CONFIG_HOME/obsidian"
cp dist/main.js dist/manifest.json dist/styles.css "$PLUGIN_DIR/"
# Enable our community plugin (and disable Obsidian's first-run restricted mode prompt).
printf '["jekyll-publish"]\n' > "$VAULT/.obsidian/community-plugins.json"
# Create a test note so the publish command has an active file.
cat > "$VAULT/Test.md" <<'MD'
---
title: E2E Test Post
date: 2024-01-01
---
# E2E Test Post
This is a test note for the E2E smoke test.
MD
# Register the vault and mark it open so Obsidian boots straight into it.
VAULT_ESCAPED=$(printf '%s' "$VAULT" | sed 's/[\/&]/\\&/g')
cat > "$CONFIG_HOME/obsidian/obsidian.json" <<JSON
{"vaults":{"e2e0000000000000":{"path":"$VAULT_ESCAPED","ts":1700000000000,"open":true}}}
JSON
export XDG_CONFIG_HOME="$CONFIG_HOME"
echo "==> Running Playwright (xvfb + real Obsidian)"
xvfb-run -a --server-args="-screen 0 1280x900x24" npx playwright test "$@"