podojo-cli 1.26.0__tar.gz → 1.27.0__tar.gz
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.
- {podojo_cli-1.26.0 → podojo_cli-1.27.0}/CHANGELOG.md +18 -0
- {podojo_cli-1.26.0 → podojo_cli-1.27.0}/CLAUDE.md +1 -1
- {podojo_cli-1.26.0 → podojo_cli-1.27.0}/PKG-INFO +1 -1
- {podojo_cli-1.26.0 → podojo_cli-1.27.0}/pyproject.toml +1 -1
- {podojo_cli-1.26.0 → podojo_cli-1.27.0}/src/podojo_cli/commands/usertests.py +36 -2
- {podojo_cli-1.26.0 → podojo_cli-1.27.0}/tests/test_usertests.py +100 -0
- {podojo_cli-1.26.0 → podojo_cli-1.27.0}/uv.lock +4 -4
- {podojo_cli-1.26.0 → podojo_cli-1.27.0}/.github/workflows/publish.yml +0 -0
- {podojo_cli-1.26.0 → podojo_cli-1.27.0}/.gitignore +0 -0
- {podojo_cli-1.26.0 → podojo_cli-1.27.0}/LICENSE +0 -0
- {podojo_cli-1.26.0 → podojo_cli-1.27.0}/README.md +0 -0
- {podojo_cli-1.26.0 → podojo_cli-1.27.0}/src/podojo_cli/__init__.py +0 -0
- {podojo_cli-1.26.0 → podojo_cli-1.27.0}/src/podojo_cli/client.py +0 -0
- {podojo_cli-1.26.0 → podojo_cli-1.27.0}/src/podojo_cli/commands/__init__.py +0 -0
- {podojo_cli-1.26.0 → podojo_cli-1.27.0}/src/podojo_cli/commands/aiinterviews.py +0 -0
- {podojo_cli-1.26.0 → podojo_cli-1.27.0}/src/podojo_cli/commands/auth.py +0 -0
- {podojo_cli-1.26.0 → podojo_cli-1.27.0}/src/podojo_cli/commands/interviews.py +0 -0
- {podojo_cli-1.26.0 → podojo_cli-1.27.0}/src/podojo_cli/commands/projects.py +0 -0
- {podojo_cli-1.26.0 → podojo_cli-1.27.0}/src/podojo_cli/commands/showreel.py +0 -0
- {podojo_cli-1.26.0 → podojo_cli-1.27.0}/src/podojo_cli/commands/synth.py +0 -0
- {podojo_cli-1.26.0 → podojo_cli-1.27.0}/src/podojo_cli/commands/transcripts.py +0 -0
- {podojo_cli-1.26.0 → podojo_cli-1.27.0}/src/podojo_cli/commands/videos.py +0 -0
- {podojo_cli-1.26.0 → podojo_cli-1.27.0}/src/podojo_cli/config.py +0 -0
- {podojo_cli-1.26.0 → podojo_cli-1.27.0}/src/podojo_cli/main.py +0 -0
- {podojo_cli-1.26.0 → podojo_cli-1.27.0}/src/podojo_cli/synth/__init__.py +0 -0
- {podojo_cli-1.26.0 → podojo_cli-1.27.0}/src/podojo_cli/synth/driver.py +0 -0
- {podojo_cli-1.26.0 → podojo_cli-1.27.0}/src/podojo_cli/synth/session.py +0 -0
- {podojo_cli-1.26.0 → podojo_cli-1.27.0}/src/podojo_cli/version_check.py +0 -0
- {podojo_cli-1.26.0 → podojo_cli-1.27.0}/src/podojo_cli/video/__init__.py +0 -0
- {podojo_cli-1.26.0 → podojo_cli-1.27.0}/src/podojo_cli/video/showreel.py +0 -0
- {podojo_cli-1.26.0 → podojo_cli-1.27.0}/tests/conftest.py +0 -0
- {podojo_cli-1.26.0 → podojo_cli-1.27.0}/tests/test_aiinterviews.py +0 -0
- {podojo_cli-1.26.0 → podojo_cli-1.27.0}/tests/test_auth.py +0 -0
- {podojo_cli-1.26.0 → podojo_cli-1.27.0}/tests/test_interviews.py +0 -0
- {podojo_cli-1.26.0 → podojo_cli-1.27.0}/tests/test_projects.py +0 -0
- {podojo_cli-1.26.0 → podojo_cli-1.27.0}/tests/test_showreel.py +0 -0
- {podojo_cli-1.26.0 → podojo_cli-1.27.0}/tests/test_transcripts.py +0 -0
|
@@ -5,6 +5,24 @@ All notable changes to the Podojo CLI will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org).
|
|
7
7
|
|
|
8
|
+
## [1.27.0] - 2026-10-27
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- User test steps support a new screen variant, `ai-question`: an AI voice
|
|
12
|
+
interviewer asks the step's `text` and up to `max_follow_ups` follow-ups
|
|
13
|
+
(default 2) aimed at `probe_for`. `podojo usertests validate` checks that
|
|
14
|
+
`text` is set and rejects `probe_for` / `max_follow_ups` on other steps.
|
|
15
|
+
`podojo usertests example` shows one.
|
|
16
|
+
|
|
17
|
+
## [1.26.1] - 2026-10-25
|
|
18
|
+
|
|
19
|
+
### Security
|
|
20
|
+
- Updated certifi to 2026.10.23, which removes the eMudhra/emSign root
|
|
21
|
+
certificates compromised in October 2026. certifi is the CA bundle httpx
|
|
22
|
+
uses for every API call to Podojo; without this update a man-in-the-middle
|
|
23
|
+
with a fraudulently issued certificate could impersonate the Podojo API to
|
|
24
|
+
the CLI.
|
|
25
|
+
|
|
8
26
|
## [1.26.0] - 2026-10-08
|
|
9
27
|
|
|
10
28
|
### Changed
|
|
@@ -32,7 +32,7 @@ podojo usertests validate usertest.yaml # validate without creating
|
|
|
32
32
|
User test configs are YAML files. Run `podojo usertests example` for the full template.
|
|
33
33
|
Required fields: `usertest_id`, `title`, `prototype_url`, `steps`. `logo` is optional —
|
|
34
34
|
the test app falls back to the account's logo (group mapping in unmoderated-usertests).
|
|
35
|
-
Each step needs `type` ("screen" or "prototype") and `title`. Screen steps should have `variant` ("question", "task", or "
|
|
35
|
+
Each step needs `type` ("screen" or "prototype") and `title`. Screen steps should have `variant` ("question", "task", "instruction" or "ai-question" — instruction screens render a "Continue" button instead of "Done with Step", useful as a briefing before a prototype step; ai-question has an AI voice interviewer ask `text` plus follow-ups, with optional `probe_for` and `max_follow_ups`, default 2).
|
|
36
36
|
|
|
37
37
|
## Synthetic User Tests (experimental)
|
|
38
38
|
|
|
@@ -14,7 +14,7 @@ err_console = Console(stderr=True, soft_wrap=True)
|
|
|
14
14
|
|
|
15
15
|
REQUIRED_FIELDS = ["usertest_id", "title", "prototype_url", "steps"]
|
|
16
16
|
VALID_STEP_TYPES = {"screen", "prototype"}
|
|
17
|
-
VALID_STEP_VARIANTS = {"question", "task", "instruction"}
|
|
17
|
+
VALID_STEP_VARIANTS = {"question", "task", "instruction", "ai-question"}
|
|
18
18
|
REQUIRED_STEP_FIELDS = ["type", "title"]
|
|
19
19
|
|
|
20
20
|
# The rrweb.record() call lives in recorder.js rather than an inline <script>:
|
|
@@ -153,7 +153,10 @@ rejection_message: >
|
|
|
153
153
|
# Steps define what participants see and do
|
|
154
154
|
# Each step requires: type ("screen" or "prototype") and title
|
|
155
155
|
# Screen steps should have a variant: "question" (open-ended), "task" (action-based),
|
|
156
|
-
#
|
|
156
|
+
# "instruction" (briefing screen shown before a prototype step — uses a "Continue" button),
|
|
157
|
+
# or "ai-question" (an AI voice interviewer asks `text` and up to max_follow_ups follow-ups;
|
|
158
|
+
# `text` is required, optional probe_for says what the follow-ups should dig into,
|
|
159
|
+
# max_follow_ups defaults to 2)
|
|
157
160
|
# Optional per step: text (markdown), and either:
|
|
158
161
|
# image: a URL to an externally-hosted image, or
|
|
159
162
|
# image_file: a path to a local image (uploaded to Podojo storage on create/update;
|
|
@@ -184,6 +187,13 @@ steps:
|
|
|
184
187
|
Think aloud as you go through each step.
|
|
185
188
|
# url: https://figma.com/proto/abc123?state=basic-active
|
|
186
189
|
|
|
190
|
+
- type: screen
|
|
191
|
+
variant: ai-question
|
|
192
|
+
title: About buying the pass
|
|
193
|
+
text: How did buying the pass go for you?
|
|
194
|
+
probe_for: What they expected after paying; whether the price was clear
|
|
195
|
+
max_follow_ups: 2
|
|
196
|
+
|
|
187
197
|
- type: screen
|
|
188
198
|
variant: task
|
|
189
199
|
title: Find the Return Policy
|
|
@@ -243,6 +253,30 @@ def validate_usertest_data(data: dict) -> list[str]:
|
|
|
243
253
|
)
|
|
244
254
|
if "url" in step and not (isinstance(step["url"], str) and step["url"]):
|
|
245
255
|
errors.append(f"Step {i}: 'url' must be a non-empty string")
|
|
256
|
+
is_ai_question = step_type == "screen" and variant == "ai-question"
|
|
257
|
+
if is_ai_question:
|
|
258
|
+
text = step.get("text")
|
|
259
|
+
if not (isinstance(text, str) and text.strip()):
|
|
260
|
+
errors.append(
|
|
261
|
+
f"Step {i}: ai-question steps need a non-empty 'text' (the question)"
|
|
262
|
+
)
|
|
263
|
+
max_follow_ups = step.get("max_follow_ups")
|
|
264
|
+
if max_follow_ups is not None and (
|
|
265
|
+
isinstance(max_follow_ups, bool)
|
|
266
|
+
or not isinstance(max_follow_ups, int)
|
|
267
|
+
or max_follow_ups < 0
|
|
268
|
+
):
|
|
269
|
+
errors.append(f"Step {i}: 'max_follow_ups' must be a non-negative integer")
|
|
270
|
+
probe_for = step.get("probe_for")
|
|
271
|
+
if probe_for is not None and not isinstance(probe_for, str):
|
|
272
|
+
errors.append(f"Step {i}: 'probe_for' must be a string")
|
|
273
|
+
else:
|
|
274
|
+
# `usertests get` prints them as null on every step; only values count.
|
|
275
|
+
for field in ("probe_for", "max_follow_ups"):
|
|
276
|
+
if step.get(field) is not None:
|
|
277
|
+
errors.append(
|
|
278
|
+
f"Step {i}: '{field}' is only allowed on screen steps with variant 'ai-question'"
|
|
279
|
+
)
|
|
246
280
|
|
|
247
281
|
screening_questions = data.get("screening_questions")
|
|
248
282
|
if screening_questions is not None:
|
|
@@ -699,3 +699,103 @@ def test_snippet(runner):
|
|
|
699
699
|
assert "rrweb.record" in result.output
|
|
700
700
|
# the recording must start from recorder.js: Boltable refuses inline scripts
|
|
701
701
|
assert "<script>" not in result.output
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def _validate_steps(runner, tmp_path, steps_yaml):
|
|
705
|
+
yaml_file = tmp_path / "usertest.yaml"
|
|
706
|
+
yaml_file.write_text(
|
|
707
|
+
VALID_USERTEST_YAML.split("steps:")[0] + "steps:\n" + steps_yaml
|
|
708
|
+
)
|
|
709
|
+
return runner.invoke(app, ["usertests", "validate", str(yaml_file)])
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def test_validate_ai_question_valid(runner, tmp_path):
|
|
713
|
+
result = _validate_steps(
|
|
714
|
+
runner,
|
|
715
|
+
tmp_path,
|
|
716
|
+
" - type: screen\n variant: ai-question\n title: Pass\n"
|
|
717
|
+
" text: How did buying the pass go?\n probe_for: price clarity\n"
|
|
718
|
+
" max_follow_ups: 0\n",
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
assert result.exit_code == 0
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def test_validate_ai_question_follow_up_fields_optional(runner, tmp_path):
|
|
725
|
+
result = _validate_steps(
|
|
726
|
+
runner,
|
|
727
|
+
tmp_path,
|
|
728
|
+
" - type: screen\n variant: ai-question\n title: Pass\n text: How was it?\n",
|
|
729
|
+
)
|
|
730
|
+
|
|
731
|
+
assert result.exit_code == 0
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def test_validate_ai_question_requires_text(runner, tmp_path):
|
|
735
|
+
result = _validate_steps(
|
|
736
|
+
runner, tmp_path, " - type: screen\n variant: ai-question\n title: Pass\n"
|
|
737
|
+
)
|
|
738
|
+
|
|
739
|
+
assert result.exit_code == 1
|
|
740
|
+
assert "non-empty 'text'" in result.output
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def test_validate_ai_question_max_follow_ups_must_be_non_negative_int(runner, tmp_path):
|
|
744
|
+
for value in ("-1", "1.5", "two", "true"):
|
|
745
|
+
result = _validate_steps(
|
|
746
|
+
runner,
|
|
747
|
+
tmp_path,
|
|
748
|
+
" - type: screen\n variant: ai-question\n title: Pass\n"
|
|
749
|
+
f" text: How was it?\n max_follow_ups: {value}\n",
|
|
750
|
+
)
|
|
751
|
+
|
|
752
|
+
assert result.exit_code == 1, value
|
|
753
|
+
assert "max_follow_ups" in result.output
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def test_validate_ai_question_probe_for_must_be_string(runner, tmp_path):
|
|
757
|
+
result = _validate_steps(
|
|
758
|
+
runner,
|
|
759
|
+
tmp_path,
|
|
760
|
+
" - type: screen\n variant: ai-question\n title: Pass\n"
|
|
761
|
+
" text: How was it?\n probe_for: 5\n",
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
assert result.exit_code == 1
|
|
765
|
+
assert "'probe_for' must be a string" in result.output
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def test_validate_probe_for_rejected_on_other_steps(runner, tmp_path):
|
|
769
|
+
result = _validate_steps(
|
|
770
|
+
runner,
|
|
771
|
+
tmp_path,
|
|
772
|
+
" - type: screen\n variant: question\n title: Q\n text: Hi\n"
|
|
773
|
+
" probe_for: x\n max_follow_ups: 2\n",
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
assert result.exit_code == 1
|
|
777
|
+
assert "'probe_for' is only allowed" in result.output
|
|
778
|
+
assert "'max_follow_ups' is only allowed" in result.output
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def test_validate_null_ai_question_fields_on_other_steps(runner, tmp_path):
|
|
782
|
+
# `usertests get` prints every stored step with these keys, so a fetched
|
|
783
|
+
# config must validate again unchanged.
|
|
784
|
+
result = _validate_steps(
|
|
785
|
+
runner,
|
|
786
|
+
tmp_path,
|
|
787
|
+
" - type: prototype\n title: Task\n probe_for: null\n max_follow_ups: null\n",
|
|
788
|
+
)
|
|
789
|
+
|
|
790
|
+
assert result.exit_code == 0
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def test_example_template_validates(runner, tmp_path):
|
|
794
|
+
example = runner.invoke(app, ["usertests", "example"])
|
|
795
|
+
yaml_file = tmp_path / "example.yaml"
|
|
796
|
+
yaml_file.write_text(example.output)
|
|
797
|
+
|
|
798
|
+
result = runner.invoke(app, ["usertests", "validate", str(yaml_file)])
|
|
799
|
+
|
|
800
|
+
assert "variant: ai-question" in example.output
|
|
801
|
+
assert result.exit_code == 0
|
|
@@ -35,11 +35,11 @@ wheels = [
|
|
|
35
35
|
|
|
36
36
|
[[package]]
|
|
37
37
|
name = "certifi"
|
|
38
|
-
version = "2026.
|
|
38
|
+
version = "2026.10.23"
|
|
39
39
|
source = { registry = "https://pypi.org/simple" }
|
|
40
|
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
|
40
|
+
sdist = { url = "https://files.pythonhosted.org/packages/fd/11/b524cbd9bbdaca58608e4cf4053fd463417549e1959fa8d3aa18319f9555/certifi-2026.10.23.tar.gz", hash = "sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd", size = 136225, upload-time = "2026-10-23T11:36:16.478Z" }
|
|
41
41
|
wheels = [
|
|
42
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
42
|
+
{ url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl", hash = "sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1", size = 135082, upload-time = "2026-10-23T11:36:14.763Z" },
|
|
43
43
|
]
|
|
44
44
|
|
|
45
45
|
[[package]]
|
|
@@ -438,7 +438,7 @@ wheels = [
|
|
|
438
438
|
|
|
439
439
|
[[package]]
|
|
440
440
|
name = "podojo-cli"
|
|
441
|
-
version = "1.
|
|
441
|
+
version = "1.27.0"
|
|
442
442
|
source = { editable = "." }
|
|
443
443
|
dependencies = [
|
|
444
444
|
{ name = "httpx" },
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|