# LabelNova Browser Submission Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Build a `LabelNova`-specific browser-driven submission path that reuses cached login state, uploads the `arm64` and `loong64` Debian packages through the real UnionTech portal flow, and records sanitized network traces for later API alignment.

**Architecture:** Add a small browser automation layer beside the existing `appstore` uploader instead of replacing it. Separate session caching, browser bootstrap, trace recording, and the `LabelNova` page flow into focused modules, then expose a dedicated CLI entry for the live `LabelNova` submission path.

**Tech Stack:** Python 3.12, `pyppeteer`, existing `appstore` package modules, `unittest`, JSON artifact files, filesystem-backed session cache.

---

## File Structure

- Create: `appstore/session_state.py`
  - Persist and restore cookies, storage snapshots, user agent, and verification metadata.
- Create: `appstore/browser_runtime.py`
  - Launch browser instances, restore cached state, verify auth, and refresh session cache.
- Create: `appstore/network_trace.py`
  - Capture and redact browser request and response traffic into structured debug artifacts.
- Create: `appstore/labelnova_browser_submit.py`
  - CLI entry point and the `LabelNova`-specific orchestration flow.
- Create: `appstore/tests/test_session_state.py`
  - Unit tests for cache serialization and invalidation behavior.
- Create: `appstore/tests/test_browser_runtime.py`
  - Unit tests for session verification and cache fallback logic.
- Create: `appstore/tests/test_network_trace.py`
  - Unit tests for trace filtering and redaction.
- Create: `appstore/tests/test_labelnova_browser_submit.py`
  - Unit tests for flow helper selection logic, artifact paths, and request filtering.
- Modify: `appstore/README.md`
  - Add usage docs for the `LabelNova` browser submission path and cached sessions.
- Modify: `appstore/requirements.txt`
  - Add any missing runtime dependency needed by the browser flow if not already present.

## Task 1: Add Session Cache Primitives

**Files:**
- Create: `appstore/session_state.py`
- Test: `appstore/tests/test_session_state.py`

- [ ] **Step 1: Write the failing tests**

```python
import json
import tempfile
import unittest
from pathlib import Path

from appstore.session_state import BrowserSessionState, SessionStateStore


class SessionStateStoreTests(unittest.TestCase):
    def test_save_and_load_round_trip(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            store = SessionStateStore(Path(tmp))
            state = BrowserSessionState(
                account="odatacc",
                cookies=[{"name": "sid", "value": "cookie"}],
                local_storage={"token": "abc"},
                session_storage={"refresh": "xyz"},
                user_agent="Mozilla/5.0",
                last_verified_at="2026-04-22T16:30:00+08:00",
            )

            store.save(state)
            loaded = store.load("odatacc")

            self.assertEqual(loaded.account, "odatacc")
            self.assertEqual(loaded.cookies[0]["name"], "sid")
            self.assertEqual(loaded.local_storage["token"], "abc")

    def test_invalidate_removes_state_file(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            store = SessionStateStore(Path(tmp))
            store.save(
                BrowserSessionState(
                    account="odatacc",
                    cookies=[],
                    local_storage={},
                    session_storage={},
                    user_agent="Mozilla/5.0",
                    last_verified_at="2026-04-22T16:30:00+08:00",
                )
            )

            store.invalidate("odatacc")

            self.assertIsNone(store.load("odatacc"))
```

- [ ] **Step 2: Run test to verify it fails**

Run: `python3 -m unittest appstore.tests.test_session_state -v`
Expected: FAIL with `ModuleNotFoundError: No module named 'appstore.session_state'`

- [ ] **Step 3: Write minimal implementation**

```python
from __future__ import annotations

import json
from dataclasses import asdict, dataclass
from pathlib import Path


@dataclass(frozen=True)
class BrowserSessionState:
    account: str
    cookies: list[dict]
    local_storage: dict[str, str]
    session_storage: dict[str, str]
    user_agent: str
    last_verified_at: str


class SessionStateStore:
    def __init__(self, base_dir: Path) -> None:
        self.base_dir = base_dir

    def _path_for(self, account: str) -> Path:
        return self.base_dir / f"{account}.json"

    def load(self, account: str) -> BrowserSessionState | None:
        path = self._path_for(account)
        if not path.exists():
            return None
        payload = json.loads(path.read_text())
        return BrowserSessionState(**payload)

    def save(self, state: BrowserSessionState) -> Path:
        self.base_dir.mkdir(parents=True, exist_ok=True)
        path = self._path_for(state.account)
        path.write_text(json.dumps(asdict(state), ensure_ascii=False, indent=2))
        return path

    def invalidate(self, account: str) -> None:
        path = self._path_for(account)
        if path.exists():
            path.unlink()
```

- [ ] **Step 4: Run test to verify it passes**

Run: `python3 -m unittest appstore.tests.test_session_state -v`
Expected: PASS with `2 tests`

- [ ] **Step 5: Commit**

```bash
git add appstore/session_state.py appstore/tests/test_session_state.py
git commit -m "feat: add browser session state store"
```

## Task 2: Add Browser Runtime With Cache Restore and Auth Verification

**Files:**
- Create: `appstore/browser_runtime.py`
- Test: `appstore/tests/test_browser_runtime.py`

- [ ] **Step 1: Write the failing tests**

```python
import unittest
from unittest.mock import AsyncMock

from appstore.browser_runtime import restore_session_state, session_is_valid
from appstore.session_state import BrowserSessionState


class BrowserRuntimeTests(unittest.IsolatedAsyncioTestCase):
    async def test_restore_session_state_replays_cookies_and_storage(self) -> None:
        page = AsyncMock()
        state = BrowserSessionState(
            account="odatacc",
            cookies=[{"name": "sid", "value": "cookie", "domain": "appstore-dev.uniontech.com", "path": "/"}],
            local_storage={"token": "abc"},
            session_storage={"refresh": "xyz"},
            user_agent="Mozilla/5.0",
            last_verified_at="2026-04-22T16:30:00+08:00",
        )

        await restore_session_state(page, state)

        page.setUserAgent.assert_awaited_once_with("Mozilla/5.0")
        page.setCookie.assert_awaited()
        page.evaluate.assert_awaited()

    async def test_session_is_valid_returns_true_for_logged_in_marker(self) -> None:
        page = AsyncMock()
        page.evaluate.return_value = True

        valid = await session_is_valid(page)

        self.assertTrue(valid)
```

- [ ] **Step 2: Run test to verify it fails**

Run: `python3 -m unittest appstore.tests.test_browser_runtime -v`
Expected: FAIL with `ModuleNotFoundError: No module named 'appstore.browser_runtime'`

- [ ] **Step 3: Write minimal implementation**

```python
from __future__ import annotations

from appstore.session_state import BrowserSessionState


async def restore_session_state(page, state: BrowserSessionState) -> None:
    await page.setUserAgent(state.user_agent)
    if state.cookies:
        await page.setCookie(*state.cookies)
    await page.evaluate(
        """(localState, sessionState) => {
            localStorage.clear();
            sessionStorage.clear();
            for (const [key, value] of Object.entries(localState)) localStorage.setItem(key, value);
            for (const [key, value] of Object.entries(sessionState)) sessionStorage.setItem(key, value);
        }""",
        state.local_storage,
        state.session_storage,
    )


async def session_is_valid(page) -> bool:
    return bool(
        await page.evaluate(
            """() => {
                return Boolean(document.body && document.body.innerText.includes('应用'));
            }"""
        )
    )
```

- [ ] **Step 4: Run test to verify it passes**

Run: `python3 -m unittest appstore.tests.test_browser_runtime -v`
Expected: PASS with `2 tests`

- [ ] **Step 5: Commit**

```bash
git add appstore/browser_runtime.py appstore/tests/test_browser_runtime.py
git commit -m "feat: add browser session bootstrap helpers"
```

## Task 3: Add Sanitized Network Trace Recording

**Files:**
- Create: `appstore/network_trace.py`
- Test: `appstore/tests/test_network_trace.py`

- [ ] **Step 1: Write the failing tests**

```python
import json
import tempfile
import unittest
from pathlib import Path

from appstore.network_trace import NetworkTraceRecorder


class NetworkTraceRecorderTests(unittest.TestCase):
    def test_should_record_filters_relevant_appstore_requests(self) -> None:
        recorder = NetworkTraceRecorder(Path("/tmp/trace"))
        self.assertTrue(recorder.should_record("https://appstore-dev.uniontech.com/devprod-api/store-file/upload/begin"))
        self.assertFalse(recorder.should_record("https://appstore-dev.uniontech.com/healthz"))

    def test_redact_headers_masks_tokens_and_cookies(self) -> None:
        recorder = NetworkTraceRecorder(Path("/tmp/trace"))
        redacted = recorder.redact_headers(
            {"Authorization": "Bearer abc", "Cookie": "sid=1", "Content-Type": "application/json"}
        )
        self.assertEqual(redacted["Authorization"], "<redacted>")
        self.assertEqual(redacted["Cookie"], "<redacted>")
        self.assertEqual(redacted["Content-Type"], "application/json")
```

- [ ] **Step 2: Run test to verify it fails**

Run: `python3 -m unittest appstore.tests.test_network_trace -v`
Expected: FAIL with `ModuleNotFoundError: No module named 'appstore.network_trace'`

- [ ] **Step 3: Write minimal implementation**

```python
from __future__ import annotations

from pathlib import Path


class NetworkTraceRecorder:
    INTERESTING_FRAGMENTS = (
        "/store-file/upload/begin",
        "/store-file/upload/end",
        "/store-dev-app/app",
        "/adapt-info/",
    )

    def __init__(self, output_dir: Path) -> None:
        self.output_dir = output_dir

    def should_record(self, url: str) -> bool:
        return any(fragment in url for fragment in self.INTERESTING_FRAGMENTS)

    def redact_headers(self, headers: dict[str, str]) -> dict[str, str]:
        redacted = {}
        for key, value in headers.items():
            normalized = key.lower()
            if normalized in {"authorization", "cookie", "token"}:
                redacted[key] = "<redacted>"
            else:
                redacted[key] = value
        return redacted
```

- [ ] **Step 4: Run test to verify it passes**

Run: `python3 -m unittest appstore.tests.test_network_trace -v`
Expected: PASS with `2 tests`

- [ ] **Step 5: Commit**

```bash
git add appstore/network_trace.py appstore/tests/test_network_trace.py
git commit -m "feat: add browser network trace recorder"
```

## Task 4: Add LabelNova-Specific Browser Submission Runner

**Files:**
- Create: `appstore/labelnova_browser_submit.py`
- Test: `appstore/tests/test_labelnova_browser_submit.py`
- Modify: `appstore/session_state.py`
- Modify: `appstore/browser_runtime.py`
- Modify: `appstore/network_trace.py`

- [ ] **Step 1: Write the failing tests**

```python
import tempfile
import unittest
from pathlib import Path

from appstore.labelnova_browser_submit import build_labelnova_run_config, artifact_dir_for_run


class LabelNovaBrowserSubmitTests(unittest.TestCase):
    def test_build_labelnova_run_config_uses_expected_fixed_targets(self) -> None:
        config = build_labelnova_run_config(
            arm64_path=Path("/tmp/labelnova_1.0.4-1_arm64.deb"),
            loong64_path=Path("/tmp/labelnova_1.0.4-1_loong64.deb"),
            headless=True,
        )

        self.assertEqual(config.app_id, "1096227")
        self.assertEqual(config.sup_sys_code, "11")
        self.assertEqual(config.baseline_id, "2300")
        self.assertTrue(config.headless)

    def test_artifact_dir_for_run_nests_under_debug_traces(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            output = artifact_dir_for_run(Path(tmp), "2026-04-22T16-40-00")
            self.assertEqual(output, Path(tmp) / "debug-traces" / "2026-04-22T16-40-00")
```

- [ ] **Step 2: Run test to verify it fails**

Run: `python3 -m unittest appstore.tests.test_labelnova_browser_submit -v`
Expected: FAIL with `ModuleNotFoundError: No module named 'appstore.labelnova_browser_submit'`

- [ ] **Step 3: Write minimal implementation**

```python
from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path


@dataclass(frozen=True)
class LabelNovaRunConfig:
    app_id: str
    app_name: str
    pkg_name: str
    arm64_path: Path
    loong64_path: Path
    sup_sys_code: str
    baseline_id: str
    headless: bool


def build_labelnova_run_config(*, arm64_path: Path, loong64_path: Path, headless: bool) -> LabelNovaRunConfig:
    return LabelNovaRunConfig(
        app_id="1096227",
        app_name="LabelNova",
        pkg_name="labelnova",
        arm64_path=arm64_path,
        loong64_path=loong64_path,
        sup_sys_code="11",
        baseline_id="2300",
        headless=headless,
    )


def artifact_dir_for_run(base_dir: Path, timestamp: str) -> Path:
    return base_dir / "debug-traces" / timestamp
```

- [ ] **Step 4: Run test to verify it passes**

Run: `python3 -m unittest appstore.tests.test_labelnova_browser_submit -v`
Expected: PASS with `2 tests`

- [ ] **Step 5: Commit**

```bash
git add appstore/labelnova_browser_submit.py appstore/tests/test_labelnova_browser_submit.py
git commit -m "feat: scaffold labelnova browser submission runner"
```

## Task 5: Implement Live Browser Flow and Cached Login Reuse

**Files:**
- Modify: `appstore/labelnova_browser_submit.py`
- Modify: `appstore/browser_runtime.py`
- Modify: `appstore/network_trace.py`
- Modify: `appstore/session_state.py`
- Test: `appstore/tests/test_browser_runtime.py`
- Test: `appstore/tests/test_network_trace.py`
- Test: `appstore/tests/test_labelnova_browser_submit.py`

- [ ] **Step 1: Write the failing behavior tests for cache reuse and trace output**

```python
import unittest
from unittest.mock import AsyncMock, Mock, patch

from appstore.labelnova_browser_submit import run_labelnova_submission
from appstore.session_state import BrowserSessionState


class LabelNovaFlowBehaviorTests(unittest.IsolatedAsyncioTestCase):
    async def test_run_reuses_cached_session_before_login(self) -> None:
        fake_runtime = Mock()
        fake_runtime.ensure_authenticated = AsyncMock(return_value="reused")
        fake_runtime.open_labelnova_editor = AsyncMock()
        fake_runtime.upload_packages = AsyncMock()
        fake_runtime.configure_targets = AsyncMock()
        fake_runtime.submit = AsyncMock(return_value={"status": "审核中"})

        result = await run_labelnova_submission(fake_runtime)

        self.assertEqual(result["status"], "审核中")
        fake_runtime.ensure_authenticated.assert_awaited_once()

    async def test_run_writes_trace_summary_on_failure(self) -> None:
        fake_runtime = Mock()
        fake_runtime.ensure_authenticated = AsyncMock(return_value="reused")
        fake_runtime.open_labelnova_editor = AsyncMock(side_effect=RuntimeError("selector drift"))
        fake_runtime.finalize_artifacts = Mock()

        with self.assertRaisesRegex(RuntimeError, "selector drift"):
            await run_labelnova_submission(fake_runtime)

        fake_runtime.finalize_artifacts.assert_called_once()
```

- [ ] **Step 2: Run test to verify it fails**

Run: `python3 -m unittest appstore.tests.test_labelnova_browser_submit -v`
Expected: FAIL because `run_labelnova_submission` is not implemented

- [ ] **Step 3: Write minimal implementation**

```python
async def run_labelnova_submission(runtime) -> dict:
    try:
        await runtime.ensure_authenticated()
        await runtime.open_labelnova_editor()
        await runtime.upload_packages()
        await runtime.configure_targets()
        result = await runtime.submit()
        runtime.finalize_artifacts(status="success", result=result)
        return result
    except Exception:
        runtime.finalize_artifacts(status="failed", result=None)
        raise
```

- [ ] **Step 4: Run targeted tests and the full appstore suite**

Run: `python3 -m unittest appstore.tests.test_labelnova_browser_submit appstore.tests.test_browser_runtime appstore.tests.test_network_trace -v`
Expected: PASS with the new browser-flow tests and no regressions in the helper modules

Run: `python3 -m unittest discover appstore/tests -v`
Expected: PASS for the full `appstore` test suite

- [ ] **Step 5: Commit**

```bash
git add appstore/session_state.py appstore/browser_runtime.py appstore/network_trace.py appstore/labelnova_browser_submit.py appstore/tests/test_session_state.py appstore/tests/test_browser_runtime.py appstore/tests/test_network_trace.py appstore/tests/test_labelnova_browser_submit.py
git commit -m "feat: implement labelnova browser submission flow"
```

## Task 6: Document the New Browser Submission Path

**Files:**
- Modify: `appstore/README.md`

- [ ] **Step 1: Write the failing documentation check**

```bash
rg -n "labelnova browser|session-state|debug-traces" appstore/README.md
```

Expected: no matches

- [ ] **Step 2: Update the README**

````markdown
## LabelNova Browser Submission

Use the browser-driven flow when the portal UI must be exercised directly:

```bash
APPSTORE_USERNAME='your-username' APPSTORE_PASSWORD='your-password' \
python3 -m appstore.labelnova_browser_submit \
  --arm64 appstore/examples/packages/labelnova_1.0.4-1_arm64.deb \
  --loong64 appstore/examples/packages/labelnova_1.0.4-1_loong64.deb
```

Session cache is stored under `appstore/cache/session-state/`.
Sanitized debug traces are written under `appstore/output/debug-traces/<timestamp>/`.
````

- [ ] **Step 3: Verify the documentation landed**

Run: `rg -n "LabelNova Browser Submission|session-state|debug-traces" appstore/README.md`
Expected: matches for all three phrases

- [ ] **Step 4: Commit**

```bash
git add appstore/README.md
git commit -m "docs: add labelnova browser submission usage"
```

## Task 7: Run the Live LabelNova Verification Flow

**Files:**
- Use: `appstore/labelnova_browser_submit.py`
- Use: `appstore/cache/session-state/`
- Use: `appstore/output/debug-traces/`

- [ ] **Step 1: Clear any stale failed session only if auth is known bad**

```bash
rm -f appstore/cache/session-state/odatacc.json
```

Expected: command succeeds even if the file does not exist

- [ ] **Step 2: Run the real browser-driven submission**

```bash
python3 -m appstore.labelnova_browser_submit \
  --arm64 WWW/appstore/examples/packages/labelnova_1.0.4-1_arm64.deb \
  --loong64 WWW/appstore/examples/packages/labelnova_1.0.4-1_loong64.deb
```

Expected:
- prompts for credentials only if the session cache is missing or invalid
- uploads two package rows
- writes artifacts under `appstore/output/debug-traces/<timestamp>/`
- reaches a portal state equivalent to `审核中`, or produces a captured final request/response if the platform rejects submission

- [ ] **Step 3: Verify artifacts**

Run:

```bash
find appstore/output/debug-traces -maxdepth 2 -type f | sort | tail -n 20
```

Expected:
- request trace file
- response trace file
- summary file
- screenshots or HTML snapshots

- [ ] **Step 4: Commit**

```bash
git add appstore/cache/session-state/ appstore/output/debug-traces/
git restore --staged appstore/cache/session-state/ appstore/output/debug-traces/
git commit -m "chore: verify labelnova browser submission flow"
```

The runtime artifacts must remain untracked. This commit is only created if implementation code changed during final live verification.

## Self-Review

Spec coverage check:

- session caching: covered by Tasks 1, 2, and 5
- browser-driven real submission: covered by Tasks 4, 5, and 7
- request/response capture: covered by Tasks 3, 5, and 7
- `LabelNova`-specific app flow: covered by Tasks 4, 5, and 7
- documentation and operator usage: covered by Task 6

Placeholder scan:

- no `TODO`, `TBD`, or deferred implementation markers remain
- every code-changing step includes concrete file paths, code, and commands

Type consistency check:

- `BrowserSessionState` and `SessionStateStore` are introduced in Task 1 and reused consistently later
- `run_labelnova_submission()` appears in Task 5 only after being scaffolded in Task 4
- trace paths stay consistent across the spec and plan:
  - `appstore/cache/session-state/`
  - `appstore/output/debug-traces/`
