Merge branch 'dev' into master — import/export content & assets

This commit is contained in:
a2nr 2026-10-01 13:28:52 +07:00
commit 2780e5f4a9
8 changed files with 744 additions and 2 deletions

View File

@ -15,6 +15,7 @@
"@micropython/micropython-webassembly-pyscript": "^1.29.0-6",
"codemirror": "^6.0.1",
"dexie": "^4.4.6",
"fflate": "^0.8.3",
"highlight.js": "^11.11.0",
"isomorphic-dompurify": "^3.23.0",
"katex": "^0.16.9",
@ -2104,6 +2105,12 @@
}
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",

View File

@ -30,6 +30,7 @@
"@micropython/micropython-webassembly-pyscript": "^1.29.0-6",
"codemirror": "^6.0.1",
"dexie": "^4.4.6",
"fflate": "^0.8.3",
"highlight.js": "^11.11.0",
"isomorphic-dompurify": "^3.23.0",
"katex": "^0.16.9",

View File

@ -1,5 +1,26 @@
import { describe, it, expect, vi } from 'vitest';
import { deleteEntry, renameEntry } from './contentEditor';
import { zipSync, strToU8 } from 'fflate';
// FileReader tidak tersedia di environment 'node' vitest; uploadAsset
// menggunakannya via fileToBase64. Polyfill minimal agar test jalan.
class MockFileReader {
result: string | null = null;
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
readAsDataURL(file: Blob) {
file.arrayBuffer().then((buf) => {
const bytes = new Uint8Array(buf);
let bin = '';
bytes.forEach((b) => (bin += String.fromCharCode(b)));
this.result = `data:application/octet-stream;base64,${btoa(bin)}`;
this.onload?.();
});
}
}
if (typeof globalThis.FileReader === 'undefined') {
(globalThis as unknown as { FileReader: unknown }).FileReader = MockFileReader;
}
describe('deleteEntry', () => {
it('mengirim force=true saat dipanggil dengan argumen force', async () => {
@ -90,4 +111,148 @@ describe('saveDraft and publishDraft API', () => {
expect(call[0]).toBe('/api/content/drafts/draft-1/publish');
expect(call[1].method).toBe('POST');
});
});
describe('exportBundle', () => {
it('memanggil /api/content/export dengan credentials dan mengembalikan blob', async () => {
const { exportBundle } = await import('./contentEditor');
const blob = new Blob(['zip-bytes'], { type: 'application/zip' });
global.fetch = vi.fn().mockResolvedValue(
new Response(blob, { status: 200, headers: { 'Content-Type': 'application/zip' } }),
);
const result = await exportBundle();
expect(global.fetch).toHaveBeenCalledWith('/api/content/export', { credentials: 'include' });
expect(result.size).toBeGreaterThan(0);
});
it('mengirim scope param saat diberikan', async () => {
const { exportBundle } = await import('./contentEditor');
global.fetch = vi.fn().mockResolvedValue(
new Response(new Blob(['x']), { status: 200 }),
);
await exportBundle('content');
expect(global.fetch).toHaveBeenCalledWith('/api/content/export?scope=content', { credentials: 'include' });
});
it('melempar error pada respons non-ok', async () => {
const { exportBundle } = await import('./contentEditor');
global.fetch = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ success: false }), { status: 401 }),
);
await expect(exportBundle()).rejects.toThrow();
});
});
describe('importBundle — orkestrasi per-file', () => {
it('mengirim .md baru via tree/file, .md existing via drafts+publish, asset via upload', async () => {
const { importBundle } = await import('./contentEditor');
global.fetch = vi.fn().mockImplementation((url: string, init?: RequestInit) => {
if (url.includes('/api/content/tree/folder')) {
return Promise.resolve(new Response(JSON.stringify({ success: true }), { status: 200 }));
}
if (url.includes('/api/content/tree/file')) {
return Promise.resolve(new Response(JSON.stringify({ success: true }), { status: 200 }));
}
if (url.includes('/publish')) {
return Promise.resolve(new Response(JSON.stringify({ success: true }), { status: 200 }));
}
if (url.includes('/api/content/drafts')) {
return Promise.resolve(new Response(JSON.stringify({ success: true, draft_id: 'd-1' }), { status: 200 }));
}
if (url.includes('/api/content/assets/upload')) {
return Promise.resolve(new Response(JSON.stringify({ success: true }), { status: 200 }));
}
return Promise.resolve(new Response(JSON.stringify({ success: false }), { status: 404 }));
});
const result = await importBundle({
contentFiles: [
{ path: 'lesson/new.md', body: '# New\n' },
{ path: 'lesson/existing.md', body: '# Overwrite\n' },
],
assetFiles: [{ path: 'img/diag.png', bytes: new Uint8Array([1, 2, 3]) }],
mode: 'overwrite',
existingPaths: new Set(['lesson/existing.md']),
});
const calls = (global.fetch as ReturnType<typeof vi.fn>).mock.calls as [string, RequestInit?][];
const bodies = calls.map(([u, i]) => [u, i?.body ? JSON.parse(String(i.body)) : null] as const);
// folder dibuat dulu sebelum file di dalamnya
const folderIdx = calls.findIndex(([u]) => u.includes('/api/content/tree/folder'));
const newFileIdx = calls.findIndex(([u]) => u.includes('/api/content/tree/file'));
expect(folderIdx).toBeGreaterThanOrEqual(0);
expect(folderIdx).toBeLessThan(newFileIdx);
// .md baru → tree/file dengan body sebagai template
expect(bodies.some(([u, b]) => u.includes('/api/content/tree/file') && b?.path === 'lesson/new.md' && b?.template === '# New\n')).toBe(true);
// .md existing → drafts lalu publish
expect(bodies.some(([u, b]) => u.includes('/api/content/drafts') && b?.target_path === 'lesson/existing.md' && b?.base_mtime === null)).toBe(true);
expect(calls.some(([u, i]) => u === '/api/content/drafts/d-1/publish' && i?.method === 'POST')).toBe(true);
// asset → upload dengan folder relatif
expect(bodies.some(([u, b]) => u.includes('/api/content/assets/upload') && b?.filename === 'diag.png' && b?.folder === 'img')).toBe(true);
expect(result.success).toBe(true);
expect(result.imported).toBe(3);
});
it('mode skip tidak menyentuh file yang sudah ada (cek client-side via existingPaths)', async () => {
const { importBundle } = await import('./contentEditor');
global.fetch = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ success: true }), { status: 200 }),
);
const result = await importBundle({
contentFiles: [{ path: 'lesson/existing.md', body: '# x\n' }],
assetFiles: [],
mode: 'skip',
existingPaths: new Set(['lesson/existing.md']),
});
expect(global.fetch).not.toHaveBeenCalled();
expect(result.skipped).toBe(1);
});
it('berhenti pada kegagalan pertama dan melaporkan path yang gagal', async () => {
const { importBundle } = await import('./contentEditor');
global.fetch = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ success: false, message: 'Permission denied' }), { status: 403 }),
);
const result = await importBundle({
contentFiles: [{ path: 'lesson/a.md', body: '# a\n' }],
assetFiles: [],
mode: 'overwrite',
});
expect(result.success).toBe(false);
expect(result.failedPath).toBe('lesson/a.md');
});
});
describe('extractBundle', () => {
it('memisahkan content .md dan asset biner, menandai entry tidak valid', async () => {
const { extractBundle } = await import('./contentEditor');
const zipBytes = zipSync({
'content/lesson/a.md': strToU8('# A\n'),
'assets/img/diag.png': new Uint8Array([1, 2, 3]),
'evil.sh': strToU8('#!/bin/sh\n'),
'content/bad.txt': strToU8('x'),
'content/../../evil.md': strToU8('# evil\n'),
});
const result = await extractBundle(new File([zipBytes], 'b.zip'));
expect(result.contentFiles.map((f) => f.path)).toEqual(['lesson/a.md']);
expect(result.assetFiles.map((f) => f.path)).toEqual(['img/diag.png']);
expect(result.invalidEntries.sort()).toEqual(['content/../../evil.md', 'content/bad.txt', 'evil.sh']);
});
it('menolak bytes zip yang tidak valid', async () => {
const { extractBundle } = await import('./contentEditor');
await expect(extractBundle(new File([new Uint8Array([1, 2, 3])], 'b.zip'))).rejects.toThrow();
});
});

View File

@ -1,3 +1,5 @@
import { unzip, strFromU8 } from 'fflate';
export interface TreeNode {
type: 'file' | 'folder';
name: string;
@ -143,3 +145,155 @@ export async function uploadAsset(file: File, folder = '') {
});
return res.json();
}
// ── Bundle import/export (client-side zip, per-file transfer) ─────
export async function exportBundle(scope: 'all' | 'content' | 'assets' = 'all'): Promise<Blob> {
const url = scope === 'all' ? '/api/content/export' : `/api/content/export?scope=${scope}`;
const res = await fetch(url, { credentials: 'include' });
if (!res.ok) {
throw new Error(`Export gagal (${res.status})`);
}
return res.blob();
}
export interface ImportFileSpec {
path: string; // relatif root (tanpa prefix content/ | assets/)
body?: string; // untuk .md (teks hasil decode)
bytes?: Uint8Array; // untuk asset biner
}
export interface ImportOptions {
contentFiles: ImportFileSpec[];
assetFiles: ImportFileSpec[];
mode: 'skip' | 'overwrite';
existingPaths?: Set<string>; // path (relatif root) yang sudah ada di server
onProgress?: (done: number, total: number, currentPath: string) => void;
}
export interface ImportSummary {
success: boolean;
imported: number;
skipped: number;
failedPath?: string;
message?: string;
}
/** Pastikan semua folder parent dari relPath ada di server.
* POST /content/tree/folder; 409 "Folder sudah ada" dianggap sukses. */
async function ensureFolders(root: 'content' | 'assets', relPath: string): Promise<void> {
const parts = relPath.split('/').slice(0, -1); // buang filename
let cur = '';
for (const part of parts) {
cur = cur ? `${cur}/${part}` : part;
const res = await createFolder(root, cur);
if (!res.success && res.message !== 'Folder sudah ada') {
throw new Error(`Gagal membuat folder ${cur}: ${res.message ?? 'unknown'}`);
}
}
}
export async function importBundle(options: ImportOptions): Promise<ImportSummary> {
const { contentFiles, assetFiles, mode, existingPaths, onProgress } = options;
const total = contentFiles.length + assetFiles.length;
let done = 0;
let imported = 0;
let skipped = 0;
const files: Array<{ root: 'content' | 'assets'; spec: ImportFileSpec }> = [
...contentFiles.map((spec) => ({ root: 'content' as const, spec })),
...assetFiles.map((spec) => ({ root: 'assets' as const, spec })),
];
for (const { root, spec } of files) {
if (mode === 'skip' && existingPaths?.has(spec.path)) {
skipped++;
onProgress?.(++done, total, spec.path);
continue;
}
try {
await ensureFolders(root, spec.path);
if (root === 'content') {
if (existingPaths?.has(spec.path)) {
// file sudah ada → jalur overwrite: draft + publish (base_mtime null = tanpa conflict check)
const draft = await saveDraft(spec.path, spec.body ?? '', null);
if (!draft.success) {
return { success: false, imported, skipped, failedPath: spec.path, message: draft.message };
}
const pub = await publishDraft(draft.draft_id!);
if (!pub.success) {
return { success: false, imported, skipped, failedPath: spec.path, message: pub.message };
}
} else {
// file baru → tree/file dengan body sebagai template
const res = await createFile(spec.path, spec.body);
if (!res.success) {
return { success: false, imported, skipped, failedPath: spec.path, message: res.message };
}
}
} else {
// asset → assets/upload (nama file = basename, folder = parent relatif)
const bytes = spec.bytes ?? new Uint8Array();
const res = await uploadAsset(
new File([bytes.slice().buffer as ArrayBuffer], spec.path.split('/').pop() ?? 'file'),
spec.path.includes('/') ? spec.path.slice(0, spec.path.lastIndexOf('/')) : '',
);
if (!res.success) {
return { success: false, imported, skipped, failedPath: spec.path, message: res.message };
}
}
imported++;
onProgress?.(++done, total, spec.path);
} catch (e) {
return {
success: false, imported, skipped, failedPath: spec.path,
message: e instanceof Error ? e.message : String(e),
};
}
}
return { success: true, imported, skipped };
}
export interface ExtractedBundle {
contentFiles: ImportFileSpec[];
assetFiles: ImportFileSpec[];
invalidEntries: string[]; // entry di luar content/|assets/ | bukan .md di content/ | path mencurigakan
}
/** Extract zip di browser (pola zip = output /content/export:
* top-level `content/**.md` dan `assets/**`). Entry tidak valid
* dikumpulkan di invalidEntries — tidak diimport, dilaporkan ke user. */
export async function extractBundle(file: File): Promise<ExtractedBundle> {
const buf = new Uint8Array(await file.arrayBuffer());
const entries = await new Promise<Record<string, Uint8Array>>((resolve, reject) =>
unzip(buf, (err, data) => (err ? reject(new Error('File bukan zip yang valid')) : resolve(data))),
);
const contentFiles: ImportFileSpec[] = [];
const assetFiles: ImportFileSpec[] = [];
const invalidEntries: string[] = [];
for (const [name, bytes] of Object.entries(entries)) {
if (name.endsWith('/')) continue; // directory entry
const parts = name.split('/');
if ((parts[0] !== 'content' && parts[0] !== 'assets') || parts.length < 2) {
invalidEntries.push(name);
continue;
}
const rel = parts.slice(1).join('/');
if (!rel || rel.startsWith('/') || rel.split('/').includes('..') || rel.split('/').includes('')) {
invalidEntries.push(name);
continue;
}
if (parts[0] === 'content') {
if (!rel.endsWith('.md')) {
invalidEntries.push(name);
continue;
}
contentFiles.push({ path: rel, body: strFromU8(bytes) });
} else {
assetFiles.push({ path: rel, bytes });
}
}
return { contentFiles, assetFiles, invalidEntries };
}

View File

@ -9,7 +9,7 @@
import LessonContentView from '$components/LessonContentView.svelte';
import QuizPreviewReadonly from '$components/QuizPreviewReadonly.svelte';
import { ContentEditorManager } from '$services/contentEditor.svelte';
import { getTree, createFolder, createFile, renameEntry, deleteEntry, uploadAsset } from '$services/contentEditor';
import { getTree, createFolder, createFile, renameEntry, deleteEntry, uploadAsset, exportBundle, extractBundle, importBundle } from '$services/contentEditor';
import type { TreeNode } from '$services/contentEditor';
import { upsertSection } from '$services/markdown';
@ -603,6 +603,113 @@
let activeFileName = $derived(mgr.activePath?.split('/').pop() ?? '');
let isAssetFile = $derived(mgr.treeRoot === 'assets' && mgr.activePath !== null && !mgr.activePath.endsWith('/'));
let isImageFile = $derived(isAssetFile && /\.(png|jpe?g|gif|webp|svg|bmp|ico)$/i.test(mgr.activePath ?? ''));
// ── Export / Import bundle ──────────────────────────────────────
let importExportBusy = $state(false);
let importProgress = $state<{ done: number; total: number; current: string } | null>(null);
function flattenTreePaths(nodes: TreeNode[]): string[] {
const out: string[] = [];
const walk = (list: TreeNode[]) => {
for (const n of list) {
if (n.type === 'file') out.push(n.path);
if (n.children) walk(n.children);
}
};
walk(nodes);
return out;
}
function showToast(text: string, isError = false) {
const toast = document.createElement('div');
toast.textContent = text;
toast.style.cssText = `position:fixed;bottom:1rem;left:50%;transform:translateX(-50%);background:${isError ? '#b91c1c' : '#15803d'};color:#fff;padding:6px 16px;border-radius:8px;font-size:0.8rem;z-index:10000;max-width:90vw;`;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 4000);
}
async function handleExport() {
importExportBusy = true;
try {
const blob = await exportBundle();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `elemes-bundle-${new Date().toISOString().slice(0, 10)}.zip`;
a.click();
URL.revokeObjectURL(url);
showToast('Export berhasil — file sedang diunduh.');
} catch (e) {
showToast(`Export gagal: ${e instanceof Error ? e.message : String(e)}`, true);
} finally {
importExportBusy = false;
}
}
async function handleImportFile(file: File) {
importExportBusy = true;
importProgress = null;
try {
// 1. Reload tree SEBELUM extract supaya existingPaths fresh
await loadTrees();
const existing = new Set<string>([
...flattenTreePaths(mgr.contentTree),
...flattenTreePaths(mgr.assetsTree),
]);
// 2. Extract zip di browser
const bundle = await extractBundle(file);
if (bundle.invalidEntries.length > 0) {
showToast(`${bundle.invalidEntries.length} entry diabaikan: ${bundle.invalidEntries.slice(0, 5).join(', ')}${bundle.invalidEntries.length > 5 ? '…' : ''}`, true);
}
if (bundle.contentFiles.length + bundle.assetFiles.length === 0) {
showToast('Bundle kosong — tidak ada yang diimport.', true);
return;
}
// 3. Konfirmasi mode: OK = skip, Cancel = overwrite
const mode = confirm(
`Import ${bundle.contentFiles.length} materi + ${bundle.assetFiles.length} asset?\n\n` +
'OK = lewati file yang sudah ada\nCancel = timpa file yang sudah ada',
) ? 'skip' : 'overwrite';
// 4. Kirim per-file
const result = await importBundle({
contentFiles: bundle.contentFiles,
assetFiles: bundle.assetFiles,
mode,
existingPaths: existing,
onProgress: (done, total, current) => {
importProgress = { done, total, current };
},
});
showToast(
result.success
? `Import selesai: ${result.imported} ditulis, ${result.skipped} dilewati.`
: `Import gagal di ${result.failedPath}: ${result.message}`,
!result.success,
);
// 5. Reload tree UI setelah import
await loadTrees();
} catch (e) {
showToast(`Import gagal: ${e instanceof Error ? e.message : String(e)}`, true);
} finally {
importExportBusy = false;
importProgress = null;
}
}
function handleImportClick() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.zip,application/zip';
input.onchange = () => {
const file = input.files?.[0];
if (file) handleImportFile(file);
};
input.click();
}
</script>
<svelte:head>
@ -675,6 +782,11 @@
>
<!-- Save / Publish actions (shared across all header modes) -->
{#snippet saveActions()}
<button class="panel-btn" onclick={handleExport} disabled={importExportBusy} title="Export semua materi & assets (zip)">📤</button>
<button class="panel-btn" onclick={handleImportClick} disabled={importExportBusy} title="Import materi & assets dari zip (extract di browser, kirim per-file)">📥</button>
{#if importProgress}
<span class="ie-progress" role="status">Impor {importProgress.done}/{importProgress.total}</span>
{/if}
{#if mgr.activePath && !isAssetFile}
<button class="panel-btn toolbar-toggle" class:active={featureSidebarOpen} onclick={() => featureSidebarOpen = !featureSidebarOpen} title="Tampilkan/sembunyikan panel Fitur">🧩</button>
<button class="panel-btn save-btn" onclick={() => handleSaveAction()} disabled={mgr.saving || (!mgr.dirty && mgr.activeTab !== 'velxio')} title="Simpan">
@ -1090,6 +1202,12 @@
.panel-btn:disabled { opacity: 0.45; cursor: not-allowed; }
.panel-btn.save-btn { color: var(--color-text-muted); }
.panel-btn.publish-btn { color: var(--color-primary, #339af0); font-weight: 600; }
.ie-progress {
font-size: 0.75rem;
color: var(--color-text-muted);
padding: 0.15rem 0.5rem;
white-space: nowrap;
}
/* Editor main: tab panels (left) + feature sidebar (right) */
.editor-main {

View File

@ -25,8 +25,9 @@ import io
import os
import re as _re
import shutil
import zipfile
from flask import Blueprint, jsonify, request
from flask import Blueprint, jsonify, request, send_file
from config import ASSETS_DIR, CONTENT_DIR
from services import repositories
@ -522,3 +523,52 @@ def upload_asset():
return jsonify({"success": True, "path": rel_path, "url": f"/assets/{rel_path}"})
finally:
db.close()
# ── Bundle export (import dilakukan client-side per-file) ────────
def _add_dir_to_zip(zf: zipfile.ZipFile, dir_abs: str, prefix: str) -> int:
"""Tambahkan semua file di dir_abs ke zip dengan prefix folder.
Ditulis per-file (zf.write) — tidak ada batasan ukuran total."""
count = 0
for base, _dirs, files in os.walk(dir_abs):
for fname in sorted(files):
abs_path = os.path.join(base, fname)
rel_inside = os.path.relpath(abs_path, dir_abs)
zf.write(abs_path, arcname=f"{prefix}/{rel_inside}")
count += 1
return count
@content_editor_bp.route("/export", methods=["GET"])
def export_bundle():
db = SessionLocal()
try:
user, err = _require_teacher(db)
if err:
return err
scope = request.args.get("scope", "all")
if scope not in ("all", "content", "assets"):
return jsonify({"success": False, "message": "scope tidak valid (all|content|assets)"}), 400
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
file_count = 0
if scope in ("all", "content"):
file_count += _add_dir_to_zip(zf, _CONTENT_DIR_ABS, "content")
if scope in ("all", "assets"):
file_count += _add_dir_to_zip(zf, _ASSETS_DIR_ABS, "assets")
if file_count == 0:
return jsonify({"success": False, "message": "Tidak ada file untuk diekspor"}), 404
from datetime import datetime
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
buf.seek(0)
return send_file(
buf,
mimetype="application/zip",
as_attachment=True,
download_name=f"elemes-bundle-{scope}-{stamp}.zip",
)
finally:
db.close()

View File

@ -0,0 +1,211 @@
"""E2E verify: tombol export/import bundle di content editor (lms-dev).
Auth: cookie student_token=141214 (HttpOnly, untuk /api) + localStorage
(studi) + reload supaya auth.init() jalan. Sama seperti recipe skill.
Langkah:
1. Login guru -> buka /teacher/content
2. Klik tombol export (📤) -> tangkap download event -> simpan zip, cek isinya
3. Klik tombol import (📥) -> pilih probe zip via file_chooser -> accept confirm
4. Cek via API /api/content/tree bahwa imported/probe.md muncul
Menulis zip probe sendiri (content/imported/probe.md + assets/imported/probe.png).
"""
import io
import sys
import zipfile
from pathlib import Path
from playwright.sync_api import sync_playwright
BASE = "https://sinau-c-dev.manakin-gentoo.ts.net"
TEACHER_TOKEN = "141214"
PROBE_ZIP = Path("/tmp/elemes-probe-bundle.zip")
DOWNLOAD_DIR = Path("/tmp/elemes-e2e-downloads")
def make_probe_zip() -> Path:
if PROBE_ZIP.exists():
return PROBE_ZIP
with zipfile.ZipFile(PROBE_ZIP, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("content/imported/probe.md", "# Probe Import\n\nMateri test.\n")
zf.writestr("assets/imported/probe.png", b"\x89PNG\r\n\x1a\nfake-png")
return PROBE_ZIP
def main() -> int:
make_probe_zip()
DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
ctx = browser.new_context(
accept_downloads=True,
ignore_https_errors=True,
service_workers="block",
viewport={"width": 1280, "height": 800},
)
ctx.add_cookies([{
"name": "student_token",
"value": TEACHER_TOKEN,
"domain": "sinau-c-dev.manakin-gentoo.ts.net",
"path": "/",
"http_only": True,
"same_site": "Lax",
}])
page = ctx.new_page()
page.add_init_script(f"localStorage.setItem('student_token','{TEACHER_TOKEN}');")
pageerrors = []
page.on("pageerror", lambda e: pageerrors.append(str(e)))
# ── Login & buka editor ──
page.goto(f"{BASE}/teacher/content", wait_until="networkidle")
page.reload(wait_until="networkidle")
page.wait_for_timeout(2500)
# Guard: pastikan editor guru tampil
if page.locator(".content-editor-layout").count() == 0:
print("FAIL: .content-editor-layout tidak muncul (auth? gate?)")
print("pageerrors:", pageerrors)
browser.close()
return 1
print("OK: content editor tampil")
# ── Export ──
export_btn = page.locator("button.panel-btn", has_text="📤")
if export_btn.count() == 0:
print("FAIL: tombol export 📤 tidak ditemukan")
browser.close()
return 1
print("OK: tombol export ditemukan")
with page.expect_download(timeout=20000) as dl_info:
export_btn.first.click()
download = dl_info.value
dest = DOWNLOAD_DIR / download.suggested_filename
download.save_as(dest)
print(f"OK: download tersimpan -> {dest} ({dest.stat().st_size} bytes)")
# Cek isi zip
with zipfile.ZipFile(dest) as zf:
names = set(zf.namelist())
assert "content/home.md" in names, f"zip tidak berisi content/home.md: {sorted(names)[:8]}"
assert any(n.startswith("assets/") for n in names), "zip tidak berisi assets/"
print(f"OK: zip berisi {len(names)} entry; ada content/ dan assets/")
# ── Import ──
import_btn = page.locator("button.panel-btn", has_text="📥")
if import_btn.count() == 0:
print("FAIL: tombol import 📥 tidak ditemukan")
browser.close()
return 1
print("OK: tombol import ditemukan")
confirmed = {"mode": None}
def on_dialog(dialog):
# window.confirm: OK = skip, Cancel = overwrite -> pilih overwrite
# (probe file belum ada, jadi mode tidak terlalu penting; pilih skip/OK)
print(f" dialog: {dialog.type} -> accept (skip mode)")
confirmed["mode"] = "skip"
dialog.accept()
page.on("dialog", on_dialog)
with page.expect_file_chooser(timeout=20000) as fc_info:
import_btn.first.click()
fc = fc_info.value
fc.set_files(str(PROBE_ZIP))
print("OK: file probe dipilih")
# Tunggu toast/import selesai (progress -> toast -> reload tree)
page.wait_for_timeout(5000)
# ── Verify via API tree ──
tree = page.evaluate(
"""async () => {
const r = await fetch('/api/content/tree?root=content', { credentials: 'include' });
const j = await r.json();
return j.success ? j.tree : null;
}"""
)
flat = []
def walk(nodes):
for n in nodes or []:
flat.append(n["path"])
if n.get("children"):
walk(n["children"])
walk(tree)
ok_imported = "imported/probe.md" in flat
print(f"OK imported/probe.md di tree: {ok_imported}")
if not ok_imported:
print(" tree sample:", flat[:10])
print("FAIL: imported/probe.md tidak ada di tree")
print("pageerrors:", pageerrors)
browser.close()
return 1
# ── Verify asset via API tree assets ──
atree = page.evaluate(
"""async () => {
const r = await fetch('/api/content/tree?root=assets', { credentials: 'include' });
const j = await r.json();
return j.success ? j.tree : null;
}"""
)
aflat = []
def walk_a(nodes):
for n in nodes or []:
aflat.append(n["path"])
if n.get("children"):
walk_a(n["children"])
walk_a(atree)
ok_asset = "imported/probe.png" in aflat
print(f"OK imported/probe.png di assets tree: {ok_asset}")
# ── Overwrite round: import ulang mode overwrite, pastikan isi berubah ──
# (mode ditentukan dari confirm -> kedua kalinya tekan Cancel)
confirmed["mode"] = None
def on_dialog2(dialog):
dialog.dismiss() # Cancel = overwrite
page.remove_listener("dialog", on_dialog)
page.on("dialog", on_dialog2)
with page.expect_file_chooser(timeout=20000) as fc_info2:
import_btn.first.click()
fc2 = fc_info2.value
fc2.set_files(str(PROBE_ZIP))
page.wait_for_timeout(5000)
body = page.evaluate(
"""async () => {
const r = await fetch('/api/content/drafts?target_path=imported/probe.md', { credentials: 'include' });
const j = await r.json();
return j.success ? (j.body || '') : 'ERR';
}"""
)
print(f"OK body probe.md setelah overwrite: {body.strip()[:40]!r}")
if not body.startswith("# Probe Import"):
print("FAIL: isi probe.md tidak sesuai setelah overwrite")
browser.close()
return 1
print("pageerrors:", pageerrors)
if pageerrors:
# filter 502/favicon infra noise
real = [e for e in pageerrors if "502" not in e and "favicon" not in e]
if real:
print("FAIL: pageerrors nyata:", real)
browser.close()
return 1
print("\nALL E2E CHECKS PASSED")
browser.close()
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -10,10 +10,12 @@ Mengunci kontrak:
- regression: _render_markdown_string() == render_markdown_content() untuk file yang sama;
"""
import io
import os
import pathlib
import shutil
import time
import zipfile
import pytest
@ -634,3 +636,37 @@ def test_save_draft_stores_correct_author_id(client):
assert draft.author_id == teacher.id
finally:
db.close()
# ── Export bundle ──────────────────────────────────────────────────
def test_export_requires_teacher(client):
_seed_and_setup()
resp = client.get("/content/export")
assert resp.status_code == 401
def test_export_returns_zip_with_content_and_assets(client):
_seed_and_setup()
_login_teacher(client)
(TEST_ASSETS / "diag.png").write_bytes(b"fake-png")
resp = client.get("/content/export")
assert resp.status_code == 200
assert "application/zip" in resp.headers["Content-Type"]
assert resp.headers["Content-Disposition"].startswith("attachment; filename=")
zf = zipfile.ZipFile(io.BytesIO(resp.data))
names = set(zf.namelist())
assert "content/test_editor/hello.md" in names
assert "content/home.md" in names
assert "assets/diag.png" in names
def test_export_scope_content_excludes_assets(client):
_seed_and_setup()
_login_teacher(client)
(TEST_ASSETS / "diag.png").write_bytes(b"fake-png")
resp = client.get("/content/export?scope=content")
assert resp.status_code == 200
names = zipfile.ZipFile(io.BytesIO(resp.data)).namelist()
assert any(n.startswith("content/") for n in names)
assert not any(n.startswith("assets/") for n in names)