Add error context files for failed Playwright tests in gallery.spec.ts

- Created error context files for multiple failed tests related to gallery functionality.
- Each file includes instructions, test info, and detailed error logs to assist in debugging.
- Tests covered include:
  - AI batch analysis using SSE without status polling
  - Thumbnails ready state handling
  - Batch upload cancellation and result retention
  - Selection of filtered results through a batch token
  - Handling legacy responses without filenames
  - Metadata retention without reverse prompt action
This commit is contained in:
Z1rconium committed 2026-08-15 13:45:58 +08:00
1 parent 959a97da20
commit ff91463e31
74 files changed
+3964 -534

No files matched your search

@@ -5,10 +5,13 @@ from .client import (
NodeImageAuthError,
NodeImageConfigurationError,
NodeImageEffectiveSettings,
NodeImageTransientError,
NodeImageUploadError,
NodeImageUploadResult,
resolve_nodeimage_settings,
upload_image_bytes,
upload_image_file,
upload_image_source,
)
__all__ = [
@@ -16,8 +19,11 @@ __all__ = [
"NodeImageAuthError",
"NodeImageConfigurationError",
"NodeImageEffectiveSettings",
"NodeImageTransientError",
"NodeImageUploadError",
"NodeImageUploadResult",
"resolve_nodeimage_settings",
"upload_image_bytes",
"upload_image_file",
"upload_image_source",
]
+157 -87
View File
@@ -4,27 +4,30 @@ from __future__ import annotations
import asyncio
import json
import mimetypes
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit
from urllib.parse import quote, urlsplit
import aiohttp
from ...core import secrets
from ...core.redaction import redact_sensitive_text
from ...core.validators import get_env_var_ref_name, resolve_env_var_ref
from ...repositories.image_files import image_content_type_for_filename
from ..session_pool import TIMEOUT_NODEIMAGE, get_pool
NODEIMAGE_API_URL = "https://api.nodeimage.com"
NODEIMAGE_UPLOAD_URL = f"{NODEIMAGE_API_URL}/api/upload"
NODEIMAGE_HOST_ALLOWLIST = "api.nodeimage.com"
MAX_NODEIMAGE_RESPONSE_BYTES = 2 * 1024 * 1024
NODEIMAGE_MAX_ATTEMPTS = 2
NODEIMAGE_RETRY_BACKOFF_SECONDS = 0.2
RETRYABLE_CONNECT_ERRORS = (
aiohttp.ClientConnectorError,
aiohttp.ClientProxyConnectionError,
)
NodeImageFileSource = Path | str | Callable[[], Any]
class NodeImageConfigurationError(ValueError):
@@ -39,6 +42,14 @@ class NodeImageUploadError(RuntimeError):
pass
class NodeImageTransientError(NodeImageUploadError):
"""An upload failure that is safe to retry before a response is complete."""
class _NodeImageResponseReadError(NodeImageUploadError):
pass
@dataclass(frozen=True)
class NodeImageEffectiveSettings:
enabled: bool
@@ -52,28 +63,16 @@ class NodeImageUploadResult:
def _resolve_secret(value: Any) -> str:
raw = str(value or "").strip()
if not raw:
return ""
env_var = get_env_var_ref_name(raw)
if env_var:
resolved = resolve_env_var_ref(raw)
if resolved:
return resolved
raise NodeImageConfigurationError(
f"NodeImage API key environment variable {env_var} is not set or empty."
)
if raw not in secrets.configured_secret_ids():
return raw
try:
return secrets.resolve_secret(
raw,
return secrets.resolve_secret_reference(
value,
purpose="nodeimage_api_key",
target_url=NODEIMAGE_API_URL,
host_allowlist=NODEIMAGE_HOST_ALLOWLIST,
field_name="NodeImage API key",
)
except secrets.SecretRegistryError as exc:
raise NodeImageConfigurationError(f"NodeImage API key: {exc}") from exc
raise NodeImageConfigurationError(str(exc)) from exc
def resolve_nodeimage_settings(
@@ -107,14 +106,18 @@ async def _read_response_json(response: aiohttp.ClientResponse) -> dict[str, Any
chunks.append(chunk)
except NodeImageUploadError:
raise
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
raise NodeImageUploadError("NodeImage returned an unreadable response.") from exc
except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as exc:
raise _NodeImageResponseReadError(
"NodeImage returned an unreadable response."
) from exc
try:
parsed = json.loads(b"".join(chunks).decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise NodeImageUploadError("NodeImage returned an invalid JSON response.") from exc
raise _NodeImageResponseReadError(
"NodeImage returned an invalid JSON response."
) from exc
if not isinstance(parsed, dict):
raise NodeImageUploadError("NodeImage returned an invalid response.")
raise _NodeImageResponseReadError("NodeImage returned an invalid response.")
return parsed
@@ -143,79 +146,146 @@ def _validate_direct_url(value: Any) -> str:
return direct
async def upload_image_bytes(
image_bytes: bytes,
def _markdown_escape_alt(value: str) -> str:
# Preserve ordinary filename punctuation while escaping inline constructs.
escaped = str(value or "image.png").replace("\\", "\\\\")
for character in "`*_[]()<>":
escaped = escaped.replace(character, f"\\{character}")
return escaped
def _markdown_for_direct_url(direct: str, filename: str) -> str:
safe_filename = Path(str(filename or "image.png")).name or "image.png"
alt = _markdown_escape_alt(safe_filename)
encoded_url = quote(direct, safe=":/?#[]@!$&'*+,;=%-._~")
return f"![{alt}]({encoded_url})"
def _open_file_source(source: NodeImageFileSource):
if isinstance(source, (str, Path)):
return Path(source).open("rb")
handle = source()
if not hasattr(handle, "read") or not hasattr(handle, "close"):
raise NodeImageUploadError("NodeImage file source was not readable.")
return handle
async def _upload_image_source_once(
image_source: bytes | NodeImageFileSource,
safe_filename: str,
effective: NodeImageEffectiveSettings,
) -> NodeImageUploadResult:
file_handle = None
if not isinstance(image_source, bytes):
try:
file_handle = _open_file_source(image_source)
except OSError:
raise
except NodeImageUploadError:
raise
except Exception as exc:
raise NodeImageUploadError("Image file could not be read") from exc
try:
form_source = image_source if isinstance(image_source, bytes) else file_handle
form = aiohttp.FormData()
form.add_field(
"image",
form_source,
filename=safe_filename,
content_type=image_content_type_for_filename(safe_filename),
)
session = get_pool().get(timeout_kind=TIMEOUT_NODEIMAGE)
async with session.post(
NODEIMAGE_UPLOAD_URL,
data=form,
headers={"X-API-Key": effective.api_key},
allow_redirects=False,
) as response:
if response.status in {401, 403}:
raise NodeImageAuthError("NodeImage API key was rejected.")
try:
payload = await _read_response_json(response)
except _NodeImageResponseReadError as exc:
if response.status >= 500:
raise NodeImageTransientError(str(exc)) from exc
raise
message = _error_text(payload, response.status, effective.api_key)
if response.status >= 500:
raise NodeImageTransientError(message)
if _is_auth_error(message):
raise NodeImageAuthError("NodeImage API key was rejected.")
if response.status >= 400 or payload.get("success") is False:
raise NodeImageUploadError(message)
links = payload.get("links")
if not isinstance(links, dict):
raise NodeImageUploadError(
"NodeImage response did not include upload links."
)
direct = _validate_direct_url(links.get("direct"))
return NodeImageUploadResult(
url=direct,
markdown=_markdown_for_direct_url(direct, safe_filename),
)
except NodeImageAuthError:
raise
except NodeImageTransientError:
raise
except NodeImageUploadError:
raise
except RETRYABLE_CONNECT_ERRORS as exc:
raise NodeImageTransientError("NodeImage upload request failed.") from exc
except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as exc:
raise NodeImageUploadError("NodeImage upload request failed.") from exc
finally:
if file_handle is not None:
file_handle.close()
async def upload_image_source(
image_source: bytes | NodeImageFileSource,
filename: str,
effective: NodeImageEffectiveSettings,
) -> NodeImageUploadResult:
if not effective.enabled:
raise NodeImageConfigurationError("NodeImage upload is disabled.")
if not image_bytes:
if isinstance(image_source, bytes) and not image_source:
raise NodeImageUploadError("Image file is empty.")
if (
isinstance(image_source, (str, Path))
and Path(image_source).stat().st_size <= 0
):
raise NodeImageUploadError("Image file is empty.")
safe_filename = Path(str(filename or "image.png")).name or "image.png"
content_type = mimetypes.guess_type(safe_filename)[0] or "application/octet-stream"
headers = {"X-API-Key": effective.api_key}
for attempt in range(2):
form = aiohttp.FormData()
form.add_field(
"image",
image_bytes,
filename=safe_filename,
content_type=content_type,
)
attempt = 1
while True:
try:
session = get_pool().get(timeout_kind=TIMEOUT_NODEIMAGE)
async with session.post(
NODEIMAGE_UPLOAD_URL,
data=form,
headers=headers,
allow_redirects=False,
) as response:
if response.status in {401, 403}:
raise NodeImageAuthError("NodeImage API key was rejected.")
try:
payload = await _read_response_json(response)
except NodeImageUploadError as exc:
if response.status >= 500 and attempt == 0:
await asyncio.sleep(0.2)
continue
raise
return await _upload_image_source_once(
image_source,
safe_filename,
effective,
)
except NodeImageTransientError:
if attempt >= NODEIMAGE_MAX_ATTEMPTS:
raise
attempt += 1
await asyncio.sleep(NODEIMAGE_RETRY_BACKOFF_SECONDS)
message = _error_text(payload, response.status, effective.api_key)
if response.status >= 500:
if attempt == 0:
await asyncio.sleep(0.2)
continue
raise NodeImageUploadError(message)
if _is_auth_error(message):
raise NodeImageAuthError("NodeImage API key was rejected.")
if response.status >= 400 or payload.get("success") is False:
raise NodeImageUploadError(message)
links = payload.get("links")
if not isinstance(links, dict):
raise NodeImageUploadError(
"NodeImage response did not include upload links."
)
direct = _validate_direct_url(links.get("direct"))
markdown = str(links.get("markdown") or "").strip()
if not markdown:
raise NodeImageUploadError(
"NodeImage response did not include upload links."
)
return NodeImageUploadResult(url=direct, markdown=markdown)
except NodeImageAuthError:
raise
except NodeImageUploadError:
raise
except RETRYABLE_CONNECT_ERRORS as exc:
if attempt == 0:
await asyncio.sleep(0.2)
continue
raise NodeImageUploadError("NodeImage upload request failed.") from exc
except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as exc:
raise NodeImageUploadError("NodeImage upload request failed.") from exc
async def upload_image_file(
path: Path | str,
filename: str,
effective: NodeImageEffectiveSettings,
) -> NodeImageUploadResult:
return await upload_image_source(path, filename, effective)
raise NodeImageUploadError("NodeImage upload request failed.")
async def upload_image_bytes(
image_bytes: bytes,
filename: str,
effective: NodeImageEffectiveSettings,
) -> NodeImageUploadResult:
return await upload_image_source(image_bytes, filename, effective)