fix: ENOENT download error for archive-only converters (PDFMathTranslate/MinerU)
- Add existsSync checks in download.tsx before file access - Fix normalizeFiletype.ts: md-t/md-i return original format (not tar.gz) - Add outputMode: 'archive' support in main.ts with auto .tar suffix - PDFMathTranslate outputs translated-<lang>.pdf + bilingual-<lang>.pdf (no original) - MinerU outputs complete folder in .tar with logging - Hide preview icon for .tar files in results.tsx - Update Bun version: 1.2.2 -> 1.3.6 - Add Step 19: auto-deploy to remote server after Docker push All 159 tests pass.
This commit is contained in:
parent
1f800c3619
commit
912546aaa7
10 changed files with 190 additions and 63 deletions
|
|
@ -1 +1 @@
|
||||||
1.2.2
|
1.3.6
|
||||||
45
.github/workflows/docker-build-remote.yml
vendored
45
.github/workflows/docker-build-remote.yml
vendored
|
|
@ -691,3 +691,48 @@ jobs:
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# Step 19: 更新遠端 ConvertX-CN 服務
|
||||||
|
# 說明:拉取最新映像並重新啟動服務(所有步驟完成後執行)
|
||||||
|
# ========================================
|
||||||
|
- name: 🔄 更新遠端 ConvertX-CN 服務
|
||||||
|
run: |
|
||||||
|
echo "========================================"
|
||||||
|
echo "🔄 更新遠端 ConvertX-CN 服務"
|
||||||
|
echo "========================================"
|
||||||
|
|
||||||
|
tailscale ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} << 'REMOTE_EOF'
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# 1. 進入專案資料夾
|
||||||
|
cd /home/bioailab/miniconda3/lid/app/convertx-cn
|
||||||
|
|
||||||
|
# 2. 停止服務
|
||||||
|
echo "📋 停止服務..."
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
# 3. 拉取最新映像檔
|
||||||
|
echo ""
|
||||||
|
echo "📋 拉取最新映像檔..."
|
||||||
|
docker compose pull
|
||||||
|
|
||||||
|
# 4. 重新啟動
|
||||||
|
echo ""
|
||||||
|
echo "📋 重新啟動服務..."
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# 5. 顯示服務狀態與 log
|
||||||
|
echo ""
|
||||||
|
echo "📋 服務狀態:"
|
||||||
|
docker compose ps
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "📋 服務 log(最近 100 行):"
|
||||||
|
docker logs convertx-cn --tail=100
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ ConvertX-CN 服務已更新完成"
|
||||||
|
REMOTE_EOF
|
||||||
|
|
||||||
|
echo "========================================"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
[tools]
|
[tools]
|
||||||
bun = "1.2.2"
|
bun = "1.3.6"
|
||||||
|
|
||||||
[env]
|
[env]
|
||||||
JWT_SECRET = "aLongAndSecretStringUsedToSignTheJSONWebToken1234"
|
JWT_SECRET = "aLongAndSecretStringUsedToSignTheJSONWebToken1234"
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ const properties: Record<
|
||||||
properties: {
|
properties: {
|
||||||
from: Record<string, string[]>;
|
from: Record<string, string[]>;
|
||||||
to: Record<string, string[]>;
|
to: Record<string, string[]>;
|
||||||
|
outputMode?: "archive";
|
||||||
options?: Record<
|
options?: Record<
|
||||||
string,
|
string,
|
||||||
Record<
|
Record<
|
||||||
|
|
@ -142,7 +143,7 @@ const properties: Record<
|
||||||
properties: propertiesMarkitdown,
|
properties: propertiesMarkitdown,
|
||||||
converter: convertMarkitdown,
|
converter: convertMarkitdown,
|
||||||
},
|
},
|
||||||
mineru: {
|
MinerU: {
|
||||||
properties: propertiesMineru,
|
properties: propertiesMineru,
|
||||||
converter: convertMineru,
|
converter: convertMineru,
|
||||||
},
|
},
|
||||||
|
|
@ -173,6 +174,10 @@ export async function handleConvert(
|
||||||
"INSERT INTO file_names (job_id, file_name, output_file_name, status) VALUES (?1, ?2, ?3, ?4)",
|
"INSERT INTO file_names (job_id, file_name, output_file_name, status) VALUES (?1, ?2, ?3, ?4)",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Check if the converter outputs an archive (.tar)
|
||||||
|
const converterProps = properties[converterName]?.properties;
|
||||||
|
const isArchiveOutput = converterProps?.outputMode === "archive";
|
||||||
|
|
||||||
for (const chunk of chunks(fileNames, MAX_CONVERT_PROCESS)) {
|
for (const chunk of chunks(fileNames, MAX_CONVERT_PROCESS)) {
|
||||||
const toProcess: Promise<string>[] = [];
|
const toProcess: Promise<string>[] = [];
|
||||||
for (const fileName of chunk) {
|
for (const fileName of chunk) {
|
||||||
|
|
@ -180,11 +185,17 @@ export async function handleConvert(
|
||||||
const fileTypeOrig = fileName.split(".").pop() ?? "";
|
const fileTypeOrig = fileName.split(".").pop() ?? "";
|
||||||
const fileType = normalizeFiletype(fileTypeOrig);
|
const fileType = normalizeFiletype(fileTypeOrig);
|
||||||
const newFileExt = normalizeOutputFiletype(convertTo);
|
const newFileExt = normalizeOutputFiletype(convertTo);
|
||||||
const newFileName = fileName.replace(
|
let newFileName = fileName.replace(
|
||||||
new RegExp(`${fileTypeOrig}(?!.*${fileTypeOrig})`),
|
new RegExp(`${fileTypeOrig}(?!.*${fileTypeOrig})`),
|
||||||
newFileExt,
|
newFileExt,
|
||||||
);
|
);
|
||||||
const targetPath = `${userOutputDir}${newFileName}`;
|
|
||||||
|
// For archive output converters, the actual file will have .tar extension
|
||||||
|
if (isArchiveOutput) {
|
||||||
|
newFileName = `${newFileName}.tar`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetPath = `${userOutputDir}${newFileName.replace(/\.tar$/, "")}`;
|
||||||
toProcess.push(
|
toProcess.push(
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
mainConverter(filePath, fileType, convertTo, targetPath, {}, converterName)
|
mainConverter(filePath, fileType, convertTo, targetPath, {}, converterName)
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,7 @@ export async function convert(
|
||||||
// Create .tar archive from the output directory (不使用壓縮)
|
// Create .tar archive from the output directory (不使用壓縮)
|
||||||
// 強制使用 .tar 格式,禁止 .tar.gz
|
// 強制使用 .tar 格式,禁止 .tar.gz
|
||||||
const tarPath = getArchiveFileName(targetPath);
|
const tarPath = getArchiveFileName(targetPath);
|
||||||
|
console.log(`[MinerU] Target tar path: ${tarPath}`);
|
||||||
|
|
||||||
// Ensure the parent directory exists
|
// Ensure the parent directory exists
|
||||||
const tarDir = dirname(tarPath);
|
const tarDir = dirname(tarPath);
|
||||||
|
|
@ -121,11 +122,21 @@ export async function convert(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the actual MinerU output directory for archiving
|
// Use the actual MinerU output directory for archiving
|
||||||
|
// MinerU 產生完整資料夾結構,全部封裝進 .tar
|
||||||
const outputToArchive = existsSync(mineruActualOutput)
|
const outputToArchive = existsSync(mineruActualOutput)
|
||||||
? mineruActualOutput
|
? mineruActualOutput
|
||||||
: mineruOutputDir;
|
: mineruOutputDir;
|
||||||
|
|
||||||
|
console.log(`[MinerU] Archiving directory: ${outputToArchive}`);
|
||||||
|
|
||||||
|
// 列出要封裝的內容
|
||||||
|
if (existsSync(outputToArchive)) {
|
||||||
|
const contents = readdirSync(outputToArchive);
|
||||||
|
console.log(`[MinerU] Archive contents: ${contents.join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
await createTarArchive(outputToArchive, tarPath, execFile);
|
await createTarArchive(outputToArchive, tarPath, execFile);
|
||||||
|
console.log(`[MinerU] Created archive: ${tarPath}`);
|
||||||
|
|
||||||
// Clean up the temporary directory
|
// Clean up the temporary directory
|
||||||
removeDir(mineruOutputDir);
|
removeDir(mineruOutputDir);
|
||||||
|
|
|
||||||
|
|
@ -261,49 +261,75 @@ export async function convert(
|
||||||
// 5. 執行 pdf2zh 翻譯
|
// 5. 執行 pdf2zh 翻譯
|
||||||
const { monoPath, dualPath } = await runPdf2zh(filePath, tempDir, targetLang, execFile);
|
const { monoPath, dualPath } = await runPdf2zh(filePath, tempDir, targetLang, execFile);
|
||||||
|
|
||||||
// 6. 複製原始檔案到封裝目錄
|
// 6. 複製翻譯後的檔案到封裝目錄
|
||||||
const originalDest = join(archiveDir, "original.pdf");
|
// PDFMathTranslate 輸出:
|
||||||
copyFileSync(filePath, originalDest);
|
// - mono: 純翻譯版本(translated-<lang>.pdf)
|
||||||
|
// - dual: 雙語對照版本(bilingual-<lang>.pdf)
|
||||||
|
// ⚠️ 不包含原始 PDF,只包含翻譯結果
|
||||||
|
|
||||||
// 7. 複製翻譯後的檔案(優先使用 mono,因為它是純翻譯版本)
|
|
||||||
const translatedDest = join(archiveDir, `translated-${targetLang}.pdf`);
|
const translatedDest = join(archiveDir, `translated-${targetLang}.pdf`);
|
||||||
|
const bilingualDest = join(archiveDir, `bilingual-${targetLang}.pdf`);
|
||||||
|
|
||||||
// 檢查各種可能的輸出檔案名稱
|
// 檢查各種可能的 mono 輸出檔案名稱
|
||||||
const possibleOutputs = [
|
const possibleMonoOutputs = [
|
||||||
monoPath,
|
monoPath,
|
||||||
dualPath,
|
|
||||||
join(tempDir, `${inputFileName}-mono.pdf`),
|
join(tempDir, `${inputFileName}-mono.pdf`),
|
||||||
|
];
|
||||||
|
|
||||||
|
// 檢查各種可能的 dual 輸出檔案名稱
|
||||||
|
const possibleDualOutputs = [
|
||||||
|
dualPath,
|
||||||
join(tempDir, `${inputFileName}-dual.pdf`),
|
join(tempDir, `${inputFileName}-dual.pdf`),
|
||||||
];
|
];
|
||||||
|
|
||||||
let foundOutput = false;
|
let foundMono = false;
|
||||||
for (const outputPath of possibleOutputs) {
|
let foundDual = false;
|
||||||
|
|
||||||
|
// 複製 mono(翻譯版)
|
||||||
|
for (const outputPath of possibleMonoOutputs) {
|
||||||
if (existsSync(outputPath)) {
|
if (existsSync(outputPath)) {
|
||||||
copyFileSync(outputPath, translatedDest);
|
copyFileSync(outputPath, translatedDest);
|
||||||
foundOutput = true;
|
foundMono = true;
|
||||||
console.log(`[PDFMathTranslate] Using output: ${outputPath}`);
|
console.log(`[PDFMathTranslate] Copied mono (translated): ${outputPath}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 複製 dual(對照版)
|
||||||
|
for (const outputPath of possibleDualOutputs) {
|
||||||
|
if (existsSync(outputPath)) {
|
||||||
|
copyFileSync(outputPath, bilingualDest);
|
||||||
|
foundDual = true;
|
||||||
|
console.log(`[PDFMathTranslate] Copied dual (bilingual): ${outputPath}`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果找不到預期的輸出檔案,嘗試找任何 PDF
|
// 如果找不到預期的輸出檔案,嘗試找任何 PDF
|
||||||
if (!foundOutput) {
|
if (!foundMono && !foundDual) {
|
||||||
const allFiles = readdirSync(tempDir);
|
const allFiles = readdirSync(tempDir);
|
||||||
const pdfFiles = allFiles.filter((f) => f.endsWith(".pdf") && f !== basename(filePath));
|
const pdfFiles = allFiles.filter((f) => f.endsWith(".pdf") && f !== basename(filePath));
|
||||||
const firstPdfName = pdfFiles[0];
|
|
||||||
|
|
||||||
if (firstPdfName) {
|
for (const pdfName of pdfFiles) {
|
||||||
const firstPdf = join(tempDir, firstPdfName);
|
const pdfPath = join(tempDir, pdfName);
|
||||||
copyFileSync(firstPdf, translatedDest);
|
if (pdfName.includes("mono") || pdfName.includes("translated")) {
|
||||||
foundOutput = true;
|
copyFileSync(pdfPath, translatedDest);
|
||||||
console.log(`[PDFMathTranslate] Using fallback output: ${firstPdf}`);
|
foundMono = true;
|
||||||
|
console.log(`[PDFMathTranslate] Using fallback mono: ${pdfPath}`);
|
||||||
|
} else if (pdfName.includes("dual") || pdfName.includes("bilingual")) {
|
||||||
|
copyFileSync(pdfPath, bilingualDest);
|
||||||
|
foundDual = true;
|
||||||
|
console.log(`[PDFMathTranslate] Using fallback dual: ${pdfPath}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!foundOutput) {
|
if (!foundMono && !foundDual) {
|
||||||
throw new Error("No translated PDF output found");
|
throw new Error("No translated PDF output found (neither mono nor dual)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`[PDFMathTranslate] Archive contents: mono=${foundMono}, dual=${foundDual}`);
|
||||||
|
|
||||||
// 8. 建立 .tar 封裝
|
// 8. 建立 .tar 封裝
|
||||||
const tarPath = getArchiveFileName(targetPath);
|
const tarPath = getArchiveFileName(targetPath);
|
||||||
const tarDir = dirname(tarPath);
|
const tarDir = dirname(tarPath);
|
||||||
|
|
|
||||||
|
|
@ -31,10 +31,11 @@ export const normalizeOutputFiletype = (filetype: string): string => {
|
||||||
case "markdown_mmd":
|
case "markdown_mmd":
|
||||||
case "markdown":
|
case "markdown":
|
||||||
return "md";
|
return "md";
|
||||||
// MinerU output formats - output as tar.gz
|
// MinerU output formats - 保持原始格式名稱(.tar 由 main.ts 自動添加)
|
||||||
|
// 因為 MinerU 是 archive-only 引擎,outputMode: "archive"
|
||||||
case "md-t":
|
case "md-t":
|
||||||
case "md-i":
|
case "md-i":
|
||||||
return "tar.gz";
|
return lowercaseFiletype;
|
||||||
default:
|
default:
|
||||||
return lowercaseFiletype;
|
return lowercaseFiletype;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
import { Elysia } from "elysia";
|
import { Elysia } from "elysia";
|
||||||
import sanitize from "sanitize-filename";
|
import sanitize from "sanitize-filename";
|
||||||
import { outputDir } from "..";
|
import { outputDir } from "..";
|
||||||
|
|
@ -10,7 +11,7 @@ export const download = new Elysia()
|
||||||
.use(userService)
|
.use(userService)
|
||||||
.get(
|
.get(
|
||||||
"/download/:userId/:jobId/:fileName",
|
"/download/:userId/:jobId/:fileName",
|
||||||
async ({ params, redirect, user }) => {
|
async ({ params, redirect, set, user }) => {
|
||||||
const userId = user.id;
|
const userId = user.id;
|
||||||
const job = await db
|
const job = await db
|
||||||
.query("SELECT * FROM jobs WHERE user_id = ? AND id = ?")
|
.query("SELECT * FROM jobs WHERE user_id = ? AND id = ?")
|
||||||
|
|
@ -24,6 +25,14 @@ export const download = new Elysia()
|
||||||
const fileName = sanitize(decodeURIComponent(params.fileName));
|
const fileName = sanitize(decodeURIComponent(params.fileName));
|
||||||
|
|
||||||
const filePath = `${outputDir}${userId}/${jobId}/${fileName}`;
|
const filePath = `${outputDir}${userId}/${jobId}/${fileName}`;
|
||||||
|
|
||||||
|
// 檢查檔案是否存在
|
||||||
|
if (!existsSync(filePath)) {
|
||||||
|
console.error(`[Download] File not found: ${filePath}`);
|
||||||
|
set.status = 404;
|
||||||
|
return { error: "File not found", path: filePath };
|
||||||
|
}
|
||||||
|
|
||||||
return Bun.file(filePath);
|
return Bun.file(filePath);
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -32,7 +41,7 @@ export const download = new Elysia()
|
||||||
)
|
)
|
||||||
.get(
|
.get(
|
||||||
"/archive/:jobId",
|
"/archive/:jobId",
|
||||||
async ({ params, redirect, user }) => {
|
async ({ params, redirect, set, user }) => {
|
||||||
const userId = user.id;
|
const userId = user.id;
|
||||||
const job = await db
|
const job = await db
|
||||||
.query("SELECT * FROM jobs WHERE user_id = ? AND id = ?")
|
.query("SELECT * FROM jobs WHERE user_id = ? AND id = ?")
|
||||||
|
|
@ -45,9 +54,23 @@ export const download = new Elysia()
|
||||||
const jobId = decodeURIComponent(params.jobId);
|
const jobId = decodeURIComponent(params.jobId);
|
||||||
const outputPath = `${outputDir}${userId}/${jobId}`;
|
const outputPath = `${outputDir}${userId}/${jobId}`;
|
||||||
|
|
||||||
|
// 檢查輸出目錄是否存在
|
||||||
|
if (!existsSync(outputPath)) {
|
||||||
|
console.error(`[Archive] Output directory not found: ${outputPath}`);
|
||||||
|
set.status = 404;
|
||||||
|
return { error: "Output directory not found", path: outputPath };
|
||||||
|
}
|
||||||
|
|
||||||
// 使用統一的封裝管理器建立 .tar(不壓縮)
|
// 使用統一的封裝管理器建立 .tar(不壓縮)
|
||||||
const outputTar = await createJobArchive(outputPath, jobId);
|
const outputTar = await createJobArchive(outputPath, jobId);
|
||||||
|
|
||||||
|
// 檢查 archive 是否成功建立
|
||||||
|
if (!existsSync(outputTar)) {
|
||||||
|
console.error(`[Archive] Failed to create archive: ${outputTar}`);
|
||||||
|
set.status = 500;
|
||||||
|
return { error: "Failed to create archive", path: outputTar };
|
||||||
|
}
|
||||||
|
|
||||||
return Bun.file(outputTar);
|
return Bun.file(outputTar);
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -97,13 +97,17 @@ function ResultsArticle({
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{files.map((file) => (
|
{files.map((file) => {
|
||||||
|
const isTarFile = file.output_file_name.endsWith(".tar");
|
||||||
|
return (
|
||||||
<tr>
|
<tr>
|
||||||
<td safe class="max-w-[20vw] truncate">
|
<td safe class="max-w-[20vw] truncate">
|
||||||
{file.output_file_name}
|
{file.output_file_name}
|
||||||
</td>
|
</td>
|
||||||
<td safe>{file.status}</td>
|
<td safe>{file.status}</td>
|
||||||
<td class="flex flex-row gap-4">
|
<td class="flex flex-row gap-4">
|
||||||
|
{/* Hide preview icon for .tar files */}
|
||||||
|
{!isTarFile && (
|
||||||
<a
|
<a
|
||||||
class={`
|
class={`
|
||||||
text-accent-500 underline
|
text-accent-500 underline
|
||||||
|
|
@ -113,6 +117,7 @@ function ResultsArticle({
|
||||||
>
|
>
|
||||||
<EyeIcon />
|
<EyeIcon />
|
||||||
</a>
|
</a>
|
||||||
|
)}
|
||||||
<a
|
<a
|
||||||
class={`
|
class={`
|
||||||
text-accent-500 underline
|
text-accent-500 underline
|
||||||
|
|
@ -125,7 +130,8 @@ function ResultsArticle({
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</article>
|
</article>
|
||||||
|
|
|
||||||
|
|
@ -255,7 +255,7 @@ describe("PDFMathTranslate converter - Output structure", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should create archive with original.pdf and translated-<lang>.pdf", async () => {
|
test("should create archive with translated-<lang>.pdf and bilingual-<lang>.pdf (no original)", async () => {
|
||||||
let tarSourceDir = "";
|
let tarSourceDir = "";
|
||||||
let archiveContents: string[] = [];
|
let archiveContents: string[] = [];
|
||||||
|
|
||||||
|
|
@ -271,7 +271,9 @@ describe("PDFMathTranslate converter - Output structure", () => {
|
||||||
if (!existsSync(outputDir)) {
|
if (!existsSync(outputDir)) {
|
||||||
mkdirSync(outputDir, { recursive: true });
|
mkdirSync(outputDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
// 模擬 pdf2zh 產生 mono(翻譯版)和 dual(對照版)
|
||||||
writeFileSync(join(outputDir, "input-mono.pdf"), "%PDF-1.4\n%Translated");
|
writeFileSync(join(outputDir, "input-mono.pdf"), "%PDF-1.4\n%Translated");
|
||||||
|
writeFileSync(join(outputDir, "input-dual.pdf"), "%PDF-1.4\n%Bilingual");
|
||||||
}
|
}
|
||||||
callback(null, "Translation complete", "");
|
callback(null, "Translation complete", "");
|
||||||
} else if (cmd === "tar") {
|
} else if (cmd === "tar") {
|
||||||
|
|
@ -291,9 +293,11 @@ describe("PDFMathTranslate converter - Output structure", () => {
|
||||||
const targetPath = join(testDir, "output.tar");
|
const targetPath = join(testDir, "output.tar");
|
||||||
await convert(testInputFile, "pdf", "pdf-ko", targetPath, undefined, mockExecFile);
|
await convert(testInputFile, "pdf", "pdf-ko", targetPath, undefined, mockExecFile);
|
||||||
|
|
||||||
// Verify archive contains expected files
|
// Verify archive contains expected files (翻譯版 + 對照版,不包含原始檔)
|
||||||
expect(archiveContents).toContain("original.pdf");
|
|
||||||
expect(archiveContents).toContain("translated-ko.pdf");
|
expect(archiveContents).toContain("translated-ko.pdf");
|
||||||
|
expect(archiveContents).toContain("bilingual-ko.pdf");
|
||||||
|
// 不應包含原始檔案
|
||||||
|
expect(archiveContents).not.toContain("original.pdf");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should only use .tar format, not .tar.gz", async () => {
|
test("should only use .tar format, not .tar.gz", async () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue