+3
-1
@@ -1,8 +1,10 @@
|
|||||||
# When .npmignore exists, npm ignores .gitignore for packing.
|
# When .npmignore exists, npm ignores .gitignore for packing.
|
||||||
# Keep packaging exclusions here even if they already appear in .gitignore.
|
# Keep packaging exclusions here even if they already appear in .gitignore.
|
||||||
|
|
||||||
__pycache__/
|
**/__pycache__/
|
||||||
|
**/__pycache__/**
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
**/*.py[cod]
|
||||||
*$py.class
|
*$py.class
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,14 @@
|
|||||||
|
|
||||||
本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
||||||
|
|
||||||
|
## [1.1.2] - 2026-08-06
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- 修复中文 Windows 下 `export_pptx.py` / `export_images.py` 导出卡住或 GBK 解码失败(stdout 改走临时文件 + UTF-8)
|
||||||
|
- 规避 agent-browser `--download-path` 导致 Chrome 静默取消下载:改为点击下载并轮询默认 Downloads
|
||||||
|
- 修复 npm 包误打入 `__pycache__/*.pyc`(`files` 仅列出脚本源文件)
|
||||||
|
|
||||||
## [1.1.1] - 2026-08-06
|
## [1.1.1] - 2026-08-06
|
||||||
|
|
||||||
### 新增
|
### 新增
|
||||||
|
|||||||
@@ -4,6 +4,14 @@
|
|||||||
|
|
||||||
This project follows [Semantic Versioning](https://semver.org/).
|
This project follows [Semantic Versioning](https://semver.org/).
|
||||||
|
|
||||||
|
## [1.1.2] - 2026-08-06
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fix Chinese-locale Windows export hang / GBK decode errors in `export_pptx.py` / `export_images.py` (capture stdout via temp file + UTF-8)
|
||||||
|
- Work around agent-browser `--download-path` silently canceling Chrome downloads: click download and poll the default Downloads folder
|
||||||
|
- Stop shipping `__pycache__/*.pyc` in the npm package (list script sources explicitly in `files`)
|
||||||
|
|
||||||
## [1.1.1] - 2026-08-06
|
## [1.1.1] - 2026-08-06
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+4
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "open-kimi-ppt-skills",
|
"name": "open-kimi-ppt-skills",
|
||||||
"version": "1.1.1",
|
"version": "1.1.2",
|
||||||
"description": "Unofficial reverse-engineered Kimi Slides skill for AI coding agents with local PPTD editing and PPTX export.",
|
"description": "Unofficial reverse-engineered Kimi Slides skill for AI coding agents with local PPTD editing and PPTX export.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "binaryify",
|
"author": "binaryify",
|
||||||
@@ -35,7 +35,9 @@
|
|||||||
"lib/",
|
"lib/",
|
||||||
"skills/open-kimi-ppt/SKILL.md",
|
"skills/open-kimi-ppt/SKILL.md",
|
||||||
"skills/open-kimi-ppt/reference/",
|
"skills/open-kimi-ppt/reference/",
|
||||||
"skills/open-kimi-ppt/scripts/",
|
"skills/open-kimi-ppt/scripts/export_host.html",
|
||||||
|
"skills/open-kimi-ppt/scripts/export_images.py",
|
||||||
|
"skills/open-kimi-ppt/scripts/export_pptx.py",
|
||||||
"README.md",
|
"README.md",
|
||||||
"README_EN.md",
|
"README_EN.md",
|
||||||
"theme.md",
|
"theme.md",
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import re
|
|||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
import zipfile
|
import zipfile
|
||||||
@@ -28,12 +27,15 @@ from export_pptx import (
|
|||||||
BrowserSession,
|
BrowserSession,
|
||||||
ExportError,
|
ExportError,
|
||||||
build_payload,
|
build_payload,
|
||||||
|
default_downloads_dir,
|
||||||
ensure_agent_browser,
|
ensure_agent_browser,
|
||||||
find_download,
|
find_download,
|
||||||
find_manifest,
|
find_manifest,
|
||||||
log,
|
log,
|
||||||
ref_by_name,
|
ref_by_name,
|
||||||
|
run_command,
|
||||||
serve,
|
serve,
|
||||||
|
temporary_directory,
|
||||||
wait_for_export_dialog,
|
wait_for_export_dialog,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -51,11 +53,8 @@ def ensure_pillow() -> Tuple[Any, Any, Any]:
|
|||||||
return Image, ImageDraw, ImageFont
|
return Image, ImageDraw, ImageFont
|
||||||
except ImportError:
|
except ImportError:
|
||||||
log("Pillow is required for stitching; installing pillow with pip --user")
|
log("Pillow is required for stitching; installing pillow with pip --user")
|
||||||
process = subprocess.run(
|
process = run_command(
|
||||||
[sys.executable, "-m", "pip", "install", "--user", "pillow"],
|
[sys.executable, "-m", "pip", "install", "--user", "pillow"],
|
||||||
text=True,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.STDOUT,
|
|
||||||
timeout=300,
|
timeout=300,
|
||||||
)
|
)
|
||||||
if process.returncode != 0:
|
if process.returncode != 0:
|
||||||
@@ -187,11 +186,8 @@ def ensure_websocket() -> Any:
|
|||||||
return websocket
|
return websocket
|
||||||
except ImportError:
|
except ImportError:
|
||||||
log("websocket-client is required for dialog automation; installing with pip --user")
|
log("websocket-client is required for dialog automation; installing with pip --user")
|
||||||
process = subprocess.run(
|
process = run_command(
|
||||||
[sys.executable, "-m", "pip", "install", "--user", "websocket-client"],
|
[sys.executable, "-m", "pip", "install", "--user", "websocket-client"],
|
||||||
text=True,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.STDOUT,
|
|
||||||
timeout=300,
|
timeout=300,
|
||||||
)
|
)
|
||||||
if process.returncode != 0:
|
if process.returncode != 0:
|
||||||
@@ -307,7 +303,7 @@ def export_images(
|
|||||||
image_cls, draw_cls, image_font = ensure_pillow()
|
image_cls, draw_cls, image_font = ensure_pillow()
|
||||||
|
|
||||||
log(f"manifest: {manifest}")
|
log(f"manifest: {manifest}")
|
||||||
with tempfile.TemporaryDirectory(prefix="open-kimi-ppt-images-") as temp_name:
|
with temporary_directory(prefix="open-kimi-ppt-images-") as temp_name:
|
||||||
temp_dir = Path(temp_name)
|
temp_dir = Path(temp_name)
|
||||||
download_dir = temp_dir / "downloads"
|
download_dir = temp_dir / "downloads"
|
||||||
download_dir.mkdir()
|
download_dir.mkdir()
|
||||||
@@ -318,6 +314,7 @@ def export_images(
|
|||||||
server, thread, url = serve(temp_dir)
|
server, thread, url = serve(temp_dir)
|
||||||
session = f"open-kimi-ppt-images-{os.getpid()}-{uuid.uuid4().hex[:8]}"
|
session = f"open-kimi-ppt-images-{os.getpid()}-{uuid.uuid4().hex[:8]}"
|
||||||
browser = BrowserSession(agent_browser, session, temp_dir, download_dir)
|
browser = BrowserSession(agent_browser, session, temp_dir, download_dir)
|
||||||
|
downloads = default_downloads_dir()
|
||||||
try:
|
try:
|
||||||
log("opening the public Kimi slide editor")
|
log("opening the public Kimi slide editor")
|
||||||
browser.open(url)
|
browser.open(url)
|
||||||
@@ -338,17 +335,15 @@ def export_images(
|
|||||||
select_image_format(browser)
|
select_image_format(browser)
|
||||||
dialog = wait_for_export_dialog(browser)
|
dialog = wait_for_export_dialog(browser)
|
||||||
|
|
||||||
|
started_at = time.time() - 1.0
|
||||||
download_ref = ref_by_name(dialog, "下载", "button")
|
download_ref = ref_by_name(dialog, "下载", "button")
|
||||||
log("rendering page images in the browser")
|
log("rendering page images in the browser")
|
||||||
result = browser.run(
|
browser.run(["click", f"@{download_ref}"], timeout=300)
|
||||||
["download", f"@{download_ref}", str(temp_dir / "browser-output.zip")],
|
|
||||||
timeout=300,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
log("download capture reported a timeout; checking browser output files")
|
|
||||||
downloaded = find_download(
|
downloaded = find_download(
|
||||||
(download_dir, temp_dir), timeout=240, accept=is_image_zip
|
(downloads, download_dir, temp_dir),
|
||||||
|
timeout=240,
|
||||||
|
accept=is_image_zip,
|
||||||
|
since=started_at,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
browser.close()
|
browser.close()
|
||||||
@@ -362,6 +357,11 @@ def export_images(
|
|||||||
images = unzip_images(downloaded, output / "pages")
|
images = unzip_images(downloaded, output / "pages")
|
||||||
if keep_download:
|
if keep_download:
|
||||||
shutil.copy2(downloaded, output / "browser-raw.zip")
|
shutil.copy2(downloaded, output / "browser-raw.zip")
|
||||||
|
try:
|
||||||
|
if downloaded.resolve().parent == downloads.resolve():
|
||||||
|
downloaded.unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
overview = stitch_overview(
|
overview = stitch_overview(
|
||||||
images, output / "overview.jpg", image_cls, draw_cls, image_font
|
images, output / "overview.jpg", image_cls, draw_cls, image_font
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -63,16 +63,83 @@ def log(message: str) -> None:
|
|||||||
print(f"[open-kimi-ppt] {message}", file=sys.stderr, flush=True)
|
print(f"[open-kimi-ppt] {message}", file=sys.stderr, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def run_command(
|
||||||
|
command: Sequence[str],
|
||||||
|
*,
|
||||||
|
cwd: Optional[Path] = None,
|
||||||
|
env: Optional[Dict[str, str]] = None,
|
||||||
|
timeout: int = 90,
|
||||||
|
) -> subprocess.CompletedProcess[str]:
|
||||||
|
"""Capture merged stdout/stderr via a temp file.
|
||||||
|
|
||||||
|
On Windows, agent-browser's detached daemon can inherit a PIPE handle and
|
||||||
|
prevent EOF, deadlocking ``subprocess.run(stdout=PIPE)``. Decoding with the
|
||||||
|
system locale (GBK on zh-CN Windows) can also raise UnicodeDecodeError.
|
||||||
|
Writing to a UTF-8 file avoids both failures.
|
||||||
|
"""
|
||||||
|
handle, sink_path = tempfile.mkstemp(prefix="open-kimi-ppt-", suffix=".log")
|
||||||
|
os.close(handle)
|
||||||
|
sink = Path(sink_path)
|
||||||
|
output = ""
|
||||||
|
try:
|
||||||
|
with sink.open("w", encoding="utf-8", errors="replace") as out:
|
||||||
|
returncode = subprocess.call(
|
||||||
|
list(command),
|
||||||
|
cwd=str(cwd) if cwd is not None else None,
|
||||||
|
env=env,
|
||||||
|
stdout=out,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
output = sink.read_text(encoding="utf-8", errors="replace")
|
||||||
|
except subprocess.TimeoutExpired as exc:
|
||||||
|
try:
|
||||||
|
output = sink.read_text(encoding="utf-8", errors="replace")
|
||||||
|
except OSError:
|
||||||
|
output = ""
|
||||||
|
raise subprocess.TimeoutExpired(
|
||||||
|
cmd=list(command),
|
||||||
|
timeout=timeout,
|
||||||
|
output=output,
|
||||||
|
) from exc
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
sink.unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
# WinError 32: daemon may still hold the log file handle.
|
||||||
|
pass
|
||||||
|
return subprocess.CompletedProcess(list(command), returncode, output, None)
|
||||||
|
|
||||||
|
|
||||||
|
def temporary_directory(prefix: str) -> Any:
|
||||||
|
# ignore_cleanup_errors avoids masking the real export error when a Windows
|
||||||
|
# browser daemon still holds files under the temp tree (Python 3.10+).
|
||||||
|
try:
|
||||||
|
return tempfile.TemporaryDirectory(prefix=prefix, ignore_cleanup_errors=True)
|
||||||
|
except TypeError:
|
||||||
|
return tempfile.TemporaryDirectory(prefix=prefix)
|
||||||
|
|
||||||
|
|
||||||
|
def default_downloads_dir() -> Path:
|
||||||
|
home = Path.home()
|
||||||
|
candidates: List[Path] = []
|
||||||
|
user_profile = os.environ.get("USERPROFILE")
|
||||||
|
if user_profile:
|
||||||
|
candidates.append(Path(user_profile) / "Downloads")
|
||||||
|
candidates.extend((home / "Downloads", home / "下载"))
|
||||||
|
for path in candidates:
|
||||||
|
if path.is_dir():
|
||||||
|
return path
|
||||||
|
return home / "Downloads"
|
||||||
|
|
||||||
|
|
||||||
def ensure_pyyaml() -> Any:
|
def ensure_pyyaml() -> Any:
|
||||||
try:
|
try:
|
||||||
import yaml
|
import yaml
|
||||||
except ImportError:
|
except ImportError:
|
||||||
log("PyYAML is required; installing pyyaml with pip --user")
|
log("PyYAML is required; installing pyyaml with pip --user")
|
||||||
process = subprocess.run(
|
process = run_command(
|
||||||
[sys.executable, "-m", "pip", "install", "--user", "pyyaml"],
|
[sys.executable, "-m", "pip", "install", "--user", "pyyaml"],
|
||||||
text=True,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.STDOUT,
|
|
||||||
timeout=300,
|
timeout=300,
|
||||||
)
|
)
|
||||||
if process.returncode != 0:
|
if process.returncode != 0:
|
||||||
@@ -103,13 +170,7 @@ def parse_node_version(output: str) -> Tuple[int, int, int]:
|
|||||||
|
|
||||||
|
|
||||||
def read_agent_browser_version(executable: str) -> Tuple[int, int, int]:
|
def read_agent_browser_version(executable: str) -> Tuple[int, int, int]:
|
||||||
process = subprocess.run(
|
process = run_command([executable, "--version"], timeout=30)
|
||||||
[executable, "--version"],
|
|
||||||
text=True,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.STDOUT,
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
if process.returncode != 0:
|
if process.returncode != 0:
|
||||||
raise ExportError(f"agent-browser --version failed:\n{process.stdout[-2000:]}")
|
raise ExportError(f"agent-browser --version failed:\n{process.stdout[-2000:]}")
|
||||||
return parse_version(process.stdout)
|
return parse_version(process.stdout)
|
||||||
@@ -120,13 +181,7 @@ def ensure_nodejs() -> str:
|
|||||||
if not executable:
|
if not executable:
|
||||||
raise ExportError(f"Node.js is not installed or not on PATH. {NODE_INSTALL_HINT}")
|
raise ExportError(f"Node.js is not installed or not on PATH. {NODE_INSTALL_HINT}")
|
||||||
|
|
||||||
process = subprocess.run(
|
process = run_command([executable, "--version"], timeout=30)
|
||||||
[executable, "--version"],
|
|
||||||
text=True,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.STDOUT,
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
if process.returncode != 0:
|
if process.returncode != 0:
|
||||||
raise ExportError(f"node --version failed:\n{process.stdout[-2000:]}")
|
raise ExportError(f"node --version failed:\n{process.stdout[-2000:]}")
|
||||||
|
|
||||||
@@ -168,11 +223,8 @@ def ensure_agent_browser() -> str:
|
|||||||
current = "not installed" if version is None else ".".join(map(str, version))
|
current = "not installed" if version is None else ".".join(map(str, version))
|
||||||
minimum = ".".join(map(str, MIN_AGENT_BROWSER_VERSION))
|
minimum = ".".join(map(str, MIN_AGENT_BROWSER_VERSION))
|
||||||
log(f"agent-browser {current} is below {minimum}; installing agent-browser@latest")
|
log(f"agent-browser {current} is below {minimum}; installing agent-browser@latest")
|
||||||
process = subprocess.run(
|
process = run_command(
|
||||||
[npm, "install", "-g", "agent-browser@latest"],
|
[npm, "install", "-g", "agent-browser@latest"],
|
||||||
text=True,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.STDOUT,
|
|
||||||
timeout=300,
|
timeout=300,
|
||||||
)
|
)
|
||||||
if process.returncode != 0:
|
if process.returncode != 0:
|
||||||
@@ -304,6 +356,8 @@ class BrowserSession:
|
|||||||
self.executable = executable
|
self.executable = executable
|
||||||
self.session = session
|
self.session = session
|
||||||
self.cwd = cwd
|
self.cwd = cwd
|
||||||
|
# Kept as a search root fallback; not passed to agent-browser. On Windows,
|
||||||
|
# --download-path can be rewritten to a \\?\ path that cancels Chrome downloads.
|
||||||
self.download_dir = download_dir
|
self.download_dir = download_dir
|
||||||
self.env = os.environ.copy()
|
self.env = os.environ.copy()
|
||||||
self.env.setdefault("AGENT_BROWSER_DEFAULT_TIMEOUT", "60000")
|
self.env.setdefault("AGENT_BROWSER_DEFAULT_TIMEOUT", "60000")
|
||||||
@@ -317,15 +371,7 @@ class BrowserSession:
|
|||||||
check: bool = True,
|
check: bool = True,
|
||||||
) -> subprocess.CompletedProcess[str]:
|
) -> subprocess.CompletedProcess[str]:
|
||||||
command = [self.executable, "--session", self.session, *args]
|
command = [self.executable, "--session", self.session, *args]
|
||||||
process = subprocess.run(
|
process = run_command(command, cwd=self.cwd, env=self.env, timeout=timeout)
|
||||||
command,
|
|
||||||
cwd=self.cwd,
|
|
||||||
env=self.env,
|
|
||||||
text=True,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.STDOUT,
|
|
||||||
timeout=timeout,
|
|
||||||
)
|
|
||||||
if check and process.returncode != 0:
|
if check and process.returncode != 0:
|
||||||
raise ExportError(
|
raise ExportError(
|
||||||
f"agent-browser command failed ({process.returncode}): "
|
f"agent-browser command failed ({process.returncode}): "
|
||||||
@@ -334,9 +380,9 @@ class BrowserSession:
|
|||||||
return process
|
return process
|
||||||
|
|
||||||
def open(self, url: str) -> None:
|
def open(self, url: str) -> None:
|
||||||
self.run(
|
# Avoid --download-path: agent-browser ≤0.33.2 + Chrome may cancel downloads
|
||||||
["--download-path", str(self.download_dir), "open", url], timeout=90
|
# when given a verbatim Windows path. Files land in the default Downloads folder.
|
||||||
)
|
self.run(["open", url], timeout=90)
|
||||||
|
|
||||||
def snapshot(self) -> Dict[str, Any]:
|
def snapshot(self) -> Dict[str, Any]:
|
||||||
process = self.run(["snapshot", "-i", "-C", "--json"])
|
process = self.run(["snapshot", "-i", "-C", "--json"])
|
||||||
@@ -408,6 +454,8 @@ def find_download(
|
|||||||
search_roots: Iterable[Path],
|
search_roots: Iterable[Path],
|
||||||
timeout: float = 150.0,
|
timeout: float = 150.0,
|
||||||
accept: Callable[[Path], bool] = is_pptx,
|
accept: Callable[[Path], bool] = is_pptx,
|
||||||
|
*,
|
||||||
|
since: Optional[float] = None,
|
||||||
) -> Path:
|
) -> Path:
|
||||||
deadline = time.monotonic() + timeout
|
deadline = time.monotonic() + timeout
|
||||||
last_sizes: Dict[Path, int] = {}
|
last_sizes: Dict[Path, int] = {}
|
||||||
@@ -420,7 +468,10 @@ def find_download(
|
|||||||
candidates.extend(path for path in root.rglob("*") if path.is_file())
|
candidates.extend(path for path in root.rglob("*") if path.is_file())
|
||||||
for path in sorted(candidates, key=lambda item: item.stat().st_mtime, reverse=True):
|
for path in sorted(candidates, key=lambda item: item.stat().st_mtime, reverse=True):
|
||||||
try:
|
try:
|
||||||
size = path.stat().st_size
|
stat = path.stat()
|
||||||
|
size = stat.st_size
|
||||||
|
if since is not None and stat.st_mtime < since:
|
||||||
|
continue
|
||||||
except OSError:
|
except OSError:
|
||||||
continue
|
continue
|
||||||
if size == last_sizes.get(path) and size > 0:
|
if size == last_sizes.get(path) and size > 0:
|
||||||
@@ -588,7 +639,7 @@ def export_pptx(
|
|||||||
f"defaults: transition={transition}, embed_fonts={'on' if embed_fonts else 'off'}"
|
f"defaults: transition={transition}, embed_fonts={'on' if embed_fonts else 'off'}"
|
||||||
)
|
)
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory(prefix="open-kimi-ppt-export-") as temp_name:
|
with temporary_directory(prefix="open-kimi-ppt-export-") as temp_name:
|
||||||
temp_dir = Path(temp_name)
|
temp_dir = Path(temp_name)
|
||||||
download_dir = temp_dir / "downloads"
|
download_dir = temp_dir / "downloads"
|
||||||
download_dir.mkdir()
|
download_dir.mkdir()
|
||||||
@@ -599,6 +650,7 @@ def export_pptx(
|
|||||||
server, thread, url = serve(temp_dir)
|
server, thread, url = serve(temp_dir)
|
||||||
session = f"open-kimi-ppt-export-{os.getpid()}-{uuid.uuid4().hex[:8]}"
|
session = f"open-kimi-ppt-export-{os.getpid()}-{uuid.uuid4().hex[:8]}"
|
||||||
browser = BrowserSession(agent_browser, session, temp_dir, download_dir)
|
browser = BrowserSession(agent_browser, session, temp_dir, download_dir)
|
||||||
|
downloads = default_downloads_dir()
|
||||||
try:
|
try:
|
||||||
log("opening the public Kimi slide editor")
|
log("opening the public Kimi slide editor")
|
||||||
browser.open(url)
|
browser.open(url)
|
||||||
@@ -627,16 +679,17 @@ def export_pptx(
|
|||||||
elif embed_fonts:
|
elif embed_fonts:
|
||||||
log("warning: the official export dialog exposed no font switch")
|
log("warning: the official export dialog exposed no font switch")
|
||||||
|
|
||||||
|
# Plain click (not agent-browser `download`) so Chrome saves to the
|
||||||
|
# default Downloads folder; --download-path is broken on some Windows setups.
|
||||||
|
started_at = time.time() - 1.0
|
||||||
download_ref = ref_by_name(dialog, "下载", "button")
|
download_ref = ref_by_name(dialog, "下载", "button")
|
||||||
log("generating PPTX in the browser")
|
log("generating PPTX in the browser")
|
||||||
result = browser.run(
|
browser.run(["click", f"@{download_ref}"], timeout=180)
|
||||||
["download", f"@{download_ref}", str(temp_dir / "browser-output.pptx")],
|
downloaded = find_download(
|
||||||
timeout=180,
|
(downloads, download_dir, temp_dir),
|
||||||
check=False,
|
timeout=90,
|
||||||
|
since=started_at,
|
||||||
)
|
)
|
||||||
if result.returncode != 0:
|
|
||||||
log("download capture reported a timeout; checking browser output files")
|
|
||||||
downloaded = find_download((download_dir, temp_dir), timeout=90)
|
|
||||||
shutil.copy2(downloaded, output)
|
shutil.copy2(downloaded, output)
|
||||||
if keep_download:
|
if keep_download:
|
||||||
debug_copy = output.with_name(f"{output.stem}.browser-raw.pptx")
|
debug_copy = output.with_name(f"{output.stem}.browser-raw.pptx")
|
||||||
@@ -645,6 +698,11 @@ def export_pptx(
|
|||||||
f"raw debug output already exists (pass --force): {debug_copy}"
|
f"raw debug output already exists (pass --force): {debug_copy}"
|
||||||
)
|
)
|
||||||
shutil.copy2(downloaded, debug_copy)
|
shutil.copy2(downloaded, debug_copy)
|
||||||
|
try:
|
||||||
|
if downloaded.resolve().parent == downloads.resolve():
|
||||||
|
downloaded.unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
finally:
|
finally:
|
||||||
browser.close()
|
browser.close()
|
||||||
server.shutdown()
|
server.shutdown()
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import importlib.util
|
import importlib.util
|
||||||
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -19,9 +21,9 @@ class ExportPptxTests(unittest.TestCase):
|
|||||||
self.assertEqual(MODULE.parse_version("agent-browser 0.33.2"), (0, 33, 2))
|
self.assertEqual(MODULE.parse_version("agent-browser 0.33.2"), (0, 33, 2))
|
||||||
self.assertEqual(MODULE.parse_version("v1.4.0-beta.1"), (1, 4, 0))
|
self.assertEqual(MODULE.parse_version("v1.4.0-beta.1"), (1, 4, 0))
|
||||||
|
|
||||||
@patch.object(MODULE.subprocess, "run")
|
@patch.object(MODULE, "run_command")
|
||||||
@patch.object(MODULE.shutil, "which")
|
@patch.object(MODULE.shutil, "which")
|
||||||
def test_old_agent_browser_is_upgraded(self, which, run):
|
def test_old_agent_browser_is_upgraded(self, which, run_command):
|
||||||
which.side_effect = [
|
which.side_effect = [
|
||||||
"/bin/node",
|
"/bin/node",
|
||||||
"/bin/npm",
|
"/bin/npm",
|
||||||
@@ -29,38 +31,38 @@ class ExportPptxTests(unittest.TestCase):
|
|||||||
"/bin/npm",
|
"/bin/npm",
|
||||||
"/bin/agent-browser",
|
"/bin/agent-browser",
|
||||||
]
|
]
|
||||||
run.side_effect = [
|
run_command.side_effect = [
|
||||||
MODULE.subprocess.CompletedProcess([], 0, "v22.11.0\n"),
|
MODULE.subprocess.CompletedProcess([], 0, "v22.11.0\n"),
|
||||||
MODULE.subprocess.CompletedProcess([], 0, "agent-browser 0.17.1\n"),
|
MODULE.subprocess.CompletedProcess([], 0, "agent-browser 0.17.1\n"),
|
||||||
MODULE.subprocess.CompletedProcess([], 0, "changed 1 package\n"),
|
MODULE.subprocess.CompletedProcess([], 0, "changed 1 package\n"),
|
||||||
MODULE.subprocess.CompletedProcess([], 0, "agent-browser 0.33.2\n"),
|
MODULE.subprocess.CompletedProcess([], 0, "agent-browser 0.33.2\n"),
|
||||||
]
|
]
|
||||||
self.assertEqual(MODULE.ensure_agent_browser(), "/bin/agent-browser")
|
self.assertEqual(MODULE.ensure_agent_browser(), "/bin/agent-browser")
|
||||||
self.assertEqual(run.call_args_list[2].args[0], [
|
self.assertEqual(run_command.call_args_list[2].args[0], [
|
||||||
"/bin/npm", "install", "-g", "agent-browser@latest"
|
"/bin/npm", "install", "-g", "agent-browser@latest"
|
||||||
])
|
])
|
||||||
|
|
||||||
@patch.object(MODULE.subprocess, "run")
|
@patch.object(MODULE, "run_command")
|
||||||
@patch.object(MODULE.shutil, "which")
|
@patch.object(MODULE.shutil, "which")
|
||||||
def test_missing_nodejs_raises_clear_error(self, which, run):
|
def test_missing_nodejs_raises_clear_error(self, which, run_command):
|
||||||
which.return_value = None
|
which.return_value = None
|
||||||
with self.assertRaisesRegex(MODULE.ExportError, "Node.js is not installed"):
|
with self.assertRaisesRegex(MODULE.ExportError, "Node.js is not installed"):
|
||||||
MODULE.ensure_nodejs()
|
MODULE.ensure_nodejs()
|
||||||
run.assert_not_called()
|
run_command.assert_not_called()
|
||||||
|
|
||||||
@patch.object(MODULE.subprocess, "run")
|
@patch.object(MODULE, "run_command")
|
||||||
@patch.object(MODULE.shutil, "which")
|
@patch.object(MODULE.shutil, "which")
|
||||||
def test_old_nodejs_raises_clear_error(self, which, run):
|
def test_old_nodejs_raises_clear_error(self, which, run_command):
|
||||||
which.return_value = "/bin/node"
|
which.return_value = "/bin/node"
|
||||||
run.return_value = MODULE.subprocess.CompletedProcess([], 0, "v16.20.2\n")
|
run_command.return_value = MODULE.subprocess.CompletedProcess([], 0, "v16.20.2\n")
|
||||||
with self.assertRaisesRegex(MODULE.ExportError, "Node.js 18\\+ is required"):
|
with self.assertRaisesRegex(MODULE.ExportError, "Node.js 18\\+ is required"):
|
||||||
MODULE.ensure_nodejs()
|
MODULE.ensure_nodejs()
|
||||||
|
|
||||||
@patch.object(MODULE.subprocess, "run")
|
@patch.object(MODULE, "run_command")
|
||||||
@patch.object(MODULE.shutil, "which")
|
@patch.object(MODULE.shutil, "which")
|
||||||
def test_missing_npm_raises_clear_error(self, which, run):
|
def test_missing_npm_raises_clear_error(self, which, run_command):
|
||||||
which.side_effect = ["/bin/node", None]
|
which.side_effect = ["/bin/node", None]
|
||||||
run.return_value = MODULE.subprocess.CompletedProcess([], 0, "v22.11.0\n")
|
run_command.return_value = MODULE.subprocess.CompletedProcess([], 0, "v22.11.0\n")
|
||||||
with self.assertRaisesRegex(MODULE.ExportError, "npm is not installed"):
|
with self.assertRaisesRegex(MODULE.ExportError, "npm is not installed"):
|
||||||
MODULE.ensure_nodejs()
|
MODULE.ensure_nodejs()
|
||||||
|
|
||||||
@@ -128,6 +130,53 @@ class ExportPptxTests(unittest.TestCase):
|
|||||||
slide = archive.read("ppt/slides/slide1.xml")
|
slide = archive.read("ppt/slides/slide1.xml")
|
||||||
self.assertIn(b"<p:fade/>", slide)
|
self.assertIn(b"<p:fade/>", slide)
|
||||||
|
|
||||||
|
@patch.object(MODULE.subprocess, "call", return_value=0)
|
||||||
|
def test_run_command_captures_utf8_via_temp_file(self, call):
|
||||||
|
def write_sink(*_args, **kwargs):
|
||||||
|
kwargs["stdout"].write("agent-browser 0.33.2\n")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
call.side_effect = write_sink
|
||||||
|
process = MODULE.run_command(["agent-browser", "--version"], timeout=5)
|
||||||
|
self.assertEqual(process.returncode, 0)
|
||||||
|
self.assertIn("0.33.2", process.stdout)
|
||||||
|
self.assertEqual(call.call_args.kwargs["stderr"], MODULE.subprocess.STDOUT)
|
||||||
|
|
||||||
|
def test_find_download_ignores_files_older_than_since(self):
|
||||||
|
with tempfile.TemporaryDirectory() as name:
|
||||||
|
root = Path(name)
|
||||||
|
old = root / "old.pptx"
|
||||||
|
new = root / "new.pptx"
|
||||||
|
for path in (old, new):
|
||||||
|
with zipfile.ZipFile(path, "w") as archive:
|
||||||
|
archive.writestr(
|
||||||
|
"[Content_Types].xml",
|
||||||
|
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
||||||
|
'<Override PartName="/ppt/presentation.xml" '
|
||||||
|
f'ContentType="{MODULE.PPTX_CONTENT_TYPE}"/></Types>',
|
||||||
|
)
|
||||||
|
archive.writestr("ppt/presentation.xml", "<p:presentation/>")
|
||||||
|
|
||||||
|
older = time.time() - 60
|
||||||
|
os.utime(old, (older, older))
|
||||||
|
since = time.time() - 5
|
||||||
|
found = MODULE.find_download([root], timeout=2.0, since=since)
|
||||||
|
self.assertEqual(found.resolve(), new.resolve())
|
||||||
|
|
||||||
|
def test_browser_open_does_not_pass_download_path(self):
|
||||||
|
session = MODULE.BrowserSession(
|
||||||
|
"/bin/agent-browser",
|
||||||
|
"test-session",
|
||||||
|
Path("."),
|
||||||
|
Path("/tmp/downloads"),
|
||||||
|
)
|
||||||
|
with patch.object(session, "run") as run:
|
||||||
|
session.open("http://127.0.0.1:9/export_host.html")
|
||||||
|
run.assert_called_once_with(
|
||||||
|
["open", "http://127.0.0.1:9/export_host.html"],
|
||||||
|
timeout=90,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user