cli-web-hackernews 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cli_web/hackernews/README.md +91 -0
- cli_web/hackernews/__init__.py +0 -0
- cli_web/hackernews/__main__.py +6 -0
- cli_web/hackernews/commands/__init__.py +0 -0
- cli_web/hackernews/commands/actions.py +105 -0
- cli_web/hackernews/commands/auth.py +80 -0
- cli_web/hackernews/commands/search.py +69 -0
- cli_web/hackernews/commands/stories.py +160 -0
- cli_web/hackernews/commands/user.py +112 -0
- cli_web/hackernews/core/__init__.py +0 -0
- cli_web/hackernews/core/auth.py +290 -0
- cli_web/hackernews/core/client.py +517 -0
- cli_web/hackernews/core/exceptions.py +63 -0
- cli_web/hackernews/core/models.py +144 -0
- cli_web/hackernews/hackernews_cli.py +171 -0
- cli_web/hackernews/tests/TEST.md +143 -0
- cli_web/hackernews/tests/__init__.py +0 -0
- cli_web/hackernews/tests/test_core.py +365 -0
- cli_web/hackernews/tests/test_e2e.py +267 -0
- cli_web/hackernews/utils/__init__.py +0 -0
- cli_web/hackernews/utils/doctor.py +188 -0
- cli_web/hackernews/utils/helpers.py +73 -0
- cli_web/hackernews/utils/mcp_server.py +290 -0
- cli_web/hackernews/utils/output.py +136 -0
- cli_web/hackernews/utils/repl_skin.py +486 -0
- cli_web_hackernews-0.1.0.dist-info/METADATA +12 -0
- cli_web_hackernews-0.1.0.dist-info/RECORD +30 -0
- cli_web_hackernews-0.1.0.dist-info/WHEEL +5 -0
- cli_web_hackernews-0.1.0.dist-info/entry_points.txt +2 -0
- cli_web_hackernews-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Data models for Hacker News CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import asdict, dataclass, field
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Story:
|
|
14
|
+
"""A Hacker News story."""
|
|
15
|
+
|
|
16
|
+
id: int
|
|
17
|
+
title: str
|
|
18
|
+
url: str | None = None
|
|
19
|
+
score: int = 0
|
|
20
|
+
by: str = ""
|
|
21
|
+
time: int = 0
|
|
22
|
+
descendants: int = 0
|
|
23
|
+
type: str = "story"
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def age(self) -> str:
|
|
27
|
+
"""Human-readable age like '2h ago'."""
|
|
28
|
+
if not self.time:
|
|
29
|
+
return ""
|
|
30
|
+
delta = int(time.time()) - self.time
|
|
31
|
+
if delta < 60:
|
|
32
|
+
return f"{delta}s ago"
|
|
33
|
+
if delta < 3600:
|
|
34
|
+
return f"{delta // 60}m ago"
|
|
35
|
+
if delta < 86400:
|
|
36
|
+
return f"{delta // 3600}h ago"
|
|
37
|
+
return f"{delta // 86400}d ago"
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def domain(self) -> str:
|
|
41
|
+
"""Extract domain from URL."""
|
|
42
|
+
if not self.url:
|
|
43
|
+
return ""
|
|
44
|
+
match = re.match(r"https?://(?:www\.)?([^/]+)", self.url)
|
|
45
|
+
return match.group(1) if match else ""
|
|
46
|
+
|
|
47
|
+
def to_dict(self) -> dict:
|
|
48
|
+
d = asdict(self)
|
|
49
|
+
d["age"] = self.age
|
|
50
|
+
d["domain"] = self.domain
|
|
51
|
+
return d
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class Comment:
|
|
56
|
+
"""A Hacker News comment."""
|
|
57
|
+
|
|
58
|
+
id: int
|
|
59
|
+
by: str = ""
|
|
60
|
+
text: str = ""
|
|
61
|
+
time: int = 0
|
|
62
|
+
parent: int = 0
|
|
63
|
+
kids: list[int] = field(default_factory=list)
|
|
64
|
+
dead: bool = False
|
|
65
|
+
deleted: bool = False
|
|
66
|
+
type: str = "comment"
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def text_plain(self) -> str:
|
|
70
|
+
"""Strip HTML tags from comment text."""
|
|
71
|
+
if not self.text:
|
|
72
|
+
return ""
|
|
73
|
+
text = re.sub(r"<[^>]+>", "", self.text)
|
|
74
|
+
return html.unescape(text)
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def age(self) -> str:
|
|
78
|
+
if not self.time:
|
|
79
|
+
return ""
|
|
80
|
+
delta = int(time.time()) - self.time
|
|
81
|
+
if delta < 60:
|
|
82
|
+
return f"{delta}s ago"
|
|
83
|
+
if delta < 3600:
|
|
84
|
+
return f"{delta // 60}m ago"
|
|
85
|
+
if delta < 86400:
|
|
86
|
+
return f"{delta // 3600}h ago"
|
|
87
|
+
return f"{delta // 86400}d ago"
|
|
88
|
+
|
|
89
|
+
def to_dict(self) -> dict:
|
|
90
|
+
d = asdict(self)
|
|
91
|
+
d["text_plain"] = self.text_plain
|
|
92
|
+
d["age"] = self.age
|
|
93
|
+
return d
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class User:
|
|
98
|
+
"""A Hacker News user profile."""
|
|
99
|
+
|
|
100
|
+
id: str
|
|
101
|
+
karma: int = 0
|
|
102
|
+
created: int = 0
|
|
103
|
+
about: str = ""
|
|
104
|
+
submitted: list[int] = field(default_factory=list)
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def about_plain(self) -> str:
|
|
108
|
+
if not self.about:
|
|
109
|
+
return ""
|
|
110
|
+
text = re.sub(r"<[^>]+>", "", self.about)
|
|
111
|
+
return html.unescape(text)
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def member_since(self) -> str:
|
|
115
|
+
if not self.created:
|
|
116
|
+
return ""
|
|
117
|
+
dt = datetime.fromtimestamp(self.created, tz=timezone.utc)
|
|
118
|
+
return dt.strftime("%Y-%m-%d")
|
|
119
|
+
|
|
120
|
+
def to_dict(self) -> dict:
|
|
121
|
+
d = asdict(self)
|
|
122
|
+
d["about_plain"] = self.about_plain
|
|
123
|
+
d["member_since"] = self.member_since
|
|
124
|
+
# Trim submitted to first 20 for readability
|
|
125
|
+
d["submitted"] = self.submitted[:20]
|
|
126
|
+
d["total_submissions"] = len(self.submitted)
|
|
127
|
+
return d
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class SearchResult:
|
|
132
|
+
"""A search result from HN Algolia API."""
|
|
133
|
+
|
|
134
|
+
objectID: str
|
|
135
|
+
title: str
|
|
136
|
+
url: str | None = None
|
|
137
|
+
author: str = ""
|
|
138
|
+
points: int | None = None
|
|
139
|
+
num_comments: int | None = None
|
|
140
|
+
created_at: str = ""
|
|
141
|
+
story_id: int | None = None
|
|
142
|
+
|
|
143
|
+
def to_dict(self) -> dict:
|
|
144
|
+
return asdict(self)
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""cli-web-hackernews — CLI entry point for Hacker News."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
if sys.stdout.encoding and sys.stdout.encoding.lower() not in ("utf-8", "utf8"):
|
|
8
|
+
try:
|
|
9
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
10
|
+
except AttributeError:
|
|
11
|
+
pass
|
|
12
|
+
if sys.stderr.encoding and sys.stderr.encoding.lower() not in ("utf-8", "utf8"):
|
|
13
|
+
try:
|
|
14
|
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
15
|
+
except AttributeError:
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
import shlex
|
|
19
|
+
|
|
20
|
+
import click
|
|
21
|
+
from cli_web.hackernews.commands.actions import (
|
|
22
|
+
comment_cmd,
|
|
23
|
+
favorite_cmd,
|
|
24
|
+
hide_cmd,
|
|
25
|
+
submit_cmd,
|
|
26
|
+
upvote_cmd,
|
|
27
|
+
)
|
|
28
|
+
from cli_web.hackernews.commands.auth import auth_group
|
|
29
|
+
from cli_web.hackernews.commands.search import search_group
|
|
30
|
+
from cli_web.hackernews.commands.stories import stories_group
|
|
31
|
+
from cli_web.hackernews.commands.user import user_group
|
|
32
|
+
from cli_web.hackernews.core.exceptions import AppError
|
|
33
|
+
from cli_web.hackernews.utils.repl_skin import ReplSkin
|
|
34
|
+
|
|
35
|
+
_skin = ReplSkin(app="hackernews", version="0.2.0", display_name="Hacker News")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------- main CLI
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@click.group(invoke_without_command=True)
|
|
42
|
+
@click.option("--json", "json_mode", is_flag=True, help="Output as JSON (applies to all commands).")
|
|
43
|
+
@click.version_option("0.2.0", prog_name="cli-web-hackernews")
|
|
44
|
+
@click.pass_context
|
|
45
|
+
def cli(ctx, json_mode):
|
|
46
|
+
"""cli-web-hackernews — Browse and interact with Hacker News from the command line.
|
|
47
|
+
|
|
48
|
+
Run without arguments to enter interactive REPL mode.
|
|
49
|
+
"""
|
|
50
|
+
ctx.ensure_object(dict)
|
|
51
|
+
ctx.obj["json"] = json_mode
|
|
52
|
+
|
|
53
|
+
if ctx.invoked_subcommand is None:
|
|
54
|
+
_run_repl(ctx)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
cli.add_command(stories_group)
|
|
58
|
+
cli.add_command(search_group)
|
|
59
|
+
cli.add_command(user_group)
|
|
60
|
+
cli.add_command(auth_group)
|
|
61
|
+
cli.add_command(upvote_cmd)
|
|
62
|
+
cli.add_command(submit_cmd)
|
|
63
|
+
cli.add_command(comment_cmd)
|
|
64
|
+
cli.add_command(favorite_cmd)
|
|
65
|
+
cli.add_command(hide_cmd)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------------------- REPL
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _print_repl_help() -> None:
|
|
72
|
+
_skin.info("Available commands:")
|
|
73
|
+
print()
|
|
74
|
+
print(" stories top [OPTIONS] Top stories (front page)")
|
|
75
|
+
print(" stories new [OPTIONS] Newest stories")
|
|
76
|
+
print(" stories best [OPTIONS] Best stories (all time)")
|
|
77
|
+
print(" stories ask [OPTIONS] Ask HN stories")
|
|
78
|
+
print(" stories show [OPTIONS] Show HN stories")
|
|
79
|
+
print(" stories jobs [OPTIONS] Job listings")
|
|
80
|
+
print(" stories view ID [OPTIONS] View story + comments")
|
|
81
|
+
print(" -n, --limit N Number of items (default 30/10)")
|
|
82
|
+
print(" --json Output as JSON")
|
|
83
|
+
print()
|
|
84
|
+
print(" search stories QUERY Search stories by keyword")
|
|
85
|
+
print(" search comments QUERY Search comments by keyword")
|
|
86
|
+
print(" --sort-date Sort by date instead of relevance")
|
|
87
|
+
print(" -n, --limit N Number of results (default 20)")
|
|
88
|
+
print(" --json Output as JSON")
|
|
89
|
+
print()
|
|
90
|
+
print(" user view USERNAME View user profile")
|
|
91
|
+
print(" user favorites [USERNAME] View favorite stories (auth)")
|
|
92
|
+
print(" user submissions [USERNAME] View submitted stories (auth)")
|
|
93
|
+
print(" user threads [USERNAME] View replies to comments (auth)")
|
|
94
|
+
print(" -n, --limit N Number of items (default 30)")
|
|
95
|
+
print(" --json Output as JSON")
|
|
96
|
+
print()
|
|
97
|
+
print(" upvote ID Upvote a story or comment (auth)")
|
|
98
|
+
print(" submit -t TITLE [-u URL] Submit a new story (auth)")
|
|
99
|
+
print(" comment PARENT_ID TEXT Post a comment or reply (auth)")
|
|
100
|
+
print(" favorite ID Favorite/save a story (auth)")
|
|
101
|
+
print(" hide ID Hide a story from feed (auth)")
|
|
102
|
+
print(" --json Output as JSON")
|
|
103
|
+
print()
|
|
104
|
+
print(" auth login Login with username/password")
|
|
105
|
+
print(" auth login-browser Login via browser window")
|
|
106
|
+
print(" auth status Check login status")
|
|
107
|
+
print(" auth logout Remove credentials")
|
|
108
|
+
print()
|
|
109
|
+
print(" help Show this help")
|
|
110
|
+
print(" exit / quit / Ctrl-D Exit REPL")
|
|
111
|
+
print()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _run_repl(ctx: click.Context) -> None:
|
|
115
|
+
_skin.print_banner()
|
|
116
|
+
_print_repl_help()
|
|
117
|
+
|
|
118
|
+
pt_session = _skin.create_prompt_session()
|
|
119
|
+
|
|
120
|
+
while True:
|
|
121
|
+
try:
|
|
122
|
+
line = _skin.get_input(pt_session)
|
|
123
|
+
except (EOFError, KeyboardInterrupt):
|
|
124
|
+
_skin.print_goodbye()
|
|
125
|
+
break
|
|
126
|
+
|
|
127
|
+
line = line.strip()
|
|
128
|
+
if not line:
|
|
129
|
+
continue
|
|
130
|
+
if line.lower() in ("exit", "quit", "q"):
|
|
131
|
+
_skin.print_goodbye()
|
|
132
|
+
break
|
|
133
|
+
if line.lower() in ("help", "?", "h"):
|
|
134
|
+
_print_repl_help()
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
args = shlex.split(line)
|
|
139
|
+
except ValueError as exc:
|
|
140
|
+
_skin.error(f"Parse error: {exc}")
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
# Preserve --json flag from context
|
|
144
|
+
if ctx.obj.get("json"):
|
|
145
|
+
args = ["--json"] + args
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
cli.main(args=args, standalone_mode=False)
|
|
149
|
+
except SystemExit:
|
|
150
|
+
pass
|
|
151
|
+
except AppError as exc:
|
|
152
|
+
_skin.error(exc.message)
|
|
153
|
+
except Exception as exc:
|
|
154
|
+
_skin.error(str(exc))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def main():
|
|
158
|
+
cli()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# MCP server mode — exposes every command as an MCP tool over stdio.
|
|
162
|
+
# Canonical adapter: cli-web-core/cli_web_core/mcp_server.py (vendored copy).
|
|
163
|
+
from cli_web.hackernews.utils.doctor import register_doctor_command # noqa: E402
|
|
164
|
+
from cli_web.hackernews.utils.mcp_server import register_mcp_command # noqa: E402
|
|
165
|
+
|
|
166
|
+
register_mcp_command(cli, app_name="hackernews", version="0.1.0")
|
|
167
|
+
register_doctor_command(cli, app_name="hackernews", pkg="hackernews")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
if __name__ == "__main__":
|
|
171
|
+
main()
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# TEST.md — cli-web-hackernews Test Plan & Results
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
## Part 1: Test Plan
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
### Test Inventory
|
|
8
|
+
|
|
9
|
+
| File | Tests | Layer |
|
|
10
|
+
|------|-------|-------|
|
|
11
|
+
| test_core.py | 31 | Unit (mocked) |
|
|
12
|
+
| test_e2e.py | 30 | E2E (live) + Subprocess |
|
|
13
|
+
|
|
14
|
+
**Total: 61 tests**
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### test_core.py
|
|
18
|
+
|
|
19
|
+
**TestStoryModel** (4 tests) — Unit (mocked)
|
|
20
|
+
|
|
21
|
+
- `test_to_dict_includes_computed` — to dict includes computed
|
|
22
|
+
- `test_domain_extraction` — domain extraction
|
|
23
|
+
- `test_domain_empty_when_no_url` — domain empty when no url
|
|
24
|
+
- `test_age_empty_when_no_time` — age empty when no time
|
|
25
|
+
|
|
26
|
+
**TestCommentModel** (3 tests) — Unit (mocked)
|
|
27
|
+
|
|
28
|
+
- `test_text_plain_strips_html` — text plain strips html
|
|
29
|
+
- `test_text_plain_unescapes_entities` — text plain unescapes entities
|
|
30
|
+
- `test_text_plain_empty` — text plain empty
|
|
31
|
+
|
|
32
|
+
**TestUserModel** (2 tests) — Unit (mocked)
|
|
33
|
+
|
|
34
|
+
- `test_to_dict_trims_submitted` — to dict trims submitted
|
|
35
|
+
- `test_about_plain` — about plain
|
|
36
|
+
|
|
37
|
+
**TestSearchResultModel** (1 tests) — Unit (mocked)
|
|
38
|
+
|
|
39
|
+
- `test_to_dict` — to dict
|
|
40
|
+
|
|
41
|
+
**TestClientHTTPErrors** (5 tests) — Unit (mocked)
|
|
42
|
+
|
|
43
|
+
- `test_rate_limit_raises` — rate limit raises
|
|
44
|
+
- `test_server_error_raises` — server error raises
|
|
45
|
+
- `test_404_raises_not_found` — 404 raises not found
|
|
46
|
+
- `test_network_error_raises` — network error raises
|
|
47
|
+
- `test_timeout_raises_network_error` — timeout raises network error
|
|
48
|
+
|
|
49
|
+
**TestClientParsing** (4 tests) — Unit (mocked)
|
|
50
|
+
|
|
51
|
+
- `test_get_story_builds_model` — get story builds model
|
|
52
|
+
- `test_get_user_builds_model` — get user builds model
|
|
53
|
+
- `test_get_user_not_found` — get user not found
|
|
54
|
+
- `test_search_builds_results` — search builds results
|
|
55
|
+
|
|
56
|
+
**TestExceptionsToDicts** (6 tests) — Unit (mocked)
|
|
57
|
+
|
|
58
|
+
- `test_app_error_to_dict` — app error to dict
|
|
59
|
+
- `test_rate_limit_error_to_dict` — rate limit error to dict
|
|
60
|
+
- `test_server_error_to_dict` — server error to dict
|
|
61
|
+
- `test_not_found_to_dict` — not found to dict
|
|
62
|
+
- `test_auth_error_to_dict` — auth error to dict
|
|
63
|
+
- `test_auth_error_recoverable` — auth error recoverable
|
|
64
|
+
|
|
65
|
+
**TestAuthModule** (6 tests) — Unit (mocked)
|
|
66
|
+
|
|
67
|
+
- `test_require_auth_raises_without_cookie` — require auth raises without cookie
|
|
68
|
+
- `test_require_auth_returns_cookie` — require auth returns cookie
|
|
69
|
+
- `test_extract_auth_token_from_html` — extract auth token from html
|
|
70
|
+
- `test_extract_auth_token_missing_raises` — extract auth token missing raises
|
|
71
|
+
- `test_parse_stories_from_html_extracts_ids` — parse stories from html extracts ids
|
|
72
|
+
- `test_authenticated_get_html_403_raises_auth_error` — authenticated get html 403 raises auth error
|
|
73
|
+
|
|
74
|
+
### test_e2e.py
|
|
75
|
+
|
|
76
|
+
**TestStoriesFeedE2E** (8 tests) — E2E (live)
|
|
77
|
+
|
|
78
|
+
- `test_top_stories_returns_list` — top stories returns list
|
|
79
|
+
- `test_new_stories_returns_list` — new stories returns list
|
|
80
|
+
- `test_best_stories_returns_list` — best stories returns list
|
|
81
|
+
- `test_ask_stories_returns_list` — ask stories returns list
|
|
82
|
+
- `test_show_stories_returns_list` — show stories returns list
|
|
83
|
+
- `test_job_stories_returns_list` — job stories returns list
|
|
84
|
+
- `test_story_has_required_fields` — story has required fields
|
|
85
|
+
- `test_story_ids_returns_ints` — story ids returns ints
|
|
86
|
+
|
|
87
|
+
**TestStoryViewE2E** (2 tests) — E2E (live)
|
|
88
|
+
|
|
89
|
+
- `test_get_story_by_id` — get story by id
|
|
90
|
+
- `test_get_comments_for_story` — get comments for story
|
|
91
|
+
|
|
92
|
+
**TestUserE2E** (2 tests) — E2E (live)
|
|
93
|
+
|
|
94
|
+
- `test_get_user_profile` — get user profile
|
|
95
|
+
- `test_get_nonexistent_user_raises` — get nonexistent user raises
|
|
96
|
+
|
|
97
|
+
**TestSearchE2E** (3 tests) — E2E (live)
|
|
98
|
+
|
|
99
|
+
- `test_search_stories` — search stories
|
|
100
|
+
- `test_search_by_date` — search by date
|
|
101
|
+
- `test_search_comments` — search comments
|
|
102
|
+
|
|
103
|
+
**TestSubprocess** (11 tests) — Subprocess
|
|
104
|
+
|
|
105
|
+
- `test_version` — version
|
|
106
|
+
- `test_help` — help
|
|
107
|
+
- `test_stories_top_json` — stories top json
|
|
108
|
+
- `test_search_stories_json` — search stories json
|
|
109
|
+
- `test_user_view_json` — user view json
|
|
110
|
+
- `test_stories_view_json` — stories view json
|
|
111
|
+
- `test_auth_status_json` — auth status json
|
|
112
|
+
- `test_auth_help` — auth help
|
|
113
|
+
- `test_upvote_help` — upvote help
|
|
114
|
+
- `test_submit_help` — submit help
|
|
115
|
+
- `test_comment_help` — comment help
|
|
116
|
+
|
|
117
|
+
**TestAuthActionsE2E** (4 tests) — E2E (live)
|
|
118
|
+
|
|
119
|
+
- `test_upvote_story` — upvote story
|
|
120
|
+
- `test_get_submissions` — get submissions
|
|
121
|
+
- `test_favorite_and_list` — favorite and list
|
|
122
|
+
- `test_auth_validate` — auth validate
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## Part 2: Test Results
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
============================= 31 passed in 0.32s ==============================
|
|
130
|
+
(test_core.py — all unit tests pass)
|
|
131
|
+
|
|
132
|
+
============================= 30 passed in 159.47s ============================
|
|
133
|
+
(test_e2e.py — all E2E + subprocess + auth tests pass)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
**Pass Rate: 61/61 (100%)**
|
|
137
|
+
|
|
138
|
+
### Notes
|
|
139
|
+
- E2E tests hit live Firebase API, Algolia search API, and HN web endpoints
|
|
140
|
+
- Subprocess tests use `_resolve_cli()` with `CLI_WEB_FORCE_INSTALLED=1`
|
|
141
|
+
- Auth E2E tests require valid `auth.json` (fail, not skip, without it)
|
|
142
|
+
- Auth actions (upvote, favorite) scrape CSRF tokens from page HTML
|
|
143
|
+
- Parallel HTTP fetching via asyncio in client
|
|
File without changes
|