Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions rhwp-studio/e2e/hwpx-direct-save.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* HWPX 직접 저장 (file:save) E2E — #1532
*
* 검증: HWPX 출처 문서에서 file:save(Ctrl+S) 가
* 1. 베타 비활성 alert 를 더 이상 띄우지 않음
* 2. application/hwp+zip blob(HWPX PK 매직)을 생성
* 3. 그 저장본을 다시 loadDocument 했을 때 페이지 수가 일치(정상 재오픈)
*
* 실행: node e2e/hwpx-direct-save.test.mjs --mode=headless
*/
import { runTest, assert, loadHwpFile } from './helpers.mjs';

const SAMPLE = 'hwpx/footnote-01.hwpx';

runTest('HWPX 직접 저장: file:save → HWPX 생성 + 재오픈', async ({ page }) => {
page.on('console', (msg) => {
const t = msg.text();
if (t.includes('[file:save') || t.includes('저장') || t.includes('save')) console.log(` [page] ${t}`);
});
// 0) HWPX 로드 → sourceFormat=hwpx
console.log(`\n[0] HWPX 로드 (${SAMPLE})`);
const load = await loadHwpFile(page, SAMPLE);
const before = load.pageCount;
console.log(` 페이지 수: ${before}`);
assert(before >= 1, `페이지 수 1 이상 (current: ${before})`);

const fmt = await page.evaluate(() => window.__wasm?.getSourceFormat?.());
console.log(` sourceFormat: ${fmt}`);
assert(fmt === 'hwpx', `sourceFormat 은 hwpx 여야 함 (current: ${fmt})`);

// 1) 훅: alert 캡처 / FS Access API 제거(폴백 강제) / blob 캡처 / 실제 다운로드 차단
console.log('\n[1] 저장 경로 훅 설치 (alert·showSaveFilePicker·createObjectURL·click)');
await page.evaluate(() => {
window.__alerts = [];
window.alert = (m) => { window.__alerts.push(String(m)); };
try { delete window.showSaveFilePicker; } catch { window.showSaveFilePicker = undefined; }
window.__savedBlob = null;
URL.createObjectURL = (blob) => { window.__savedBlob = blob; return 'blob:captured'; };
HTMLAnchorElement.prototype.click = function () { /* no-op: 실제 다운로드 차단 */ };
});

// 2) file:save 트리거 — 메뉴바 파일 열기(mousedown→updateMenuStates) → 저장 클릭
console.log('\n[2] file:save 트리거 (메뉴: 파일 → 저장)');
const triggered = await page.evaluate(() => {
const fileItem = [...document.querySelectorAll('#menu-bar .menu-item')]
.find((el) => (el.textContent || '').includes('파일'));
const title = fileItem?.querySelector('.menu-title');
if (!title) return { ok: false, reason: '파일 메뉴 타이틀 없음' };
// 메뉴는 .menu-title 의 mousedown 으로 열리며 그때 updateMenuStates 가 실행된다.
title.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
const pageCount = window.__wasm?.pageCount;
const saveItem = document.querySelector('.md-item[data-cmd="file:save"]');
if (!saveItem) return { ok: false, reason: 'file:save 메뉴 항목 없음', pageCount };
const disabled = saveItem.classList.contains('disabled');
saveItem.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
return { ok: true, disabled, pageCount };
});
console.log(` 트리거: ${JSON.stringify(triggered)}`);
assert(triggered.ok, `저장 메뉴 항목을 찾아야 함 (${triggered.reason || ''})`);
assert(!triggered.disabled, '저장 메뉴 항목이 비활성(disabled) 이면 안 됨');
await page.evaluate(() => new Promise((r) => setTimeout(r, 2000)));

// 3) 결과 수집 + 저장본 재오픈
console.log('\n[3] 저장 결과 수집 + 저장본 재오픈');
const result = await page.evaluate(async () => {
const alerts = window.__alerts || [];
if (!window.__savedBlob) return { alerts, captured: false };
const buf = await window.__savedBlob.arrayBuffer();
const head = Array.from(new Uint8Array(buf.slice(0, 4)));
const type = window.__savedBlob.type;
const len = buf.byteLength;
// 저장본 재오픈
const info = window.__wasm?.loadDocument(new Uint8Array(buf), 'saved.hwpx');
return { alerts, captured: true, head, type, len, reopenPages: info?.pageCount };
});

console.log(` alerts=${JSON.stringify(result.alerts)}`);
console.log(` captured=${result.captured} head=[${result.head}] type=${result.type} len=${result.len}`);
console.log(` 재오픈 페이지수=${result.reopenPages} (원본 ${before})`);

// 단언
assert(result.alerts.length === 0,
`베타 비활성 alert 가 없어야 함 (alerts: ${JSON.stringify(result.alerts)})`);
assert(result.captured, 'file:save 가 저장 blob 을 생성해야 함 (캡처 실패)');
assert(result.head[0] === 0x50 && result.head[1] === 0x4B && result.head[2] === 0x03 && result.head[3] === 0x04,
`저장본은 HWPX PK\\x03\\x04 매직 (current head: ${result.head})`);
assert(result.type === 'application/hwp+zip',
`blob type 은 application/hwp+zip (current: ${result.type})`);
assert(result.len > 0, `저장본 길이 > 0 (current: ${result.len})`);
assert(result.reopenPages === before,
`저장본 재오픈 페이지수 일치 (${result.reopenPages} vs ${before})`);

console.log('\n✅ HWPX 직접 저장 + 재오픈 검증 통과');
});
41 changes: 22 additions & 19 deletions rhwp-studio/src/command/commands/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,18 @@ function isUserCancelError(e: unknown): boolean {
&& (e.name === 'AbortError' || e.name === 'NotAllowedError');
}

function hwpSaveFileName(fileName: string): string {
const trimmed = fileName.trim() || 'document.hwp';
/// 출처 포맷에 맞춘 저장 파일명(.hwp / .hwpx). HWPX 직접 저장 활성화용.
function saveFileNameFor(fileName: string, isHwpx: boolean): string {
const ext = isHwpx ? '.hwpx' : '.hwp';
const trimmed = fileName.trim() || `document${ext}`;
if (/\.(hwp|hwpx)$/i.test(trimmed)) {
return trimmed.replace(/\.(hwp|hwpx)$/i, '.hwp');
return trimmed.replace(/\.(hwp|hwpx)$/i, ext);
}
return `${trimmed}.hwp`;
return `${trimmed}${ext}`;
}

function hwpSaveBaseName(fileName: string): string {
return hwpSaveFileName(fileName).replace(/\.hwp$/i, '');
function saveBaseNameFor(fileName: string, isHwpx: boolean): string {
return saveFileNameFor(fileName, isHwpx).replace(/\.(hwp|hwpx)$/i, '');
}

function hwpSaveCurrentHandle(
Expand All @@ -58,13 +60,12 @@ export async function saveCurrentDocument(services: CommandServices): Promise<Sa
const saveName = services.wasm.fileName;
const sourceFormat = services.wasm.getSourceFormat();
const isHwpx = sourceFormat === 'hwpx';
if (isHwpx) {
alert('HWPX 형식은 현재 베타 단계라 직접 저장이 비활성화되어 있습니다.');
return 'unsupported';
}

const bytes = services.wasm.exportHwp();
const blob = new Blob([bytes as unknown as BlobPart], { type: 'application/x-hwp' });
// HWPX 출처는 HWPX 로 직접 저장(직렬화 충실도 확보 후 활성화). 그 외는 HWP.
const bytes = isHwpx ? services.wasm.exportHwpx() : services.wasm.exportHwp();
const blob = new Blob([bytes as unknown as BlobPart], {
type: isHwpx ? 'application/hwp+zip' : 'application/x-hwp',
});
console.log(`[file:save] format=${sourceFormat}, isHwpx=${isHwpx}, ${bytes.length} bytes`);

try {
Expand All @@ -89,7 +90,7 @@ export async function saveCurrentDocument(services: CommandServices): Promise<Sa

let downloadName = saveName;
if (services.wasm.isNewDocument) {
const baseName = saveName.replace(/\.hwp$/i, '');
const baseName = saveBaseNameFor(saveName, isHwpx);
const result = await showSaveAs(baseName);
if (!result) return 'cancelled';
downloadName = result;
Expand Down Expand Up @@ -122,7 +123,7 @@ export async function confirmSaveBeforeReplacingDocument(

const choice = await showUnsavedChangesDialog({
fileName: services.wasm.fileName,
canSave: ctx.sourceFormat !== 'hwpx',
canSave: true, // HWPX 직접 저장 활성화로 모든 출처 저장 가능
});

if (choice === 'cancel') return false;
Expand Down Expand Up @@ -246,10 +247,12 @@ export const fileCommands: CommandDef[] = [
try {
const sourceFormat = services.wasm.getSourceFormat();
const isHwpx = sourceFormat === 'hwpx';
const saveName = hwpSaveFileName(services.wasm.fileName);
const bytes = services.wasm.exportHwp();
const blob = new Blob([bytes as unknown as BlobPart], { type: 'application/x-hwp' });
console.log(`[file:save-as] format=${sourceFormat}, hwpExport=${isHwpx}, ${bytes.length} bytes`);
const saveName = saveFileNameFor(services.wasm.fileName, isHwpx);
const bytes = isHwpx ? services.wasm.exportHwpx() : services.wasm.exportHwp();
const blob = new Blob([bytes as unknown as BlobPart], {
type: isHwpx ? 'application/hwp+zip' : 'application/x-hwp',
});
console.log(`[file:save-as] format=${sourceFormat}, isHwpx=${isHwpx}, ${bytes.length} bytes`);

try {
const saveResult = await saveDocumentToFileSystem({
Expand All @@ -272,7 +275,7 @@ export const fileCommands: CommandDef[] = [
}

// 폴백: 파일명 입력 → blob download
const baseName = hwpSaveBaseName(saveName);
const baseName = saveBaseNameFor(saveName, isHwpx);
const result = await showSaveAs(baseName);
if (!result) return;
const downloadName = result;
Expand Down
6 changes: 1 addition & 5 deletions rhwp-studio/src/hwpctl/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,7 @@ export class HwpCtrl {
SaveAs(filename: string, format?: string, arg?: string): boolean {
try {
const sourceFormat = this.wasmDoc.getSourceFormat();
// #196: HWPX 출처는 저장 비활성화 (베타 단계, #197 완전 변환기 완료 시까지)
if (sourceFormat === 'hwpx' && format !== 'hwp') {
console.warn('[hwpctl] SaveAs: HWPX 출처 저장은 현재 베타 단계로 비활성화되어 있습니다 (#196)');
return false;
}
// HWPX 직접 저장 활성화(직렬화 충실도 확보). format 지정 우선, 없으면 출처 따름.
const isHwpx = format === 'hwpx' || (!format && sourceFormat === 'hwpx');
console.log(`[hwpctl] SaveAs: filename=${filename}, sourceFormat=${sourceFormat}, isHwpx=${isHwpx}`);

Expand Down
Loading