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