podojo-cli 1.6.0__tar.gz → 1.7.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.6.0 → podojo_cli-1.7.0}/CHANGELOG.md +5 -0
- {podojo_cli-1.6.0 → podojo_cli-1.7.0}/PKG-INFO +7 -1
- {podojo_cli-1.6.0 → podojo_cli-1.7.0}/README.md +6 -0
- {podojo_cli-1.6.0 → podojo_cli-1.7.0}/pyproject.toml +1 -1
- {podojo_cli-1.6.0 → podojo_cli-1.7.0}/src/podojo_cli/commands/usertests.py +66 -0
- {podojo_cli-1.6.0 → podojo_cli-1.7.0}/tests/test_usertests.py +95 -0
- {podojo_cli-1.6.0 → podojo_cli-1.7.0}/uv.lock +1 -1
- {podojo_cli-1.6.0 → podojo_cli-1.7.0}/.github/workflows/publish.yml +0 -0
- {podojo_cli-1.6.0 → podojo_cli-1.7.0}/.gitignore +0 -0
- {podojo_cli-1.6.0 → podojo_cli-1.7.0}/CLAUDE.md +0 -0
- {podojo_cli-1.6.0 → podojo_cli-1.7.0}/LICENSE +0 -0
- {podojo_cli-1.6.0 → podojo_cli-1.7.0}/src/podojo_cli/__init__.py +0 -0
- {podojo_cli-1.6.0 → podojo_cli-1.7.0}/src/podojo_cli/client.py +0 -0
- {podojo_cli-1.6.0 → podojo_cli-1.7.0}/src/podojo_cli/commands/__init__.py +0 -0
- {podojo_cli-1.6.0 → podojo_cli-1.7.0}/src/podojo_cli/commands/aiinterviews.py +0 -0
- {podojo_cli-1.6.0 → podojo_cli-1.7.0}/src/podojo_cli/commands/auth.py +0 -0
- {podojo_cli-1.6.0 → podojo_cli-1.7.0}/src/podojo_cli/commands/interviews.py +0 -0
- {podojo_cli-1.6.0 → podojo_cli-1.7.0}/src/podojo_cli/commands/projects.py +0 -0
- {podojo_cli-1.6.0 → podojo_cli-1.7.0}/src/podojo_cli/commands/showreel.py +0 -0
- {podojo_cli-1.6.0 → podojo_cli-1.7.0}/src/podojo_cli/commands/synth.py +0 -0
- {podojo_cli-1.6.0 → podojo_cli-1.7.0}/src/podojo_cli/commands/transcripts.py +0 -0
- {podojo_cli-1.6.0 → podojo_cli-1.7.0}/src/podojo_cli/commands/videos.py +0 -0
- {podojo_cli-1.6.0 → podojo_cli-1.7.0}/src/podojo_cli/config.py +0 -0
- {podojo_cli-1.6.0 → podojo_cli-1.7.0}/src/podojo_cli/main.py +0 -0
- {podojo_cli-1.6.0 → podojo_cli-1.7.0}/src/podojo_cli/synth/__init__.py +0 -0
- {podojo_cli-1.6.0 → podojo_cli-1.7.0}/src/podojo_cli/synth/driver.py +0 -0
- {podojo_cli-1.6.0 → podojo_cli-1.7.0}/src/podojo_cli/synth/session.py +0 -0
- {podojo_cli-1.6.0 → podojo_cli-1.7.0}/src/podojo_cli/version_check.py +0 -0
- {podojo_cli-1.6.0 → podojo_cli-1.7.0}/src/podojo_cli/video/__init__.py +0 -0
- {podojo_cli-1.6.0 → podojo_cli-1.7.0}/src/podojo_cli/video/showreel.py +0 -0
- {podojo_cli-1.6.0 → podojo_cli-1.7.0}/tests/conftest.py +0 -0
- {podojo_cli-1.6.0 → podojo_cli-1.7.0}/tests/test_aiinterviews.py +0 -0
- {podojo_cli-1.6.0 → podojo_cli-1.7.0}/tests/test_auth.py +0 -0
- {podojo_cli-1.6.0 → podojo_cli-1.7.0}/tests/test_interviews.py +0 -0
- {podojo_cli-1.6.0 → podojo_cli-1.7.0}/tests/test_projects.py +0 -0
- {podojo_cli-1.6.0 → podojo_cli-1.7.0}/tests/test_showreel.py +0 -0
- {podojo_cli-1.6.0 → podojo_cli-1.7.0}/tests/test_transcripts.py +0 -0
|
@@ -5,6 +5,11 @@ 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.7.0] - 2026-07-04
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- User tests support an optional participant screener: `screening_questions` (single-select, options flagged `qualifies: true`) and `rejection_message`, mirroring AI interviews. `validate`/`create` check the screener shape, and `example` shows the new fields.
|
|
12
|
+
|
|
8
13
|
## [1.6.0] - 2026-07-03
|
|
9
14
|
|
|
10
15
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: podojo-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.7.0
|
|
4
4
|
Summary: CLI for the Podojo user research platform
|
|
5
5
|
Project-URL: Homepage, https://github.com/podojo/cli-podojo
|
|
6
6
|
Project-URL: Source, https://github.com/podojo/cli-podojo
|
|
@@ -123,6 +123,12 @@ podojo usertests delete checkout-usability-v1
|
|
|
123
123
|
podojo usertests snippet # recorder script for self-hosted prototypes
|
|
124
124
|
```
|
|
125
125
|
|
|
126
|
+
A user test can open with an optional participant screener: on-screen
|
|
127
|
+
single-select `screening_questions` whose options carry `qualifies: true`
|
|
128
|
+
flags. Participants must pick a qualifying option on every question; everyone
|
|
129
|
+
else sees the test's `rejection_message` and never reaches the recorded test.
|
|
130
|
+
See `podojo usertests example` for the exact shape.
|
|
131
|
+
|
|
126
132
|
### AI interviews
|
|
127
133
|
|
|
128
134
|
An AI interview is a self-serve voice conversation: an AI interviewer asks
|
|
@@ -92,6 +92,12 @@ podojo usertests delete checkout-usability-v1
|
|
|
92
92
|
podojo usertests snippet # recorder script for self-hosted prototypes
|
|
93
93
|
```
|
|
94
94
|
|
|
95
|
+
A user test can open with an optional participant screener: on-screen
|
|
96
|
+
single-select `screening_questions` whose options carry `qualifies: true`
|
|
97
|
+
flags. Participants must pick a qualifying option on every question; everyone
|
|
98
|
+
else sees the test's `rejection_message` and never reaches the recorded test.
|
|
99
|
+
See `podojo usertests example` for the exact shape.
|
|
100
|
+
|
|
95
101
|
### AI interviews
|
|
96
102
|
|
|
97
103
|
An AI interview is a self-serve voice conversation: an AI interviewer asks
|
|
@@ -37,7 +37,16 @@ EXAMPLE_YAML = """\
|
|
|
37
37
|
#
|
|
38
38
|
# Required fields: usertest_id, title, logo, prototype_url, steps
|
|
39
39
|
# Optional fields: welcome_text, promo_code, promo_code_info,
|
|
40
|
+
# screening_questions, rejection_message,
|
|
40
41
|
# project_name, live, collect_contact
|
|
42
|
+
#
|
|
43
|
+
# Each screening question (optional participant screener, answered on screen
|
|
44
|
+
# before consent — screening is never recorded):
|
|
45
|
+
# text (required) single-select multiple-choice question
|
|
46
|
+
# options (required, at least 2) each with `text` and an optional
|
|
47
|
+
# `qualifies: true` — participants must pick a qualifying
|
|
48
|
+
# option on every question, otherwise they see the
|
|
49
|
+
# rejection_message and the test never starts
|
|
41
50
|
|
|
42
51
|
usertest_id: checkout-usability-v1
|
|
43
52
|
title: Checkout Flow Usability Test
|
|
@@ -66,6 +75,31 @@ project_name: checkout-redesign-q1
|
|
|
66
75
|
# Optional: collect participant name/email on the final screen (default: false)
|
|
67
76
|
# collect_contact: true
|
|
68
77
|
|
|
78
|
+
# Optional: participant screener — shown on screen before consent and
|
|
79
|
+
# recording. Only qualified participants reach the test; screen-out answers
|
|
80
|
+
# are still captured for funnel metrics.
|
|
81
|
+
screening_questions:
|
|
82
|
+
- text: How often do you shop online?
|
|
83
|
+
options:
|
|
84
|
+
- text: Rarely or never
|
|
85
|
+
- text: A few times a year
|
|
86
|
+
- text: At least once a month
|
|
87
|
+
qualifies: true
|
|
88
|
+
- text: Weekly or more
|
|
89
|
+
qualifies: true
|
|
90
|
+
|
|
91
|
+
- text: Have you abandoned an online purchase at checkout in the past 3 months?
|
|
92
|
+
options:
|
|
93
|
+
- text: "Yes"
|
|
94
|
+
qualifies: true
|
|
95
|
+
- text: "No"
|
|
96
|
+
- text: Not sure
|
|
97
|
+
|
|
98
|
+
# Optional: shown to participants whose screener answers don't qualify
|
|
99
|
+
rejection_message: >
|
|
100
|
+
Thank you for your time, you did not meet the research criteria for this
|
|
101
|
+
study!
|
|
102
|
+
|
|
69
103
|
# Steps define what participants see and do
|
|
70
104
|
# Each step requires: type ("screen" or "prototype") and title
|
|
71
105
|
# Screen steps should have a variant: "question" (open-ended), "task" (action-based),
|
|
@@ -143,6 +177,38 @@ def validate_usertest_data(data: dict) -> list[str]:
|
|
|
143
177
|
errors.append(
|
|
144
178
|
f"Step {i}: 'variant' must be one of {sorted(VALID_STEP_VARIANTS)}, got '{variant}'"
|
|
145
179
|
)
|
|
180
|
+
|
|
181
|
+
screening_questions = data.get("screening_questions")
|
|
182
|
+
if screening_questions is not None:
|
|
183
|
+
if not isinstance(screening_questions, list):
|
|
184
|
+
errors.append("'screening_questions' must be a list")
|
|
185
|
+
else:
|
|
186
|
+
for i, question in enumerate(screening_questions, 1):
|
|
187
|
+
if not isinstance(question, dict) or "text" not in question:
|
|
188
|
+
errors.append(f"Screening question {i}: must be a mapping with 'text'")
|
|
189
|
+
continue
|
|
190
|
+
options = question.get("options")
|
|
191
|
+
if not isinstance(options, list) or len(options) < 2:
|
|
192
|
+
errors.append(f"Screening question {i}: 'options' must list at least 2 options")
|
|
193
|
+
continue
|
|
194
|
+
qualifying = 0
|
|
195
|
+
for j, option in enumerate(options, 1):
|
|
196
|
+
if not isinstance(option, dict) or "text" not in option:
|
|
197
|
+
errors.append(
|
|
198
|
+
f"Screening question {i}, option {j}: must be a mapping with 'text'"
|
|
199
|
+
)
|
|
200
|
+
continue
|
|
201
|
+
qualifies = option.get("qualifies", False)
|
|
202
|
+
if not isinstance(qualifies, bool):
|
|
203
|
+
errors.append(
|
|
204
|
+
f"Screening question {i}, option {j}: 'qualifies' must be true or false"
|
|
205
|
+
)
|
|
206
|
+
elif qualifies:
|
|
207
|
+
qualifying += 1
|
|
208
|
+
if qualifying == 0:
|
|
209
|
+
errors.append(
|
|
210
|
+
f"Screening question {i}: needs at least one option with 'qualifies: true'"
|
|
211
|
+
)
|
|
146
212
|
return errors
|
|
147
213
|
|
|
148
214
|
|
|
@@ -292,6 +292,87 @@ def test_validate_invalid(runner, tmp_path):
|
|
|
292
292
|
assert "Missing required field" in result.output
|
|
293
293
|
|
|
294
294
|
|
|
295
|
+
SCREENING_YAML = VALID_USERTEST_YAML + """\
|
|
296
|
+
screening_questions:
|
|
297
|
+
- text: Do you shop online?
|
|
298
|
+
options:
|
|
299
|
+
- text: "Yes"
|
|
300
|
+
qualifies: true
|
|
301
|
+
- text: "No"
|
|
302
|
+
- text: How often do you shop online?
|
|
303
|
+
options:
|
|
304
|
+
- text: Never
|
|
305
|
+
- text: Monthly
|
|
306
|
+
qualifies: true
|
|
307
|
+
- text: Weekly
|
|
308
|
+
qualifies: true
|
|
309
|
+
rejection_message: You did not meet the research criteria for this study.
|
|
310
|
+
"""
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def test_validate_with_screening(runner, tmp_path):
|
|
314
|
+
yaml_file = tmp_path / "usertest.yaml"
|
|
315
|
+
yaml_file.write_text(SCREENING_YAML)
|
|
316
|
+
|
|
317
|
+
result = runner.invoke(app, ["usertests", "validate", str(yaml_file)])
|
|
318
|
+
|
|
319
|
+
assert result.exit_code == 0
|
|
320
|
+
assert "Valid user test config" in result.output
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def test_validate_screening_question_needs_two_options(runner, tmp_path):
|
|
324
|
+
yaml_file = tmp_path / "usertest.yaml"
|
|
325
|
+
yaml_file.write_text(
|
|
326
|
+
VALID_USERTEST_YAML
|
|
327
|
+
+ "screening_questions:\n"
|
|
328
|
+
+ " - text: Only one way to answer?\n"
|
|
329
|
+
+ " options:\n"
|
|
330
|
+
+ " - text: \"Yes\"\n"
|
|
331
|
+
+ " qualifies: true\n"
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
result = runner.invoke(app, ["usertests", "validate", str(yaml_file)])
|
|
335
|
+
|
|
336
|
+
assert result.exit_code == 1
|
|
337
|
+
assert "'options' must list at least 2 options" in result.output
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def test_validate_screening_question_needs_a_qualifying_option(runner, tmp_path):
|
|
341
|
+
yaml_file = tmp_path / "usertest.yaml"
|
|
342
|
+
yaml_file.write_text(
|
|
343
|
+
VALID_USERTEST_YAML
|
|
344
|
+
+ "screening_questions:\n"
|
|
345
|
+
+ " - text: Nobody can pass this one\n"
|
|
346
|
+
+ " options:\n"
|
|
347
|
+
+ " - text: A\n"
|
|
348
|
+
+ " - text: B\n"
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
result = runner.invoke(app, ["usertests", "validate", str(yaml_file)])
|
|
352
|
+
|
|
353
|
+
assert result.exit_code == 1
|
|
354
|
+
assert "needs at least one option with 'qualifies: true'" in result.output
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def test_validate_screening_option_qualifies_must_be_bool(runner, tmp_path):
|
|
358
|
+
yaml_file = tmp_path / "usertest.yaml"
|
|
359
|
+
yaml_file.write_text(
|
|
360
|
+
VALID_USERTEST_YAML
|
|
361
|
+
+ "screening_questions:\n"
|
|
362
|
+
+ " - text: Q\n"
|
|
363
|
+
+ " options:\n"
|
|
364
|
+
+ " - text: A\n"
|
|
365
|
+
+ " qualifies: yep\n"
|
|
366
|
+
+ " - text: B\n"
|
|
367
|
+
+ " qualifies: true\n"
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
result = runner.invoke(app, ["usertests", "validate", str(yaml_file)])
|
|
371
|
+
|
|
372
|
+
assert result.exit_code == 1
|
|
373
|
+
assert "'qualifies' must be true or false" in result.output
|
|
374
|
+
|
|
375
|
+
|
|
295
376
|
def test_example(runner):
|
|
296
377
|
result = runner.invoke(app, ["usertests", "example"])
|
|
297
378
|
|
|
@@ -300,6 +381,20 @@ def test_example(runner):
|
|
|
300
381
|
assert "steps:" in result.output
|
|
301
382
|
assert "prototype" in result.output
|
|
302
383
|
assert "screen" in result.output
|
|
384
|
+
assert "screening_questions:" in result.output
|
|
385
|
+
assert "rejection_message:" in result.output
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def test_example_validates(runner, tmp_path):
|
|
389
|
+
from podojo_cli.commands.usertests import EXAMPLE_YAML
|
|
390
|
+
|
|
391
|
+
yaml_file = tmp_path / "example.yaml"
|
|
392
|
+
yaml_file.write_text(EXAMPLE_YAML)
|
|
393
|
+
|
|
394
|
+
result = runner.invoke(app, ["usertests", "validate", str(yaml_file)])
|
|
395
|
+
|
|
396
|
+
assert result.exit_code == 0
|
|
397
|
+
assert "Valid user test config" in result.output
|
|
303
398
|
|
|
304
399
|
|
|
305
400
|
def test_snippet(runner):
|
|
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
|