test: e2e smoke; docs: README; ci: release workflow
Co-Authored-By: Claude
This commit is contained in:
108
.gitea/workflows/release.yml
Normal file
108
.gitea/workflows/release.yml
Normal 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
94
README.md
Normal 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
103
e2e/harness.ts
Normal 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
27
e2e/publish.spec.ts
Normal 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
11
playwright.config.ts
Normal 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
63
scripts/e2e.sh
Executable 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 "$@"
|
||||||
Reference in New Issue
Block a user