diff --git a/README.md b/README.md index 36a2627..1f8492b 100644 --- a/README.md +++ b/README.md @@ -1173,7 +1173,18 @@ Go test wrapper (opt-in): GOG_LIVE=1 go test -tags=integration ./internal/integration -run Live ``` -Optional env: `GOG_LIVE_FAST=1`, `GOG_LIVE_SKIP=groups,keep`, `GOG_LIVE_AUTH=all,groups`, `GOG_LIVE_ALLOW_NONTEST=1`. +Optional env: +- `GOG_LIVE_FAST=1` +- `GOG_LIVE_SKIP=groups,keep` +- `GOG_LIVE_AUTH=all,groups` +- `GOG_LIVE_ALLOW_NONTEST=1` +- `GOG_LIVE_EMAIL_TEST=steipete+gogtest@gmail.com` +- `GOG_LIVE_GROUP_EMAIL=group@domain` +- `GOG_LIVE_CLASSROOM_COURSE=` +- `GOG_LIVE_CLASSROOM_CREATE=1` +- `GOG_LIVE_TRACK=1` +- `GOG_KEEP_SERVICE_ACCOUNT=/path/to/service-account.json` +- `GOG_KEEP_IMPERSONATE=user@workspace-domain` ### Make Shortcut diff --git a/scripts/live-test.sh b/scripts/live-test.sh index b306f3e..e92c8d6 100755 --- a/scripts/live-test.sh +++ b/scripts/live-test.sh @@ -21,10 +21,20 @@ Options: --auth Re-auth before running (e.g., all,groups) -h, --help Show this help -Skip keys: - auth-alias, enable-commands, gmail, drive, docs, sheets, slides, - calendar, calendar-enterprise, tasks, contacts, people, - groups, keep, classroom +Skip keys (base): + auth-alias, enable-commands, gmail, gmail-settings, gmail-delegates, gmail-batch-delete, drive, docs, sheets, slides, + calendar, calendar-enterprise, calendar-respond, calendar-team, calendar-users, + tasks, contacts, people, groups, keep, classroom + +Env: + GOG_LIVE_EMAIL_TEST=steipete+gogtest@gmail.com + GOG_LIVE_GROUP_EMAIL= + GOG_LIVE_CLASSROOM_COURSE= + GOG_LIVE_CLASSROOM_CREATE=1 + GOG_LIVE_TRACK=1 + GOG_LIVE_ALLOW_NONTEST=1 + GOG_KEEP_SERVICE_ACCOUNT=/path/to/service-account.json + GOG_KEEP_IMPERSONATE=user@workspace-domain USAGE } @@ -62,7 +72,14 @@ while [ $# -gt 0 ]; do ;; esac shift - done +done + +if [ -n "${GOG_LIVE_FAST:-}" ]; then + FAST=true +fi +if [ -z "$AUTH_SERVICES" ] && [ -n "${GOG_LIVE_AUTH:-}" ]; then + AUTH_SERVICES="$GOG_LIVE_AUTH" +fi SKIP="${SKIP:-${GOG_LIVE_SKIP:-}}" if [ "$FAST" = true ]; then @@ -97,216 +114,32 @@ fi echo "Using account: $ACCOUNT" -is_test_account() { - local a - a=$(echo "$1" | tr 'A-Z' 'a-z') - case "$a" in - *test*|*bot*|*sandbox*|*qa*|*staging*|*dev*|*@example.com) - return 0 - ;; - esac - case "$a" in - *+*) - return 0 - ;; - esac - return 1 -} +EMAIL_TEST="${GOG_LIVE_EMAIL_TEST:-steipete+gogtest@gmail.com}" +TS=$(date +%Y%m%d%H%M%S) +LIVE_TMP=$(mktemp -d "${TMPDIR:-/tmp}/gog-live-$TS-XXXX") +trap 'rm -rf "$LIVE_TMP"' EXIT -if [ "$ALLOW_NONTEST" = false ] && [ -z "${GOG_LIVE_ALLOW_NONTEST:-}" ]; then - if ! is_test_account "$ACCOUNT"; then - echo "Refusing to run live tests against non-test account: $ACCOUNT" >&2 - echo "Pass --allow-nontest or set GOG_LIVE_ALLOW_NONTEST=1 to override." >&2 - exit 2 - fi -fi +source scripts/live-tests/common.sh +source scripts/live-tests/core.sh +source scripts/live-tests/gmail.sh +source scripts/live-tests/drive.sh +source scripts/live-tests/docs.sh +source scripts/live-tests/sheets.sh +source scripts/live-tests/slides.sh +source scripts/live-tests/calendar.sh +source scripts/live-tests/tasks.sh +source scripts/live-tests/contacts.sh +source scripts/live-tests/people.sh +source scripts/live-tests/workspace.sh +source scripts/live-tests/classroom.sh + +ensure_test_account if [ -n "$AUTH_SERVICES" ]; then $BIN auth add "$ACCOUNT" --services "$AUTH_SERVICES" fi -TS=$(date +%Y%m%d%H%M%S) -ACCOUNT_ARGS=(--account "$ACCOUNT") - -gog() { - "$BIN" "${ACCOUNT_ARGS[@]}" "$@" -} - -skip() { - local key="$1" - [ -n "$SKIP" ] || return 1 - IFS=',' read -r -a items <<<"$SKIP" - for item in "${items[@]}"; do - if [ "$item" = "$key" ]; then - return 0 - fi - done - return 1 -} - -extract_id() { - $PY -c 'import json,sys -obj=json.load(sys.stdin) - -def find_id(x): - if isinstance(x, dict): - for key in ("id", "draftId", "spreadsheetId", "presentationId", "documentId"): - if isinstance(x.get(key), str): - return x[key] - for v in x.values(): - r=find_id(v) - if r: - return r - if isinstance(x, list): - for v in x: - r=find_id(v) - if r: - return r - return "" -print(find_id(obj))' <<<"$1" -} - -extract_field() { - local value="$1" - local field="$2" - $PY -c 'import json,sys -obj=json.load(sys.stdin) -key=sys.argv[1] - -def find_field(x, k): - if isinstance(x, dict): - if k in x and isinstance(x[k], str): - return x[k] - for v in x.values(): - r=find_field(v, k) - if r: - return r - if isinstance(x, list): - for v in x: - r=find_field(v, k) - if r: - return r - return "" -print(find_field(obj, key))' "$field" <<<"$value" -} - -extract_tasklist_id() { - $PY -c 'import json,sys -obj=json.load(sys.stdin) -for key in ("tasklists","lists","items"): - if isinstance(obj, dict) and obj.get(key): - print(obj[key][0].get("id","")) - sys.exit(0) -print("")' <<<"$1" -} - -extract_task_ids() { - $PY -c 'import json,sys -obj=json.load(sys.stdin) -ids=[] -if isinstance(obj, dict) and "tasks" in obj: - ids=[t.get("id") for t in obj.get("tasks",[]) if t.get("id")] -elif isinstance(obj, dict) and "task" in obj: - if obj["task"].get("id"): - ids=[obj["task"]["id"]] -print("\n".join(ids))' <<<"$1" -} - -run_required() { - local key="$1" - local label="$2" - shift 2 - if skip "$key"; then - echo "==> $label (skipped)" - return 0 - fi - echo "==> $label" - "$@" -} - -run_optional() { - local key="$1" - local label="$2" - shift 2 - if skip "$key"; then - echo "==> $label (skipped)" - return 0 - fi - echo "==> $label (optional)" - if "$@"; then - echo "ok" - return 0 - fi - echo "skipped/failed" - if [ "$STRICT" = true ]; then - return 1 - fi - return 0 -} - -run_required "time" "time now" "$BIN" time now --json >/dev/null - -if ! skip "auth-alias"; then - alias_name="smoke-$TS" - run_required "auth-alias" "auth alias set" "$BIN" auth alias set "$alias_name" "$ACCOUNT" --json >/dev/null - run_required "auth-alias" "auth alias list" "$BIN" auth alias list --json >/dev/null - run_required "auth-alias" "auth alias unset" "$BIN" auth alias unset "$alias_name" --json >/dev/null -fi - -if ! skip "enable-commands"; then - run_required "enable-commands" "enable-commands allow time" "$BIN" --enable-commands time time now --json >/dev/null - if $BIN --enable-commands time gmail labels list >/dev/null 2>&1; then - echo "Expected enable-commands to block gmail, but it succeeded" >&2 - exit 1 - else - echo "enable-commands block OK" - fi -fi - -if ! skip "gmail"; then - run_required "gmail" "gmail labels list" gog gmail labels list --json >/dev/null - DRAFT_JSON=$(gog gmail drafts create --to "$ACCOUNT" --subject "gogcli smoke $TS" --body "smoke" --json) - DRAFT_ID=$(extract_field "$DRAFT_JSON" draftId) - [ -n "$DRAFT_ID" ] || { echo "Failed to parse draft id" >&2; exit 1; } - run_required "gmail" "gmail drafts get" gog gmail drafts get "$DRAFT_ID" --json >/dev/null - run_required "gmail" "gmail drafts delete" gog gmail drafts delete "$DRAFT_ID" --force >/dev/null -fi - -if ! skip "drive"; then - run_required "drive" "drive ls" gog drive ls --json --max 1 >/dev/null - FOLDER_JSON=$(gog drive mkdir "gogcli-smoke-$TS" --json) - FOLDER_ID=$(extract_id "$FOLDER_JSON") - [ -n "$FOLDER_ID" ] || { echo "Failed to parse drive folder id" >&2; exit 1; } - run_required "drive" "drive get folder" gog drive get "$FOLDER_ID" --json >/dev/null - run_required "drive" "drive delete folder" gog drive delete "$FOLDER_ID" --force >/dev/null -fi - -if ! skip "docs"; then - DOC_JSON=$(gog docs create "gogcli-smoke-$TS" --json) - DOC_ID=$(extract_id "$DOC_JSON") - [ -n "$DOC_ID" ] || { echo "Failed to parse doc id" >&2; exit 1; } - run_required "docs" "drive get doc" gog drive get "$DOC_ID" --json >/dev/null - run_required "docs" "drive delete doc" gog drive delete "$DOC_ID" --force >/dev/null -fi - -if ! skip "sheets"; then - SHEET_JSON=$(gog sheets create "gogcli-smoke-$TS" --json) - SHEET_ID=$(extract_id "$SHEET_JSON") - [ -n "$SHEET_ID" ] || { echo "Failed to parse sheet id" >&2; exit 1; } - run_required "sheets" "drive get sheet" gog drive get "$SHEET_ID" --json >/dev/null - run_required "sheets" "drive delete sheet" gog drive delete "$SHEET_ID" --force >/dev/null -fi - -if ! skip "slides"; then - SLIDES_JSON=$(gog slides create "gogcli-smoke-$TS" --json) - SLIDES_ID=$(extract_id "$SLIDES_JSON") - [ -n "$SLIDES_ID" ] || { echo "Failed to parse slides id" >&2; exit 1; } - run_required "slides" "drive get slides" gog drive get "$SLIDES_ID" --json >/dev/null - run_required "slides" "drive delete slides" gog drive delete "$SLIDES_ID" --force >/dev/null -fi - -if ! skip "calendar"; then - read -r START END DAY1 DAY2 <<<"$($PY - <<'PY' +read -r START END DAY1 DAY2 <<<"$($PY - <<'PY' import datetime now=datetime.datetime.now(datetime.timezone.utc).replace(minute=0, second=0, microsecond=0) start=now + datetime.timedelta(hours=1) @@ -315,46 +148,17 @@ print(start.strftime('%Y-%m-%dT%H:%M:%SZ'), end.strftime('%Y-%m-%dT%H:%M:%SZ'), PY )" - EV_JSON=$(gog calendar create primary --summary "gogcli-smoke-$TS" --from "$START" --to "$END" --json) - EV_ID=$(extract_id "$EV_JSON") - [ -n "$EV_ID" ] || { echo "Failed to parse calendar event id" >&2; exit 1; } - run_required "calendar" "calendar event get" gog calendar event primary "$EV_ID" --json >/dev/null - run_required "calendar" "calendar propose-time" gog calendar propose-time primary "$EV_ID" --json >/dev/null - run_required "calendar" "calendar delete event" gog calendar delete primary "$EV_ID" --force >/dev/null - - if ! skip "calendar-enterprise"; then - run_optional "calendar-enterprise" "calendar focus-time" gog calendar create primary --event-type focus-time --from "$START" --to "$END" --json >/dev/null 2>&1 || true - run_optional "calendar-enterprise" "calendar out-of-office" gog calendar create primary --event-type out-of-office --from "$DAY1" --to "$DAY2" --all-day --json >/dev/null 2>&1 || true - run_optional "calendar-enterprise" "calendar working-location" gog calendar create primary --event-type working-location --working-location-type office --working-office-label "HQ" --from "$DAY1" --to "$DAY2" --json >/dev/null 2>&1 || true - fi -fi - -if ! skip "tasks"; then - LIST_JSON=$(gog tasks lists --json --max 1) - LIST_ID=$(extract_tasklist_id "$LIST_JSON") - [ -n "$LIST_ID" ] || { echo "No task list found" >&2; exit 1; } - TASK_JSON=$(gog tasks add "$LIST_ID" --title "gogcli-smoke-$TS" --due "$DAY1" --repeat daily --repeat-count 2 --json) - TASK_IDS=$(extract_task_ids "$TASK_JSON") - [ -n "$TASK_IDS" ] || { echo "Failed to parse task ids" >&2; exit 1; } - FIRST_TASK_ID=$(echo "$TASK_IDS" | head -n1) - run_required "tasks" "tasks get" gog tasks get "$LIST_ID" "$FIRST_TASK_ID" --json >/dev/null - while IFS= read -r tid; do - [ -n "$tid" ] && run_required "tasks" "tasks delete" gog tasks delete "$LIST_ID" "$tid" --force >/dev/null - done <<<"$TASK_IDS" -fi - -if ! skip "contacts"; then - run_required "contacts" "contacts list" gog contacts list --json --max 1 >/dev/null - CONTACT_JSON=$(gog contacts create --given "gogcli" --family "smoke-$TS" --email "gogcli-smoke-$TS@example.com" --json) - CONTACT_ID=$(extract_field "$CONTACT_JSON" resourceName) - [ -n "$CONTACT_ID" ] || { echo "Failed to parse contact resourceName" >&2; exit 1; } - run_required "contacts" "contacts delete" gog contacts delete "$CONTACT_ID" --force >/dev/null -fi - -run_required "people" "people me" gog people me --json >/dev/null - -run_optional "groups" "groups list" gog groups list --json --max 1 >/dev/null 2>&1 -run_optional "keep" "keep list" gog keep list --json >/dev/null 2>&1 -run_optional "classroom" "classroom list" gog classroom courses list --json --max 1 >/dev/null 2>&1 +run_core_tests +run_gmail_tests +run_drive_tests +run_docs_tests +run_sheets_tests +run_slides_tests +run_calendar_tests +run_tasks_tests +run_contacts_tests +run_people_tests +run_workspace_tests +run_classroom_tests echo "Live tests complete." diff --git a/scripts/live-tests/calendar.sh b/scripts/live-tests/calendar.sh new file mode 100644 index 0000000..88790b1 --- /dev/null +++ b/scripts/live-tests/calendar.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash + +set -euo pipefail + +run_calendar_tests() { + if skip "calendar"; then + echo "==> calendar (skipped)" + return 0 + fi + + read -r START END DAY1 DAY2 <<<"$($PY - <<'PY' +import datetime +now=datetime.datetime.now(datetime.timezone.utc).replace(minute=0, second=0, microsecond=0) +start=now + datetime.timedelta(hours=1) +end=start + datetime.timedelta(hours=1) +print(start.strftime('%Y-%m-%dT%H:%M:%SZ'), end.strftime('%Y-%m-%dT%H:%M:%SZ'), start.strftime('%Y-%m-%d'), (start+datetime.timedelta(days=1)).strftime('%Y-%m-%d')) +PY +)" + + run_required "calendar" "calendar list" gog calendar calendars --json --max 1 >/dev/null + run_required "calendar" "calendar acl" gog calendar acl primary --json --max 1 >/dev/null + run_required "calendar" "calendar colors" gog calendar colors --json >/dev/null + run_required "calendar" "calendar time" gog calendar time --json >/dev/null + + local ev_json ev_id + ev_json=$(gog calendar create primary --summary "gogcli-smoke-$TS" --from "$START" --to "$END" --location "Test" --send-updates none --json) + ev_id=$(extract_id "$ev_json") + [ -n "$ev_id" ] || { echo "Failed to parse calendar event id" >&2; exit 1; } + + run_required "calendar" "calendar event get" gog calendar event primary "$ev_id" --json >/dev/null + run_required "calendar" "calendar update" gog calendar update primary "$ev_id" --summary "gogcli-smoke-updated-$TS" --json >/dev/null + run_required "calendar" "calendar events list" gog calendar events primary --from "$START" --to "$END" --json --max 5 >/dev/null + run_required "calendar" "calendar search" gog calendar search "gogcli-smoke" --from "$START" --to "$END" --json --max 5 >/dev/null + run_required "calendar" "calendar freebusy" gog calendar freebusy primary --from "$START" --to "$END" --json >/dev/null + run_required "calendar" "calendar conflicts" gog calendar conflicts --from "$START" --to "$END" --json >/dev/null + + run_optional "calendar-respond" "calendar respond" gog calendar respond primary "$ev_id" --status accepted --json >/dev/null + + run_required "calendar" "calendar delete event" gog calendar delete primary "$ev_id" --force >/dev/null + + if ! skip "calendar-enterprise"; then + run_optional "calendar-enterprise" "calendar focus-time" gog calendar create primary --event-type focus-time --from "$START" --to "$END" --json >/dev/null 2>&1 || true + run_optional "calendar-enterprise" "calendar out-of-office" gog calendar create primary --event-type out-of-office --from "$DAY1" --to "$DAY2" --all-day --json >/dev/null 2>&1 || true + run_optional "calendar-enterprise" "calendar working-location" gog calendar create primary --event-type working-location --working-location-type office --working-office-label "HQ" --from "$DAY1" --to "$DAY2" --json >/dev/null 2>&1 || true + fi + + if [ -n "${GOG_LIVE_GROUP_EMAIL:-}" ]; then + run_optional "calendar-team" "calendar team" gog calendar team "$GOG_LIVE_GROUP_EMAIL" --json --max 5 >/dev/null + fi + + run_optional "calendar-users" "calendar users list" gog calendar users --json --max 1 >/dev/null +} diff --git a/scripts/live-tests/classroom.sh b/scripts/live-tests/classroom.sh new file mode 100644 index 0000000..b9c2c12 --- /dev/null +++ b/scripts/live-tests/classroom.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash + +set -euo pipefail + +run_classroom_tests() { + if skip "classroom"; then + echo "==> classroom (skipped)" + return 0 + fi + + run_optional "classroom" "classroom profile get" gog classroom profile get --json >/dev/null + run_optional "classroom" "classroom courses list" gog classroom courses list --json --max 1 >/dev/null + + if [ -n "${GOG_LIVE_CLASSROOM_COURSE:-}" ]; then + local course_id cw_json cw_id + course_id="$GOG_LIVE_CLASSROOM_COURSE" + run_optional "classroom" "classroom courses get" gog classroom courses get "$course_id" --json >/dev/null + run_optional "classroom" "classroom courses url" gog classroom courses url "$course_id" --json >/dev/null + run_optional "classroom" "classroom roster" gog classroom roster "$course_id" --students --teachers --max 1 --json >/dev/null + run_optional "classroom" "classroom students list" gog classroom students "$course_id" --max 1 --json >/dev/null + run_optional "classroom" "classroom teachers list" gog classroom teachers "$course_id" --max 1 --json >/dev/null + run_optional "classroom" "classroom coursework list" gog classroom coursework "$course_id" --max 1 --json >/dev/null + run_optional "classroom" "classroom materials list" gog classroom materials "$course_id" --max 1 --json >/dev/null + run_optional "classroom" "classroom announcements list" gog classroom announcements "$course_id" --max 1 --json >/dev/null + run_optional "classroom" "classroom topics list" gog classroom topics "$course_id" --max 1 --json >/dev/null + + cw_json=$(gog classroom coursework "$course_id" --max 1 --json 2>/dev/null || true) + cw_id=$(extract_id "$cw_json") + if [ -n "$cw_id" ]; then + run_optional "classroom" "classroom submissions list" gog classroom submissions "$course_id" "$cw_id" --max 1 --json >/dev/null + fi + else + if [ "${STRICT:-false}" = true ]; then + echo "Missing GOG_LIVE_CLASSROOM_COURSE for classroom coverage." >&2 + return 1 + fi + echo "==> classroom (optional; set GOG_LIVE_CLASSROOM_COURSE to expand)" + fi + + if [ -n "${GOG_LIVE_CLASSROOM_CREATE:-}" ]; then + local course_json course_id topic_json topic_id announcement_json announcement_id material_json material_id coursework_json coursework_id + + echo "==> classroom courses create" + course_json=$(gog classroom courses create --name "gogcli-smoke-$TS" --section "gogcli" --state ACTIVE --json) + course_id=$(extract_id "$course_json") + [ -n "$course_id" ] || { echo "Failed to parse course id" >&2; exit 1; } + + run_required "classroom" "classroom courses update" gog classroom courses update "$course_id" --name "gogcli-smoke-updated-$TS" --json >/dev/null + run_required "classroom" "classroom courses archive" gog classroom courses archive "$course_id" --json >/dev/null + run_required "classroom" "classroom courses unarchive" gog classroom courses unarchive "$course_id" --json >/dev/null + + echo "==> classroom topics create" + topic_json=$(gog classroom topics create "$course_id" --name "gogcli topic $TS" --json) + topic_id=$(extract_id "$topic_json") + + echo "==> classroom announcements create" + announcement_json=$(gog classroom announcements create "$course_id" --text "gogcli announcement $TS" --state DRAFT --json) + announcement_id=$(extract_id "$announcement_json") + + echo "==> classroom materials create" + material_json=$(gog classroom materials create "$course_id" --title "gogcli material $TS" --state DRAFT --json) + material_id=$(extract_id "$material_json") + + echo "==> classroom coursework create" + coursework_json=$(gog classroom coursework create "$course_id" --title "gogcli coursework $TS" --type ASSIGNMENT --state DRAFT --max-points 10 --json) + coursework_id=$(extract_id "$coursework_json") + + if [ -n "$announcement_id" ]; then + run_required "classroom" "classroom announcements update" gog classroom announcements update "$course_id" "$announcement_id" --text "gogcli announcement updated $TS" --json >/dev/null + run_required "classroom" "classroom announcements delete" gog --force classroom announcements delete "$course_id" "$announcement_id" --json >/dev/null + fi + if [ -n "$material_id" ]; then + run_required "classroom" "classroom materials update" gog classroom materials update "$course_id" "$material_id" --title "gogcli material updated $TS" --json >/dev/null + run_required "classroom" "classroom materials delete" gog --force classroom materials delete "$course_id" "$material_id" --json >/dev/null + fi + if [ -n "$coursework_id" ]; then + run_required "classroom" "classroom coursework update" gog classroom coursework update "$course_id" "$coursework_id" --title "gogcli coursework updated $TS" --state DRAFT --json >/dev/null + run_required "classroom" "classroom coursework delete" gog --force classroom coursework delete "$course_id" "$coursework_id" --json >/dev/null + fi + if [ -n "$topic_id" ]; then + run_required "classroom" "classroom topics update" gog classroom topics update "$course_id" "$topic_id" --name "gogcli topic updated $TS" --json >/dev/null + run_required "classroom" "classroom topics delete" gog --force classroom topics delete "$course_id" "$topic_id" --json >/dev/null + fi + + run_required "classroom" "classroom courses delete" gog --force classroom courses delete "$course_id" --json >/dev/null + fi +} diff --git a/scripts/live-tests/common.sh b/scripts/live-tests/common.sh new file mode 100644 index 0000000..d5d4f52 --- /dev/null +++ b/scripts/live-tests/common.sh @@ -0,0 +1,192 @@ +#!/usr/bin/env bash + +set -euo pipefail + +PY="${PYTHON:-python3}" +if ! command -v "$PY" >/dev/null 2>&1; then + PY="python" +fi + +skip() { + local key="$1" + [ -n "${SKIP:-}" ] || return 1 + IFS=',' read -r -a items <<<"$SKIP" + for item in "${items[@]}"; do + if [ "$item" = "$key" ]; then + return 0 + fi + done + return 1 +} + +run_required() { + local key="$1" + local label="$2" + shift 2 + if skip "$key"; then + echo "==> $label (skipped)" + return 0 + fi + echo "==> $label" + "$@" +} + +run_optional() { + local key="$1" + local label="$2" + shift 2 + if skip "$key"; then + echo "==> $label (skipped)" + return 0 + fi + echo "==> $label (optional)" + if "$@"; then + echo "ok" + return 0 + fi + echo "skipped/failed" + if [ "${STRICT:-false}" = true ]; then + return 1 + fi + return 0 +} + +extract_id() { + $PY -c 'import json,sys +obj=json.load(sys.stdin) + +def find_id(x): + if isinstance(x, dict): + for key in ("id", "draftId", "spreadsheetId", "presentationId", "documentId", "topicId"): + if isinstance(x.get(key), str): + return x[key] + for v in x.values(): + r=find_id(v) + if r: + return r + if isinstance(x, list): + for v in x: + r=find_id(v) + if r: + return r + return "" +print(find_id(obj))' <<<"$1" +} + +extract_field() { + local value="$1" + local field="$2" + $PY -c 'import json,sys +obj=json.load(sys.stdin) +key=sys.argv[1] + +def find_field(x, k): + if isinstance(x, dict): + if k in x and isinstance(x[k], str): + return x[k] + for v in x.values(): + r=find_field(v, k) + if r: + return r + if isinstance(x, list): + for v in x: + r=find_field(v, k) + if r: + return r + return "" +print(find_field(obj, key))' "$field" <<<"$value" +} + +extract_tasklist_id() { + $PY -c 'import json,sys +obj=json.load(sys.stdin) +for key in ("tasklists","lists","items"): + if isinstance(obj, dict) and obj.get(key): + print(obj[key][0].get("id","")) + sys.exit(0) +print("")' <<<"$1" +} + +extract_task_ids() { + $PY -c 'import json,sys +obj=json.load(sys.stdin) +ids=[] +if isinstance(obj, dict) and "tasks" in obj: + ids=[t.get("id") for t in obj.get("tasks",[]) if t.get("id")] +elif isinstance(obj, dict) and "task" in obj: + if obj["task"].get("id"): + ids=[obj["task"]["id"]] +print("\n".join(ids))' <<<"$1" +} + +extract_permission_id() { + local value="$1" + local email="$2" + $PY -c 'import json,sys +obj=json.load(sys.stdin) +email=sys.argv[1].lower() +base=email +if "@" in email: + local, domain = email.split("@", 1) + if "+" in local: + base = local.split("+", 1)[0] + "@" + domain +emails={email, base} + +def find_permissions(x): + if isinstance(x, dict): + if isinstance(x.get("permissions"), list): + return x["permissions"] + for v in x.values(): + r = find_permissions(v) + if r is not None: + return r + if isinstance(x, list): + for v in x: + r = find_permissions(v) + if r is not None: + return r + return None + +perms = find_permissions(obj) or [] +for p in perms: + if not isinstance(p, dict): + continue + addr = (p.get("emailAddress") or "").lower() + if addr in emails: + pid = p.get("id") or "" + if pid: + print(pid) + sys.exit(0) +print("")' "$email" <<<"$value" +} + +gog() { + "$BIN" --account "$ACCOUNT" "$@" +} + +is_test_account() { + local a + a=$(echo "$1" | tr 'A-Z' 'a-z') + case "$a" in + *test*|*bot*|*sandbox*|*qa*|*staging*|*dev*|*@example.com) + return 0 + ;; + esac + case "$a" in + *+*) + return 0 + ;; + esac + return 1 +} + +ensure_test_account() { + if [ "${ALLOW_NONTEST:-false}" = true ] || [ -n "${GOG_LIVE_ALLOW_NONTEST:-}" ]; then + return 0 + fi + if ! is_test_account "$ACCOUNT"; then + echo "Refusing to run live tests against non-test account: $ACCOUNT" >&2 + echo "Pass --allow-nontest or set GOG_LIVE_ALLOW_NONTEST=1 to override." >&2 + exit 2 + fi +} diff --git a/scripts/live-tests/contacts.sh b/scripts/live-tests/contacts.sh new file mode 100644 index 0000000..00b2b18 --- /dev/null +++ b/scripts/live-tests/contacts.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash + +set -euo pipefail + +run_contacts_tests() { + if skip "contacts"; then + echo "==> contacts (skipped)" + return 0 + fi + + run_required "contacts" "contacts list" gog contacts list --json --max 1 >/dev/null + + local contact_json contact_id + contact_json=$(gog contacts create --given "gogcli" --family "smoke-$TS" --email "gogcli-smoke-$TS@example.com" --phone "+1555555$TS" --json) + contact_id=$(extract_field "$contact_json" resourceName) + [ -n "$contact_id" ] || { echo "Failed to parse contact resourceName" >&2; exit 1; } + + run_required "contacts" "contacts get" gog contacts get "$contact_id" --json >/dev/null + run_required "contacts" "contacts update" gog contacts update "$contact_id" --given "gogcli" --family "smoke-updated-$TS" --email "gogcli-smoke-$TS@example.com" --json >/dev/null + run_required "contacts" "contacts search" gog contacts search "gogcli-smoke-$TS@example.com" --json --max 1 >/dev/null + run_required "contacts" "contacts delete" gog contacts delete "$contact_id" --force >/dev/null + + run_optional "contacts-directory" "contacts directory list" gog contacts directory list --json --max 1 >/dev/null + run_optional "contacts-directory" "contacts directory search" gog contacts directory search "gogcli" --json --max 1 >/dev/null + run_optional "contacts-other" "contacts other list" gog contacts other list --json --max 1 >/dev/null + run_optional "contacts-other" "contacts other search" gog contacts other search "gogcli" --json --max 1 >/dev/null +} diff --git a/scripts/live-tests/core.sh b/scripts/live-tests/core.sh new file mode 100644 index 0000000..ee12192 --- /dev/null +++ b/scripts/live-tests/core.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash + +set -euo pipefail + +run_core_tests() { + run_required "time" "time now" "$BIN" time now --json >/dev/null + + if ! skip "auth-alias"; then + local alias_name + alias_name="smoke-$TS" + run_required "auth-alias" "auth alias set" "$BIN" auth alias set "$alias_name" "$ACCOUNT" --json >/dev/null + run_required "auth-alias" "auth alias list" "$BIN" auth alias list --json >/dev/null + run_required "auth-alias" "auth alias unset" "$BIN" auth alias unset "$alias_name" --json >/dev/null + fi + + if ! skip "enable-commands"; then + run_required "enable-commands" "enable-commands allow time" "$BIN" --enable-commands time time now --json >/dev/null + if $BIN --enable-commands time gmail labels list >/dev/null 2>&1; then + echo "Expected enable-commands to block gmail, but it succeeded" >&2 + exit 1 + else + echo "enable-commands block OK" + fi + fi +} diff --git a/scripts/live-tests/docs.sh b/scripts/live-tests/docs.sh new file mode 100644 index 0000000..0c6176a --- /dev/null +++ b/scripts/live-tests/docs.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash + +set -euo pipefail + +run_docs_tests() { + if skip "docs"; then + echo "==> docs (skipped)" + return 0 + fi + + local doc_json doc_id copy_json copy_id export_path + doc_json=$(gog docs create "gogcli-smoke-doc-$TS" --json) + doc_id=$(extract_id "$doc_json") + [ -n "$doc_id" ] || { echo "Failed to parse doc id" >&2; exit 1; } + + run_required "docs" "docs info" gog docs info "$doc_id" --json >/dev/null + run_required "docs" "docs cat" gog docs cat "$doc_id" >/dev/null + + export_path="$LIVE_TMP/docs-export-$TS.pdf" + run_required "docs" "docs export" gog docs export "$doc_id" --format pdf --out "$export_path" >/dev/null + + copy_json=$(gog docs copy "$doc_id" "gogcli-smoke-doc-copy-$TS" --json) + copy_id=$(extract_id "$copy_json") + [ -n "$copy_id" ] || { echo "Failed to parse doc copy id" >&2; exit 1; } + + run_required "docs" "drive delete doc copy" gog drive delete "$copy_id" --force >/dev/null + run_required "docs" "drive delete doc" gog drive delete "$doc_id" --force >/dev/null +} diff --git a/scripts/live-tests/drive.sh b/scripts/live-tests/drive.sh new file mode 100644 index 0000000..8041beb --- /dev/null +++ b/scripts/live-tests/drive.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash + +set -euo pipefail + +run_drive_tests() { + if skip "drive"; then + echo "==> drive (skipped)" + return 0 + fi + + run_required "drive" "drive ls" gog drive ls --json --max 1 >/dev/null + run_optional "drive" "drive drives list" gog drive drives --json --max 1 >/dev/null + + local folder_a_json folder_b_json folder_a_id folder_b_id + folder_a_json=$(gog drive mkdir "gogcli-smoke-a-$TS" --json) + folder_a_id=$(extract_id "$folder_a_json") + [ -n "$folder_a_id" ] || { echo "Failed to parse folder A id" >&2; exit 1; } + folder_b_json=$(gog drive mkdir "gogcli-smoke-b-$TS" --json) + folder_b_id=$(extract_id "$folder_b_json") + [ -n "$folder_b_id" ] || { echo "Failed to parse folder B id" >&2; exit 1; } + + local upload_path upload_json file_id + upload_path="$LIVE_TMP/drive-upload-$TS.txt" + printf "drive upload %s\n" "$TS" >"$upload_path" + upload_json=$(gog drive upload "$upload_path" --parent "$folder_a_id" --name "gogcli-smoke-$TS.txt" --json) + file_id=$(extract_id "$upload_json") + [ -n "$file_id" ] || { echo "Failed to parse uploaded file id" >&2; exit 1; } + + run_required "drive" "drive get file" gog drive get "$file_id" --json >/dev/null + run_required "drive" "drive rename" gog drive rename "$file_id" "gogcli-smoke-renamed-$TS.txt" >/dev/null + + local copy_json copy_id + copy_json=$(gog drive copy "$file_id" "gogcli-smoke-copy-$TS.txt" --json) + copy_id=$(extract_id "$copy_json") + [ -n "$copy_id" ] || { echo "Failed to parse copy id" >&2; exit 1; } + + run_required "drive" "drive move" gog drive move "$file_id" --parent "$folder_b_id" --json >/dev/null + run_required "drive" "drive search" gog drive search "name contains 'gogcli-smoke'" --json --max 1 >/dev/null + + run_required "drive" "drive permissions" gog drive permissions "$file_id" --json >/dev/null + + local share_json perm_id perms_json + share_json=$(gog drive share "$file_id" --email "$EMAIL_TEST" --role reader --json) + perms_json=$(gog drive permissions "$file_id" --json --max 50) + perm_id=$(extract_permission_id "$perms_json" "$EMAIL_TEST") + if [ -z "$perm_id" ]; then + perm_id=$(extract_field "$share_json" permissionId) + fi + [ -n "$perm_id" ] || { echo "Failed to parse permission id" >&2; exit 1; } + run_required "drive" "drive unshare" gog drive unshare "$file_id" "$perm_id" --force >/dev/null + + run_required "drive" "drive url" gog drive url "$file_id" --json >/dev/null + + local comment_json comment_id + comment_json=$(gog drive comments create "$file_id" "gogcli comment $TS" --json) + comment_id=$(extract_id "$comment_json") + [ -n "$comment_id" ] || { echo "Failed to parse comment id" >&2; exit 1; } + run_required "drive" "drive comments get" gog drive comments get "$file_id" "$comment_id" --json >/dev/null + run_required "drive" "drive comments list" gog drive comments list "$file_id" --json >/dev/null + run_required "drive" "drive comments update" gog drive comments update "$file_id" "$comment_id" "gogcli comment updated $TS" --json >/dev/null + run_required "drive" "drive comments reply" gog drive comments reply "$file_id" "$comment_id" "gogcli reply $TS" --json >/dev/null + run_required "drive" "drive comments delete" gog drive comments delete "$file_id" "$comment_id" --force >/dev/null + + local download_path + download_path="$LIVE_TMP/drive-download-$TS.txt" + run_required "drive" "drive download" gog drive download "$file_id" --out "$download_path" >/dev/null + + run_required "drive" "drive delete copy" gog drive delete "$copy_id" --force >/dev/null + run_required "drive" "drive delete file" gog drive delete "$file_id" --force >/dev/null + run_required "drive" "drive delete folder A" gog drive delete "$folder_a_id" --force >/dev/null + run_required "drive" "drive delete folder B" gog drive delete "$folder_b_id" --force >/dev/null +} diff --git a/scripts/live-tests/gmail.sh b/scripts/live-tests/gmail.sh new file mode 100644 index 0000000..48671c8 --- /dev/null +++ b/scripts/live-tests/gmail.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash + +set -euo pipefail + +run_gmail_tests() { + if skip "gmail"; then + echo "==> gmail (skipped)" + return 0 + fi + + run_required "gmail" "gmail labels list" gog gmail labels list --json >/dev/null + run_required "gmail" "gmail labels get" gog gmail labels get INBOX --json >/dev/null + + if ! skip "gmail-settings"; then + run_required "gmail" "gmail settings sendas list" gog gmail settings sendas list --json >/dev/null + run_required "gmail" "gmail settings vacation get" gog gmail settings vacation get --json >/dev/null + run_required "gmail" "gmail settings filters list" gog gmail settings filters list --json >/dev/null + run_optional "gmail-delegates" "gmail settings delegates list" gog gmail settings delegates list --json >/dev/null + run_required "gmail" "gmail settings forwarding list" gog gmail settings forwarding list --json >/dev/null + run_required "gmail" "gmail settings autoforward get" gog gmail settings autoforward get --json >/dev/null + fi + + local draft_json draft_id sent_draft_json sent_draft_msg_id + draft_json=$(gog gmail drafts create --to "$EMAIL_TEST" --subject "gogcli smoke draft $TS" --body "smoke draft" --json) + draft_id=$(extract_field "$draft_json" draftId) + [ -n "$draft_id" ] || { echo "Failed to parse draft id" >&2; exit 1; } + run_required "gmail" "gmail drafts get" gog gmail drafts get "$draft_id" --json >/dev/null + run_required "gmail" "gmail drafts update" gog gmail drafts update "$draft_id" --subject "gogcli smoke draft updated $TS" --body "updated" --json >/dev/null + sent_draft_json=$(gog gmail drafts send "$draft_id" --json) + sent_draft_msg_id=$(extract_field "$sent_draft_json" messageId) + [ -n "$sent_draft_msg_id" ] || { echo "Failed to parse sent draft message id" >&2; exit 1; } + + local body_file send_json send_msg_id send_thread_id + body_file="$LIVE_TMP/gmail-body-$TS.txt" + printf "hello from gogcli %s\n" "$TS" >"$body_file" + send_json=$(gog gmail send --to "$EMAIL_TEST" --subject "gogcli smoke send $TS" --body-file "$body_file" --json) + send_msg_id=$(extract_field "$send_json" messageId) + send_thread_id=$(extract_field "$send_json" threadId) + [ -n "$send_msg_id" ] || { echo "Failed to parse send message id" >&2; exit 1; } + + run_required "gmail" "gmail get message" gog gmail get "$send_msg_id" --format metadata --json >/dev/null + if [ -n "$send_thread_id" ]; then + run_required "gmail" "gmail thread get" gog gmail thread get "$send_thread_id" --json >/dev/null + run_required "gmail" "gmail thread modify add label" gog gmail thread modify "$send_thread_id" --add STARRED --json >/dev/null + run_required "gmail" "gmail thread modify remove label" gog gmail thread modify "$send_thread_id" --remove STARRED --json >/dev/null + fi + + run_required "gmail" "gmail search" gog gmail search "subject:gogcli smoke send $TS" --json >/dev/null + run_required "gmail" "gmail batch modify add" gog gmail batch modify "$send_msg_id" --add STARRED --json >/dev/null + run_required "gmail" "gmail batch modify remove" gog gmail batch modify "$send_msg_id" --remove STARRED --json >/dev/null + + if skip "gmail-batch-delete"; then + echo "==> gmail batch delete (skipped)" + else + echo "==> gmail batch delete" + if gog gmail batch delete "$send_msg_id" "$sent_draft_msg_id" --json >/dev/null; then + : + else + echo "gmail batch delete failed; falling back to trash" >&2 + gog gmail batch modify "$send_msg_id" "$sent_draft_msg_id" --add TRASH --json >/dev/null || true + if [ "${STRICT:-false}" = true ]; then + return 1 + fi + fi + fi + + if [ -n "${GOG_LIVE_TRACK:-}" ]; then + run_optional "gmail-track" "gmail send --track" gog gmail send --to "$EMAIL_TEST" --subject "gogcli smoke track $TS" --body-html "

track $TS

" --track --json >/dev/null + fi +} diff --git a/scripts/live-tests/people.sh b/scripts/live-tests/people.sh new file mode 100644 index 0000000..10a8d98 --- /dev/null +++ b/scripts/live-tests/people.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -euo pipefail + +run_people_tests() { + run_required "people" "people me" gog people me --json >/dev/null +} diff --git a/scripts/live-tests/sheets.sh b/scripts/live-tests/sheets.sh new file mode 100644 index 0000000..f071f5c --- /dev/null +++ b/scripts/live-tests/sheets.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash + +set -euo pipefail + +run_sheets_tests() { + if skip "sheets"; then + echo "==> sheets (skipped)" + return 0 + fi + + local sheet_json sheet_id copy_json copy_id export_path + sheet_json=$(gog sheets create "gogcli-smoke-sheet-$TS" --json) + sheet_id=$(extract_id "$sheet_json") + [ -n "$sheet_id" ] || { echo "Failed to parse sheet id" >&2; exit 1; } + + run_required "sheets" "sheets metadata" gog sheets metadata "$sheet_id" --json >/dev/null + run_required "sheets" "sheets update" gog sheets update "$sheet_id" "Sheet1!A1:B2" --values-json '[["A1","B1"],["A2","B2"]]' --json >/dev/null + run_required "sheets" "sheets get" gog sheets get "$sheet_id" "Sheet1!A1:B2" --json >/dev/null + run_required "sheets" "sheets append" gog sheets append "$sheet_id" "Sheet1!A3:B3" --values-json '[["A3","B3"]]' --json >/dev/null + run_required "sheets" "sheets format" gog sheets format "$sheet_id" "Sheet1!A1:B1" --format-json '{"textFormat":{"bold":true}}' --format-fields textFormat.bold --json >/dev/null + run_required "sheets" "sheets clear" gog sheets clear "$sheet_id" "Sheet1!A1:B3" --json >/dev/null + + export_path="$LIVE_TMP/sheets-export-$TS.xlsx" + run_required "sheets" "sheets export" gog sheets export "$sheet_id" --format xlsx --out "$export_path" >/dev/null + + copy_json=$(gog sheets copy "$sheet_id" "gogcli-smoke-sheet-copy-$TS" --json) + copy_id=$(extract_id "$copy_json") + [ -n "$copy_id" ] || { echo "Failed to parse sheet copy id" >&2; exit 1; } + + run_required "sheets" "drive delete sheet copy" gog drive delete "$copy_id" --force >/dev/null + run_required "sheets" "drive delete sheet" gog drive delete "$sheet_id" --force >/dev/null +} diff --git a/scripts/live-tests/slides.sh b/scripts/live-tests/slides.sh new file mode 100644 index 0000000..d607d87 --- /dev/null +++ b/scripts/live-tests/slides.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash + +set -euo pipefail + +run_slides_tests() { + if skip "slides"; then + echo "==> slides (skipped)" + return 0 + fi + + local slides_json slides_id copy_json copy_id export_path + slides_json=$(gog slides create "gogcli-smoke-slides-$TS" --json) + slides_id=$(extract_id "$slides_json") + [ -n "$slides_id" ] || { echo "Failed to parse slides id" >&2; exit 1; } + + run_required "slides" "slides info" gog slides info "$slides_id" --json >/dev/null + + export_path="$LIVE_TMP/slides-export-$TS.pdf" + run_required "slides" "slides export" gog slides export "$slides_id" --format pdf --out "$export_path" >/dev/null + + copy_json=$(gog slides copy "$slides_id" "gogcli-smoke-slides-copy-$TS" --json) + copy_id=$(extract_id "$copy_json") + [ -n "$copy_id" ] || { echo "Failed to parse slides copy id" >&2; exit 1; } + + run_required "slides" "drive delete slides copy" gog drive delete "$copy_id" --force >/dev/null + run_required "slides" "drive delete slides" gog drive delete "$slides_id" --force >/dev/null +} diff --git a/scripts/live-tests/tasks.sh b/scripts/live-tests/tasks.sh new file mode 100644 index 0000000..83ac06f --- /dev/null +++ b/scripts/live-tests/tasks.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash + +set -euo pipefail + +run_tasks_tests() { + if skip "tasks"; then + echo "==> tasks (skipped)" + return 0 + fi + + run_required "tasks" "tasks lists list" gog tasks lists list --json --max 1 >/dev/null + + local list_json list_id + list_json=$(gog tasks lists list --json --max 1) + list_id=$(extract_tasklist_id "$list_json") + [ -n "$list_id" ] || { echo "No task list found" >&2; exit 1; } + + run_required "tasks" "tasks list" gog tasks list "$list_id" --json --max 1 >/dev/null + + local task_json task_id + task_json=$(gog tasks add "$list_id" --title "gogcli-smoke-$TS" --due "$DAY1" --json) + task_id=$(extract_id "$task_json") + [ -n "$task_id" ] || { echo "Failed to parse task id" >&2; exit 1; } + + run_required "tasks" "tasks get" gog tasks get "$list_id" "$task_id" --json >/dev/null + run_required "tasks" "tasks update" gog tasks update "$list_id" "$task_id" --title "gogcli-smoke-updated-$TS" --json >/dev/null + run_required "tasks" "tasks done" gog tasks done "$list_id" "$task_id" --json >/dev/null + run_required "tasks" "tasks undo" gog tasks undo "$list_id" "$task_id" --json >/dev/null + run_required "tasks" "tasks delete" gog tasks delete "$list_id" "$task_id" --force >/dev/null + + local repeat_json repeat_ids + repeat_json=$(gog tasks add "$list_id" --title "gogcli-smoke-repeat-$TS" --due "$DAY1" --repeat daily --repeat-count 2 --json) + repeat_ids=$(extract_task_ids "$repeat_json") + [ -n "$repeat_ids" ] || { echo "Failed to parse repeat task ids" >&2; exit 1; } + while IFS= read -r tid; do + [ -n "$tid" ] && run_required "tasks" "tasks delete repeat" gog tasks delete "$list_id" "$tid" --force >/dev/null + done <<<"$repeat_ids" + + local done_json done_id + done_json=$(gog tasks add "$list_id" --title "gogcli-smoke-done-$TS" --due "$DAY1" --json) + done_id=$(extract_id "$done_json") + [ -n "$done_id" ] || { echo "Failed to parse done task id" >&2; exit 1; } + run_required "tasks" "tasks done (for clear)" gog tasks done "$list_id" "$done_id" --json >/dev/null + run_required "tasks" "tasks clear" gog --force tasks clear "$list_id" --json >/dev/null +} diff --git a/scripts/live-tests/workspace.sh b/scripts/live-tests/workspace.sh new file mode 100644 index 0000000..4736cfa --- /dev/null +++ b/scripts/live-tests/workspace.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash + +set -euo pipefail + +run_workspace_tests() { + run_optional "groups" "groups list" gog groups list --json --max 5 >/dev/null + + if [ -n "${GOG_LIVE_GROUP_EMAIL:-}" ]; then + run_optional "groups" "groups members" gog groups members "$GOG_LIVE_GROUP_EMAIL" --json --max 5 >/dev/null + fi + + if skip "keep"; then + echo "==> keep (skipped)" + return 0 + fi + + if [ -z "${GOG_KEEP_SERVICE_ACCOUNT:-}" ] || [ -z "${GOG_KEEP_IMPERSONATE:-}" ]; then + if [ "${STRICT:-false}" = true ]; then + echo "Missing GOG_KEEP_SERVICE_ACCOUNT/GOG_KEEP_IMPERSONATE for keep tests." >&2 + return 1 + fi + echo "==> keep (optional; set GOG_KEEP_SERVICE_ACCOUNT and GOG_KEEP_IMPERSONATE)" + return 0 + fi + + run_optional "keep" "keep list" gog keep list --service-account "$GOG_KEEP_SERVICE_ACCOUNT" --impersonate "$GOG_KEEP_IMPERSONATE" --json --max 5 >/dev/null + run_optional "keep" "keep search" gog keep search "gogcli" --service-account "$GOG_KEEP_SERVICE_ACCOUNT" --impersonate "$GOG_KEEP_IMPERSONATE" --json >/dev/null +}