adding tests
Some checks failed
Build Org Website / build (push) Failing after 50s

This commit is contained in:
2026-05-07 15:24:00 +01:00
parent 3c1f76e65c
commit 4f0b042f09
7 changed files with 649 additions and 38 deletions

View File

@@ -1,4 +1,4 @@
.PHONY: all clean norm author author-stop author-restart help
.PHONY: all clean norm author author-stop author-restart author-status help
VENV := .venv
PY := $(VENV)/bin/python
@@ -6,8 +6,10 @@ PIP := $(VENV)/bin/pip
PERM_DIR := /home/zaine/master-folder/projects/scripts/bash-scripts
AUTHOR_PORT := 8765
AUTHOR_URL := http://127.0.0.1:$(AUTHOR_PORT)
AUTHOR_PID := /home/zaine/logs/authoring-server.pid
AUTHOR_LOG := /home/zaine/logs/authoring-server.log
AUTHOR_PAGES_CHECK := /tmp/authoring-server-pages.json
all: set-perms clean-output build search clean-venv author-restart
@@ -40,9 +42,9 @@ norm:
@echo "sorting out the backups..."
find . -path ./backups -prune -o -type f -name '*~' -exec mv {} backups/ \;
author:
author: $(VENV)
@echo "Starting local authoring UI in foreground..."
python3 authoring_server.py
$(PY) authoring_server.py
author-stop:
@echo "Stopping existing authoring UI if running..."
@@ -50,22 +52,37 @@ author-stop:
@if [ -f "$(AUTHOR_PID)" ]; then \
PID=$$(cat "$(AUTHOR_PID)"); \
if kill -0 $$PID 2>/dev/null; then \
echo "Killing PID $$PID"; \
kill $$PID || true; \
sleep 1; \
echo "Stopping PID $$PID"; \
kill $$PID 2>/dev/null || true; \
for i in 1 2 3 4 5; do \
if ! kill -0 $$PID 2>/dev/null; then break; fi; \
sleep 1; \
done; \
if kill -0 $$PID 2>/dev/null; then \
echo "PID $$PID did not stop cleanly; killing it."; \
kill -9 $$PID 2>/dev/null || true; \
fi; \
fi; \
rm -f "$(AUTHOR_PID)"; \
fi
@fuser -k $(AUTHOR_PORT)/tcp 2>/dev/null || true
@fuser -k $(AUTHOR_PORT)/tcp >/dev/null 2>&1 || true
author-restart: author-stop
author-restart: $(VENV) author-stop
@echo "Starting authoring UI on port $(AUTHOR_PORT)..."
@mkdir -p /home/zaine/logs
@nohup python3 authoring_server.py > "$(AUTHOR_LOG)" 2>&1 & echo $$! > "$(AUTHOR_PID)"
@echo "Waiting for authoring UI to respond..."
@start-stop-daemon --start --background --make-pidfile --pidfile "$(AUTHOR_PID)" \
--chdir "$(CURDIR)" --startas "$(CURDIR)/$(PY)" -- \
authoring_server.py > "$(AUTHOR_LOG)" 2>&1
@echo "Waiting for authoring UI and page list to respond..."
@for i in 1 2 3 4 5 6 7 8 9 10; do \
if curl -fsS http://127.0.0.1:$(AUTHOR_PORT)/ >/dev/null; then \
echo "Authoring UI is running on http://127.0.0.1:$(AUTHOR_PORT)/"; \
PID=$$(cat "$(AUTHOR_PID)"); \
if ! kill -0 $$PID 2>/dev/null; then \
echo "Authoring UI process exited before becoming healthy. Last logs:"; \
tail -n 40 "$(AUTHOR_LOG)" || true; \
exit 1; \
fi; \
if curl -fsS "$(AUTHOR_URL)/api/pages" -o "$(AUTHOR_PAGES_CHECK)" && $(PY) -c 'import json,sys; data=json.load(sys.stdin); sys.exit(0 if isinstance(data, list) and data else 1)' < "$(AUTHOR_PAGES_CHECK)"; then \
echo "Authoring UI is running on $(AUTHOR_URL)/"; \
exit 0; \
fi; \
sleep 1; \
@@ -74,6 +91,14 @@ author-restart: author-stop
tail -n 40 "$(AUTHOR_LOG)" || true; \
exit 1
author-status:
@if [ -f "$(AUTHOR_PID)" ] && kill -0 $$(cat "$(AUTHOR_PID)") 2>/dev/null; then \
echo "PID: $$(cat "$(AUTHOR_PID)")"; \
else \
echo "No live authoring UI PID recorded."; \
fi
@curl -fsS "$(AUTHOR_URL)/api/pages" -o "$(AUTHOR_PAGES_CHECK)" && $(PY) -c 'import json,sys; print(f"Pages: {len(json.load(sys.stdin))}")' < "$(AUTHOR_PAGES_CHECK)" || true
help:
@echo "Available targets:"
@echo " make - Full rebuild and restart authoring UI"
@@ -85,5 +110,6 @@ help:
@echo " make author - Start the local authoring UI in foreground"
@echo " make author-stop - Stop the authoring UI"
@echo " make author-restart - Restart the authoring UI in background"
@echo " make author-status - Show authoring UI PID and page count"
@echo " make search - Create the search index"
@echo " make help - Show this help message"

View File

@@ -411,9 +411,9 @@ def render_markdown(data: dict[str, Any]) -> str:
flags=re.IGNORECASE,
)
if content:
if re.search(r"^#{1,6}\s+.+?\s*$", content, flags=re.MULTILINE):
if re.search(r"^#{1,6}[^\S\r\n]+.+?[^\S\r\n]*$", content, flags=re.MULTILINE):
content = re.sub(
r"^#{1,6}\s+.+?\s*$",
r"^#{1,6}[^\S\r\n]+.+?[^\S\r\n]*$",
f"# {title}",
content,
count=1,
@@ -629,9 +629,20 @@ APP_HTML = r"""<!doctype html>
.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.toolbar { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; border: 1px solid var(--line); background: var(--panel); border-radius: 6px; padding: 7px; }
.toolbar input[type="file"] { display: none; }
.link-picker { display: grid; grid-template-columns: minmax(220px, 1fr) auto auto; gap: 8px; align-items: center; border: 1px solid var(--line); background: var(--panel); border-radius: 6px; padding: 8px; }
label { display: grid; gap: 5px; font-size: 13px; font-weight: 700; color: var(--muted); }
input, textarea, select { width: 100%; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); color: var(--ink); padding: 9px 10px; }
textarea { min-height: 48vh; resize: vertical; font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; line-height: 1.45; font-size: 14px; }
.content-layout { display: grid; gap: 12px; }
.content-layout.previewing { grid-template-columns: minmax(0, 1fr) minmax(280px, 1fr); align-items: start; }
.preview-panel { border: 1px solid var(--line); border-radius: 6px; background: var(--panel); padding: 12px 14px; min-height: 48vh; overflow: auto; overflow-wrap: anywhere; }
.preview-panel h1, .preview-panel h2, .preview-panel h3 { color: var(--ink); margin: 0.8em 0 0.35em; }
.preview-panel h1 { font-size: 24px; }
.preview-panel h2 { font-size: 20px; }
.preview-panel h3 { font-size: 17px; }
.preview-panel p, .preview-panel ul, .preview-panel ol, .preview-panel blockquote { margin: 0 0 0.8em; }
.preview-panel blockquote { border-left: 3px solid var(--line); padding-left: 10px; color: var(--muted); }
.preview-panel img, .preview-panel video { max-width: 100%; height: auto; }
.actions { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.hint { color: var(--muted); font-size: 13px; }
.full { grid-column: 1 / -1; }
@@ -642,7 +653,8 @@ APP_HTML = r"""<!doctype html>
.queue-item strong { display: block; overflow-wrap: anywhere; }
.queue-item span { color: var(--muted); overflow-wrap: anywhere; }
pre { white-space: pre-wrap; overflow: auto; max-height: 280px; background: #201f1d; color: #f7f1e4; padding: 12px; border-radius: 6px; font-size: 12px; }
@media (max-width: 860px) { .app { grid-template-columns: 1fr; } aside, main { max-height: none; } aside { border-right: 0; border-bottom: 1px solid var(--line); } .grid { grid-template-columns: 1fr; } }
@media (max-width: 980px) { .content-layout.previewing { grid-template-columns: 1fr; } }
@media (max-width: 860px) { .app { grid-template-columns: 1fr; } aside, main { max-height: none; } aside { border-right: 0; border-bottom: 1px solid var(--line); } .grid { grid-template-columns: 1fr; } .link-picker { grid-template-columns: 1fr; } }
</style>
</head>
<body>
@@ -698,7 +710,7 @@ APP_HTML = r"""<!doctype html>
<input name="targetPath" placeholder="blogs/2026/05-may/my-page.org" />
</label>
</div>
<div id="mdToolbar" class="toolbar" hidden>
<div id="mdToolbar" class="toolbar">
<button class="icon" type="button" data-md="bold" title="Bold">B</button>
<button class="icon" type="button" data-md="italic" title="Italic"><i>I</i></button>
<button class="icon" type="button" data-md="underline" title="Underline"><u>U</u></button>
@@ -709,12 +721,23 @@ APP_HTML = r"""<!doctype html>
<button type="button" data-md="numbered">1. List</button>
<button type="button" data-md="quote">Quote</button>
<button type="button" data-md="link">Link</button>
<button type="button" id="pageLinkBtn">Link to page</button>
<button type="button" id="attachBtn">Insert attachment</button>
<input id="attachInput" type="file" accept="image/*,video/*" />
</div>
<label>Content
<textarea name="content" spellcheck="true"></textarea>
</label>
<div id="pageLinkPicker" class="link-picker" hidden>
<select id="pageLinkSelect"></select>
<button type="button" id="insertPageLinkBtn">Insert link</button>
<button type="button" id="cancelPageLinkBtn">Cancel</button>
</div>
<div id="contentLayout" class="content-layout">
<label>Content
<textarea name="content" spellcheck="true"></textarea>
</label>
<label id="previewWrap" hidden>Preview
<div id="markdownPreview" class="preview-panel"></div>
</label>
</div>
<label><span><input name="comments" type="checkbox" checked style="width:auto" /> Comments enabled</span></label>
<div class="actions">
<button class="primary" id="saveBtn" type="submit">Save and build</button>
@@ -734,6 +757,12 @@ APP_HTML = r"""<!doctype html>
const editor = $("#editor");
const mdToolbar = $("#mdToolbar");
const attachInput = $("#attachInput");
const attachBtn = $("#attachBtn");
const pageLinkPicker = $("#pageLinkPicker");
const pageLinkSelect = $("#pageLinkSelect");
const contentLayout = $("#contentLayout");
const previewWrap = $("#previewWrap");
const markdownPreview = $("#markdownPreview");
const statusBox = $("#status");
const pagesBox = $("#pages");
const buildLog = $("#buildLog");
@@ -809,6 +838,79 @@ APP_HTML = r"""<!doctype html>
return String(value).replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[char]));
}
function renderInlineMarkdown(value) {
let out = html(value);
out = out.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1">');
out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
out = out.replace(/\*([^*]+)\*/g, '<em>$1</em>');
out = out.replace(/&lt;u&gt;(.+?)&lt;\/u&gt;/g, '<u>$1</u>');
return out;
}
function markdownToHtml(markdown) {
const lines = String(markdown || "").replace(/\r\n/g, "\n").split("\n");
const blocks = [];
let paragraph = [];
let list = null;
function flushParagraph() {
if (!paragraph.length) return;
blocks.push(`<p>${renderInlineMarkdown(paragraph.join(" "))}</p>`);
paragraph = [];
}
function closeList() {
if (!list) return;
blocks.push(`<${list.type}>${list.items.map((item) => `<li>${renderInlineMarkdown(item)}</li>`).join("")}</${list.type}>`);
list = null;
}
lines.forEach((line) => {
const trimmed = line.trim();
if (!trimmed) {
flushParagraph();
closeList();
return;
}
const heading = trimmed.match(/^(#{1,3})\s+(.+)$/);
if (heading) {
flushParagraph();
closeList();
blocks.push(`<h${heading[1].length}>${renderInlineMarkdown(heading[2])}</h${heading[1].length}>`);
return;
}
const bullet = trimmed.match(/^[-*]\s+(.+)$/);
const numbered = trimmed.match(/^\d+\.\s+(.+)$/);
if (bullet || numbered) {
flushParagraph();
const type = bullet ? "ul" : "ol";
if (!list || list.type !== type) {
closeList();
list = { type, items: [] };
}
list.items.push((bullet || numbered)[1]);
return;
}
const quote = trimmed.match(/^>\s?(.+)$/);
if (quote) {
flushParagraph();
closeList();
blocks.push(`<blockquote>${renderInlineMarkdown(quote[1])}</blockquote>`);
return;
}
closeList();
paragraph.push(trimmed);
});
flushParagraph();
closeList();
return blocks.join("\n");
}
function updateMarkdownPreview() {
markdownPreview.innerHTML = markdownToHtml(editor.content.value);
}
function setForm(page) {
state.currentPath = page.path || "";
state.pathManual = Boolean(page.path);
@@ -826,6 +928,7 @@ APP_HTML = r"""<!doctype html>
editor.targetPath.disabled = Boolean(state.currentPath);
updateEditorMode();
updateSuggestedPath();
updateMarkdownPreview();
saveMessage.textContent = "";
}
@@ -866,8 +969,12 @@ APP_HTML = r"""<!doctype html>
mdToolbar.querySelectorAll("[data-md]").forEach((button) => {
button.addEventListener("click", () => applyMarkdown(button.dataset.md));
});
$("#attachBtn").addEventListener("click", () => attachInput.click());
attachBtn.addEventListener("click", () => attachInput.click());
$("#pageLinkBtn").addEventListener("click", showPageLinkPicker);
$("#insertPageLinkBtn").addEventListener("click", insertSelectedPageLink);
$("#cancelPageLinkBtn").addEventListener("click", () => { pageLinkPicker.hidden = true; });
attachInput.addEventListener("change", uploadAttachment);
editor.content.addEventListener("input", updateMarkdownPreview);
$("#search").addEventListener("input", renderPages);
$("#typeFilter").addEventListener("change", renderPages);
$("#newBtn").addEventListener("click", () => setForm({ pageType: "blog", date: currentOrgDate(), comments: true }));
@@ -909,14 +1016,22 @@ APP_HTML = r"""<!doctype html>
function updateEditorMode() {
const isLima = editor.pageType.value === "lima";
mdToolbar.hidden = !isLima;
const isOrg = !isLima;
mdToolbar.querySelectorAll("[data-md='link']").forEach((button) => {
button.textContent = isLima ? "Link" : "Org link";
});
editor.tags.closest("label").hidden = isLima;
editor.date.closest("label").hidden = isLima;
editor.comments.closest("label").hidden = isLima;
editor.section.closest("label").hidden = editor.pageType.value !== "post";
attachBtn.hidden = isOrg;
previewWrap.hidden = !isLima;
contentLayout.classList.toggle("previewing", isLima);
pageLinkPicker.hidden = true;
const pathLabel = editor.targetPath.closest("label").firstChild;
if (pathLabel) pathLabel.textContent = isLima ? "Markdown file path" : "Org file path";
editor.targetPath.placeholder = isLima ? "lima/family-update.md" : "blogs/2026/05-may/my-page.org";
updateMarkdownPreview();
}
function selectedText() {
@@ -936,35 +1051,142 @@ APP_HTML = r"""<!doctype html>
if (selectStart !== null && selectEnd !== null) {
area.setSelectionRange(start + selectStart, start + selectEnd);
}
updateMarkdownPreview();
}
function linePrefix(prefix, fallback) {
function toggleLinePrefix(prefix, fallback, ordered = false) {
const area = editor.content;
let { start, end, text } = selectedText();
if (start === end) {
start = area.value.lastIndexOf("\n", start - 1) + 1;
const lineEnd = area.value.indexOf("\n", end);
end = lineEnd === -1 ? area.value.length : lineEnd;
text = area.value.slice(start, end);
}
const value = text || fallback;
const lines = value.split("\n");
const hasPrefix = lines.every((line) => ordered ? /^\d+\.\s/.test(line) : line.startsWith(prefix));
const replacement = lines.map((line, index) => {
if (hasPrefix) return ordered ? line.replace(/^\d+\.\s/, "") : line.slice(prefix.length);
return ordered ? `${index + 1}. ${line || fallback}` : `${prefix}${line || fallback}`;
}).join("\n");
area.setRangeText(replacement, start, end, "end");
area.setSelectionRange(start, start + replacement.length);
area.focus();
updateMarkdownPreview();
}
function toggleWrap(prefix, suffix, fallback) {
const area = editor.content;
const { start, end, text } = selectedText();
const value = text || fallback;
const replacement = value.split("\n").map((line) => `${prefix}${line || fallback}`).join("\n");
area.setRangeText(replacement, start, end, "end");
const before = area.value.slice(start - prefix.length, start);
const after = area.value.slice(end, end + suffix.length);
if (text && text.startsWith(prefix) && text.endsWith(suffix)) {
const inner = text.slice(prefix.length, text.length - suffix.length);
area.setRangeText(inner, start, end, "select");
area.setSelectionRange(start, start + inner.length);
} else if (text && before === prefix && after === suffix) {
area.setSelectionRange(start - prefix.length, end + suffix.length);
area.setRangeText(text, start - prefix.length, end + suffix.length, "select");
area.setSelectionRange(start - prefix.length, start - prefix.length + text.length);
} else {
const value = text || fallback;
area.setRangeText(`${prefix}${value}${suffix}`, start, end, "select");
area.setSelectionRange(start + prefix.length, start + prefix.length + value.length);
}
area.focus();
updateMarkdownPreview();
}
function applyMarkdown(action) {
const { text } = selectedText();
const sample = text || "text";
if (action === "bold") replaceSelection(`**${sample}**`, 2, 2 + sample.length);
if (action === "italic") replaceSelection(`*${sample}*`, 1, 1 + sample.length);
if (action === "underline") replaceSelection(`<u>${sample}</u>`, 3, 3 + sample.length);
if (action === "h1") linePrefix("# ", "Heading");
if (action === "h2") linePrefix("## ", "Heading");
if (action === "h3") linePrefix("### ", "Heading");
if (action === "bullet") linePrefix("- ", "List item");
if (action === "numbered") linePrefix("1. ", "List item");
if (action === "quote") linePrefix("> ", "Quote");
const isLima = editor.pageType.value === "lima";
if (action === "bold") toggleWrap(isLima ? "**" : "*", isLima ? "**" : "*", sample);
if (action === "italic") toggleWrap(isLima ? "*" : "/", isLima ? "*" : "/", sample);
if (action === "underline") toggleWrap(isLima ? "<u>" : "_", isLima ? "</u>" : "_", sample);
if (action === "h1") toggleLinePrefix(isLima ? "# " : "* ", "Heading");
if (action === "h2") toggleLinePrefix(isLima ? "## " : "** ", "Heading");
if (action === "h3") toggleLinePrefix(isLima ? "### " : "*** ", "Heading");
if (action === "bullet") toggleLinePrefix(isLima ? "- " : "- ", "List item");
if (action === "numbered") toggleLinePrefix("", "List item", true);
if (action === "quote") {
if (isLima) {
toggleLinePrefix("> ", "Quote");
} else {
const body = text || "Quote";
if (body.startsWith("#+begin_quote") && body.trimEnd().endsWith("#+end_quote")) {
replaceSelection(body.replace(/^#\+begin_quote\s*\n?/i, "").replace(/\n?#\+end_quote\s*$/i, ""));
} else {
replaceSelection(`#+begin_quote\n${body}\n#+end_quote`, 14, 14 + body.length);
}
}
}
if (action === "link") {
const label = sample === "text" ? "link text" : sample;
replaceSelection(`[${label}](https://)`, 1, 1 + label.length);
if (isLima) {
replaceSelection(`[${label}](https://)`, 1, 1 + label.length);
} else {
replaceSelection(`[[https://][${label}]]`, 11, 11);
}
}
}
function pageUrl(page) {
const path = page.path.replace(/\.(org|md)$/i, ".html");
return `https://zainezq.com/${path}`;
}
function currentEditorPath() {
return state.currentPath || editor.targetPath.value || "";
}
function dirname(path) {
const index = path.lastIndexOf("/");
return index === -1 ? "" : path.slice(0, index);
}
function relativePath(fromDir, toPath) {
const fromParts = fromDir ? fromDir.split("/").filter(Boolean) : [];
const toParts = toPath.split("/").filter(Boolean);
while (fromParts.length && toParts.length && fromParts[0] === toParts[0]) {
fromParts.shift();
toParts.shift();
}
return [...fromParts.map(() => ".."), ...toParts].join("/") || toPath;
}
function orgFileLink(page) {
const current = currentEditorPath();
const target = current ? relativePath(dirname(current), page.path) : page.path;
return `[[file:${target}][${page.title}]]`;
}
function markdownPageLink(page, label) {
return `[${label || page.title}](${pageUrl(page)})`;
}
function showPageLinkPicker() {
pageLinkSelect.innerHTML = "";
state.pages.forEach((page) => {
const option = document.createElement("option");
option.value = page.path;
option.textContent = `${page.title} - ${page.path}`;
pageLinkSelect.appendChild(option);
});
pageLinkPicker.hidden = false;
pageLinkSelect.focus();
}
function insertSelectedPageLink() {
const page = state.pages.find((item) => item.path === pageLinkSelect.value);
if (!page) return;
const { text } = selectedText();
const isLima = editor.pageType.value === "lima";
replaceSelection(isLima ? markdownPageLink(page, text || page.title) : orgFileLink(page));
pageLinkPicker.hidden = true;
}
async function uploadAttachment() {
const file = attachInput.files[0];
if (!file) return;

View File

@@ -58,6 +58,10 @@ $Config = @{
MaxDiscordEmbeds = 10
RetryCount = 3
RetryDelaySeconds = 2
AuthoringTestsEnabled = $true
AuthoringTestsPattern = 'test_authoring_server.py'
AuthoringTestsDirectory = Join-Path $PSScriptRoot 'tests'
AuthoringTestTimeoutSec = 120
# Tune these patterns to your build tooling.
ErrorPatterns = @(
@@ -613,6 +617,148 @@ function Send-DiscordNotification {
}
}
function Get-PythonCommand {
$linuxVenvPython = Join-Path $PSScriptRoot '.venv/bin/python'
$windowsVenvPython = Join-Path $PSScriptRoot '.venv/Scripts/python.exe'
if (Test-Path -Path $linuxVenvPython) {
return $linuxVenvPython
}
if (Test-Path -Path $windowsVenvPython) {
return $windowsVenvPython
}
return 'python3'
}
function Invoke-AuthoringServerTests {
if (-not $Config.AuthoringTestsEnabled) {
return $null
}
if (-not (Test-Path -Path $Config.AuthoringTestsDirectory)) {
throw "Authoring test directory does not exist: $($Config.AuthoringTestsDirectory)"
}
$python = Get-PythonCommand
$arguments = @(
'-m',
'unittest',
'discover',
'-s',
$Config.AuthoringTestsDirectory,
'-p',
$Config.AuthoringTestsPattern,
'-v'
)
Write-Log -Message "Running authoring server tests with $python."
$process = New-Object System.Diagnostics.Process
$process.StartInfo.FileName = $python
foreach ($argument in $arguments) {
$process.StartInfo.ArgumentList.Add([string]$argument)
}
$process.StartInfo.WorkingDirectory = $PSScriptRoot
$process.StartInfo.UseShellExecute = $false
$process.StartInfo.RedirectStandardOutput = $true
$process.StartInfo.RedirectStandardError = $true
[void]$process.Start()
$stdoutTask = $process.StandardOutput.ReadToEndAsync()
$stderrTask = $process.StandardError.ReadToEndAsync()
$completed = $process.WaitForExit([int]$Config.AuthoringTestTimeoutSec * 1000)
if (-not $completed) {
$process.Kill()
$process.WaitForExit()
}
else {
$process.WaitForExit()
}
$stdout = $stdoutTask.Result
$stderr = $stderrTask.Result
$output = (($stdout, $stderr) -join "`n").Trim()
$exitCode = if ($completed) { $process.ExitCode } else { 124 }
$ran = 0
$failures = 0
$errors = 0
$skipped = 0
if ($output -match 'Ran\s+(\d+)\s+tests?') {
$ran = [int]$Matches[1]
}
if ($output -match 'failures=(\d+)') {
$failures = [int]$Matches[1]
}
if ($output -match 'errors=(\d+)') {
$errors = [int]$Matches[1]
}
if ($output -match 'skipped=(\d+)') {
$skipped = [int]$Matches[1]
}
$status = if ($completed -and $exitCode -eq 0) { 'passed' } elseif (-not $completed) { 'timed out' } else { 'failed' }
$tailLines = @($output -split "`r?`n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Last 12)
$summary = if ($tailLines.Count -gt 0) { $tailLines -join "`n" } else { 'No test output captured.' }
if ($summary.Length -gt 950) {
$summary = $summary.Substring(0, 947) + '...'
}
$result = [pscustomobject]@{
Name = 'authoring_server.py tests'
Status = $status
Success = ($completed -and $exitCode -eq 0)
ExitCode = $exitCode
Ran = $ran
Failures = $failures
Errors = $errors
Skipped = $skipped
Summary = $summary
Completed = $completed
Timestamp = (Get-Date).ToUniversalTime().ToString('o')
}
Write-Log -Message ("Authoring server tests {0}: ran={1}, failures={2}, errors={3}, skipped={4}, exit={5}" -f $result.Status, $result.Ran, $result.Failures, $result.Errors, $result.Skipped, $result.ExitCode)
return $result
}
function Send-AuthoringTestNotification {
param([Parameter(Mandatory)][object]$Result)
$emoji = if ($Result.Success) { '✅' } else { '❌' }
$color = if ($Result.Success) { 3066993 } else { 15158332 }
$payload = @{
username = 'Gitea Build Monitor'
content = "$emoji Authoring server test result: $($Result.Status)."
embeds = @(
@{
title = "$emoji $($Result.Name)"
color = $color
description = "Unit test results from gitea-build-monitor.ps1"
fields = @(
@{ name = 'Status'; value = $Result.Status; inline = $true },
@{ name = 'Tests'; value = [string]$Result.Ran; inline = $true },
@{ name = 'Exit code'; value = [string]$Result.ExitCode; inline = $true },
@{ name = 'Failures'; value = [string]$Result.Failures; inline = $true },
@{ name = 'Errors'; value = [string]$Result.Errors; inline = $true },
@{ name = 'Skipped'; value = [string]$Result.Skipped; inline = $true },
@{ name = 'Summary'; value = $Result.Summary; inline = $false }
)
timestamp = $Result.Timestamp
}
)
}
Invoke-DiscordWebhook -Payload $payload
if ($DryRun) {
Write-Log -Message 'Prepared authoring server test notification in dry-run mode.'
}
else {
Write-Log -Message 'Sent authoring server test notification.'
}
}
function Test-IsRecentBuild {
param([Parameter(Mandatory)][object]$Job)
@@ -657,6 +803,11 @@ function Start-BuildMonitor {
return
}
$authoringTestResult = Invoke-AuthoringServerTests
if ($null -ne $authoringTestResult) {
Send-AuthoringTestNotification -Result $authoringTestResult
}
$cache = if ($DryRun) { @{} } else { Read-ProcessedCache }
$analyses = New-Object System.Collections.Generic.List[object]
@@ -705,6 +856,10 @@ function Start-BuildMonitor {
if (-not $DryRun) {
Save-ProcessedCache -Cache $cache
}
if ($null -ne $authoringTestResult -and -not $authoringTestResult.Success) {
throw 'Authoring server tests failed.'
}
}
try {

View File

@@ -1,4 +1,4 @@
# Weeee
# Main Index
I made quite a few changes. It all started when I realised that not everyone knows how to use `.org` files and editing with them (they are quite niche). So I decided to make a very unique solution by having markdown files, which through a process of very complicated transformations result into the very page youre looking at now :)
- You can edit files using this link in [nextcloud](https://nextcloud.zainezq.com/apps/files/files/18016?dir=/lima-website).

View File

@@ -4,7 +4,7 @@
See the categories: @@html:<a href="../home/categories.html">Categories</a>@@
* Posts:
- [[file:career/career-list.org][Career List]] @@html:<span class="post-date">07-05-2026 14:34</span>@@
- [[file:career/career-list.org][Career List]] @@html:<span class="post-date">07-05-2026 15:23</span>@@
- [[file:posts-list.sync-conflict-20260417-233432-VT6366A.org][Posts List]] @@html:<span class="post-date">14-04-2026 16:36</span>@@
- [[file:career/ha-dr.org][High Availability, Disaster Recovery and Business Continuity]] @@html:<span class="post-date">11-03-2026 17:18</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@
- [[file:career/javascript.org][Understands the Javascript language]] @@html:<span class="post-date">11-03-2026 16:52</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@

View File

@@ -16,8 +16,8 @@
- [[file:tags/life.org][Tag: life]]
- [[file:tags/education.org][Tag: education]]
- [[file:tags/insights.org][Tag: insights]]
- [[file:tags/reading.org][Tag: reading]]
- [[file:tags/emacs.org][Tag: emacs]]
- [[file:tags/reading.org][Tag: reading]]
- [[file:tags/maths.org][Tag: maths]]
- home
- [[file:home/countdown.org][Countdown]]

View File

@@ -0,0 +1,208 @@
import tempfile
import unittest
from datetime import datetime
from pathlib import Path
from unittest import mock
import authoring_server as server
class AuthoringServerTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.root = Path(self.tmp.name)
self.blogs = self.root / "blogs"
self.posts = self.root / "posts"
self.lima = self.root / "lima"
self.hzone = self.root / "assets" / "images" / "hzone"
self.blogs.mkdir()
self.posts.mkdir()
self.lima.mkdir()
patches = {
"ROOT": self.root,
"BLOGS_DIR": self.blogs,
"POSTS_DIR": self.posts,
"LIMA_DIR": self.lima,
"HZONE_ASSETS_DIR": self.hzone,
}
self.patchers = [mock.patch.object(server, name, value) for name, value in patches.items()]
for patcher in self.patchers:
patcher.start()
def tearDown(self):
for patcher in reversed(self.patchers):
patcher.stop()
self.tmp.cleanup()
class UtilityTests(AuthoringServerTestCase):
def test_slugify_normalises_text_and_keeps_fallback(self):
self.assertEqual(server.slugify("Hello, Org Web!"), "hello-org-web")
self.assertEqual(server.slugify(" "), "untitled")
def test_normalise_tags_accepts_strings_and_deduplicates(self):
self.assertEqual(
server.normalise_tags("Life, review:Life Emacs"),
["life", "review", "emacs"],
)
def test_parse_org_datetime_handles_date_and_optional_time(self):
self.assertEqual(
server.parse_org_datetime("<2026-05-07 Thu 14:35>"),
datetime(2026, 5, 7, 14, 35),
)
self.assertEqual(
server.parse_org_datetime("<2026-05-07 Thu>"),
datetime(2026, 5, 7, 12, 0),
)
self.assertIsNone(server.parse_org_datetime("2026-05-07"))
def test_safe_relative_path_allows_expected_content_roots(self):
self.assertEqual(
server.safe_relative_path("blogs/example.org"),
self.blogs / "example.org",
)
self.assertEqual(
server.safe_relative_path("posts/career/example.org"),
self.posts / "career" / "example.org",
)
self.assertEqual(
server.safe_relative_path("lima/index.md"),
self.lima / "index.md",
)
def test_safe_relative_path_rejects_escapes_and_wrong_locations(self):
for path in ("../secret.org", "/tmp/secret.org", "sitemap.org", "lima/index.org"):
with self.subTest(path=path):
with self.assertRaises(ValueError):
server.safe_relative_path(path)
def test_image_dimensions_detects_png_and_gif(self):
png = b"\x89PNG\r\n\x1a\n" + b"\x00" * 8 + (640).to_bytes(4, "big") + (480).to_bytes(4, "big")
gif = b"GIF89a" + (320).to_bytes(2, "little") + (200).to_bytes(2, "little")
self.assertEqual(server.image_dimensions(png, ".png"), (640, 480))
self.assertEqual(server.image_dimensions(gif, ".gif"), (320, 200))
self.assertIsNone(server.image_dimensions(b"not an image", ".png"))
class PageRenderingTests(AuthoringServerTestCase):
def test_render_org_writes_metadata_and_body(self):
rendered = server.render_org(
{
"title": "A New Note",
"slug": "Custom Slug",
"tags": ["Life", "life", "Review"],
"content": "Body text",
"date": "<2026-05-07 Thu 10:30>",
"comments": False,
"wip": "draft",
},
previous=None,
)
self.assertIn("#+TITLE: A New Note", rendered)
self.assertIn("#+DATE: <2026-05-07 Thu 10:30>", rendered)
self.assertIn("#+filetags: :life:review:", rendered)
self.assertIn("#+COMMENTS: ", rendered)
self.assertIn("#+SLUG: custom-slug", rendered)
self.assertIn("#+WIP: draft", rendered)
self.assertTrue(rendered.endswith("Body text\n"))
def test_render_markdown_adds_or_replaces_title_heading(self):
self.assertEqual(
server.render_markdown({"title": "Family Update", "content": "Body"}),
"# Family Update\n\nBody\n",
)
self.assertEqual(
server.render_markdown({"title": "New Title", "content": "## Old\n\nBody"}),
"# New Title\n\nBody\n",
)
def test_save_page_creates_blog_and_round_trips_content(self):
saved = server.save_page(
{
"pageType": "blog",
"title": "Test Post",
"slug": "test-post",
"date": "<2026-05-07 Thu 09:00>",
"tags": "test, blog",
"content": "The body",
"comments": True,
}
)
self.assertEqual(saved["path"], "blogs/2026/05-may/test-post.org")
self.assertEqual(saved["title"], "Test Post")
self.assertEqual(saved["tags"], ["test", "blog"])
self.assertEqual(saved["content"], "The body")
def test_save_page_creates_lima_markdown(self):
saved = server.save_page(
{
"pageType": "lima",
"title": "Lima Entry",
"slug": "lima-entry",
"content": "Some markdown",
}
)
path = self.lima / "lima-entry.md"
self.assertEqual(saved["path"], "lima/lima-entry.md")
self.assertEqual(path.read_text(encoding="utf-8"), "# Lima Entry\n\nSome markdown\n")
def test_list_pages_excludes_generated_and_sync_conflict_files(self):
(self.blogs / "keep.org").write_text("#+TITLE: Keep\n#+DATE: <2026-05-07 Thu 12:00>\n", encoding="utf-8")
(self.posts / "posts-list.org").write_text("#+TITLE: Generated\n", encoding="utf-8")
(self.blogs / "note.sync-conflict-1.org").write_text("#+TITLE: Conflict\n", encoding="utf-8")
(self.lima / "index.md").write_text("# Lima Home\n", encoding="utf-8")
paths = [page["path"] for page in server.list_pages()]
self.assertEqual(set(paths), {"blogs/keep.org", "lima/index.md"})
def test_relative_asset_path_is_calculated_from_lima_page_directory(self):
self.assertEqual(
server.relative_asset_path("lima/family/update.md", "assets/images/hzone/pic.png"),
"../../assets/images/hzone/pic.png",
)
self.assertEqual(
server.relative_asset_path("blogs/example.org", "assets/images/hzone/pic.png"),
"../assets/images/hzone/pic.png",
)
def test_save_upload_rejects_disallowed_extensions(self):
with self.assertRaises(ValueError):
server.save_upload("shell.php", b"<?php")
class BuildQueueTests(unittest.TestCase):
def test_snapshot_reports_recent_completed_job(self):
queue = server.BuildQueue()
job = server.BuildJob(1, "blogs/post.org", "Post")
job.started_at = 1.0
job.finished_at = 2.0
job.ok = True
job.message = "Done"
job.log = "build log"
queue._recent.append(job)
snapshot = queue.snapshot()
self.assertFalse(snapshot["running"])
self.assertEqual(snapshot["message"], "Done")
self.assertEqual(snapshot["recent"][0]["status"], "done")
self.assertEqual(snapshot["log"], "build log")
def test_queue_build_enqueues_from_page_data(self):
queue = server.BuildQueue()
with mock.patch.object(server, "BUILD_QUEUE", queue), mock.patch.object(queue, "_run_worker"):
queued = server.queue_build({"path": "blogs/post.org", "title": "Post"})
self.assertEqual(queued["id"], 1)
self.assertEqual(queued["status"], "queued")
self.assertEqual(queued["path"], "blogs/post.org")
if __name__ == "__main__":
unittest.main()