Skip to content

Commit 9d808ea

Browse files
authored
Add smoke tests (#37)
1 parent ff3dbbf commit 9d808ea

40 files changed

Lines changed: 2254 additions & 149 deletions

.golangci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ version: "2"
22

33
run:
44
build-tags:
5-
- integration
5+
- smoke
66

77
linters:
88
enable:

.surface

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ hey compose --subject
3535
hey compose --thread-id
3636
hey compose --to
3737
hey config
38+
hey config set
3839
hey config show
3940
hey doctor
4041
hey drafts

AGENTS.md

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,60 @@ The server code is located at `~/Work/basecamp/haystack/`, feel free to read it
7171

7272
If you don't understand how the routes are laid out you can call rails routes in that directory to get a list of all the routes and their corresponding controller actions.
7373

74-
### Testing
74+
### Unit Testing
7575

7676
Whenever you add, remove or change any functionality add/remove/change tests as well. Tests are located in the same package as the code they test, with filenames ending in `_test.go`. Run `make test` to run all tests.
7777

78+
### Smoke Testing
79+
80+
Smoke tests verify all CLI commands against a real HEY server. They live in `tests/smoke/` as a separate Go module and use a pre-compiled binary built by `make build`.
81+
82+
**What they test:** Every CLI command and its flags — boxes, box, compose, reply, threads, drafts, calendars, recordings, todo, journal, habit, timetrack, seen/unseen, config, auth, and all output format flags (--json, --quiet, --ids-only, --count, --markdown, --styled, --verbose, --stats). Browser-based cross-verification tests confirm CLI actions are visible in the browser and vice versa.
83+
84+
**Running:**
85+
86+
```bash
87+
make test-smoke # Builds binary then runs tests (requires dev server)
88+
```
89+
90+
The dev server must be running at `http://app.hey.localhost:3003` (override with `HEY_SMOKE_BASE_URL`). Default login: `david@basecamp.com` / `secret123456` (override with `HEY_SMOKE_EMAIL` and `HEY_SMOKE_PASSWORD`).
91+
92+
**How they work:**
93+
94+
1. `TestMain` in `helpers_test.go` orchestrates setup: finds the binary, checks server reachability, launches headless Chrome via chromedp to log in and extract the `session_token` cookie, then authenticates the CLI with `hey auth login --cookie`.
95+
2. All CLI invocations run in an isolated environment: temp `XDG_CONFIG_HOME`, `HEY_NO_KEYRING=1`, `HEY_BASE_URL` pointing to the dev server.
96+
3. Helper functions (`hey()`, `heyOK()`, `heyJSON()`, `heyFail()`) run the binary and parse output. `dataAs[T]()` generically unmarshals response data.
97+
4. Tests that depend on write operations (compose, todo add, journal write, reply, timetrack start) skip gracefully when the server returns errors, since the SDK's parameter format may not match the server's expectations.
98+
5. Test data uses `uniqueID()` (nanosecond timestamps) to avoid collisions. Cleanup happens via `t.Cleanup()`.
99+
100+
**How to add a new test:**
101+
102+
1. Create or edit a `*_test.go` file in `tests/smoke/` (package `smoke_test`).
103+
2. Use `heyJSON(t, "command", "args...")` for commands that should succeed and return JSON.
104+
3. Use `heyFail(t, "command", "args...")` for commands that should fail.
105+
4. For write operations that may fail server-side, use `hey(t, ...)` directly and skip on non-zero exit: `if code != 0 { t.Skipf("... (exit %d): %s", code, stderr) }`.
106+
5. Use `dataAs[T](t, resp)` to unmarshal response data into typed structs.
107+
6. For browser cross-verification, use `browserPageText(t, url)` to get page content.
108+
109+
**File layout:**
110+
111+
| File | What it covers |
112+
|------|---------------|
113+
| `helpers_test.go` | Setup, CLI runners, browser helpers, assertions |
114+
| `util_test.go` | Shared utilities (intStr, extractTopicID) |
115+
| `auth_test.go` | auth status/login/logout/token/refresh |
116+
| `boxes_test.go` | boxes list, box by name/ID, --limit, --all |
117+
| `compose_test.go` | compose, threads, reply, drafts |
118+
| `todo_test.go` | todo CRUD, --date, --limit, --all |
119+
| `calendar_test.go` | calendars, recordings with date ranges/limits |
120+
| `journal_test.go` | journal write/read/list with limits |
121+
| `timetrack_test.go` | timetrack start/stop/current/list |
122+
| `habit_test.go` | habit complete/uncomplete with --date |
123+
| `seen_test.go` | seen/unseen single and batch |
124+
| `config_test.go` | config show/set with validation |
125+
| `output_test.go` | all output format flags |
126+
| `browser_test.go` | CLI-to-browser and browser-to-CLI cross-verification |
127+
78128
### Running
79129

80130
To run the cli use `make build` and then `./bin/hey`. This ensures that you and I are running the same version of the program.

Makefile

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: build build-pgo test test-unit fmt fmt-check vet lint tidy tidy-check \
1+
.PHONY: build build-pgo test test-unit test-smoke fmt fmt-check vet lint tidy tidy-check \
22
race-test vuln secrets replace-check check-toolchain check security \
33
release-check release bench bench-cpu bench-mem bench-save bench-compare \
44
collect-profile clean-pgo check-surface check-surface-compat tools clean \
@@ -19,6 +19,7 @@ help:
1919
@echo " make build-pgo Build with PGO profile"
2020
@echo " make test-unit Run unit tests"
2121
@echo " make test Alias for test-unit"
22+
@echo " make test-smoke Run smoke tests against a live server"
2223
@echo " make clean Remove build artifacts"
2324
@echo " make tidy Tidy dependencies"
2425
@echo ""
@@ -85,6 +86,12 @@ test-unit: check-toolchain
8586
# Alias for test-unit
8687
test: test-unit
8788

89+
# Run smoke tests against a live HEY server.
90+
# Requires: a running server (default http://app.hey.localhost:3003) and Chrome.
91+
# Override defaults: make test-smoke HEY_SMOKE_BASE_URL=... HEY_SMOKE_EMAIL=... HEY_SMOKE_PASSWORD=...
92+
test-smoke: build
93+
cd tests/smoke && go test -v -count=1 -timeout 5m ./...
94+
8895
# Format Go source
8996
fmt:
9097
gofmt -s -w .

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ require (
66
charm.land/bubbles/v2 v2.0.0
77
charm.land/bubbletea/v2 v2.0.2
88
charm.land/lipgloss/v2 v2.0.1
9-
github.com/basecamp/hey-sdk/go v0.1.0
9+
github.com/basecamp/hey-sdk/go v0.1.1
1010
github.com/mattn/go-runewidth v0.0.21
1111
github.com/spf13/cobra v1.10.2
1212
github.com/spf13/pflag v1.0.10

go.sum

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,15 @@ charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
66
charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
77
charm.land/lipgloss/v2 v2.0.1 h1:6Xzrn49+Py1Um5q/wZG1gWgER2+7dUyZ9XMEufqPSys=
88
charm.land/lipgloss/v2 v2.0.1/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM=
9+
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
10+
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
11+
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
912
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
1013
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
1114
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
1215
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
13-
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
14-
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
15-
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
16-
github.com/basecamp/hey-sdk/go v0.1.0 h1:b2VsJGQh9tFMG1OrZpG7zEnipV33y4VeSf20EDPlWUA=
17-
github.com/basecamp/hey-sdk/go v0.1.0/go.mod h1:vHKyozk7SNjDrGqgdYIelXjiwBk6az0KSC+73Wt3dbI=
16+
github.com/basecamp/hey-sdk/go v0.1.1 h1:IACc6wav+HGjHr1cfB0NuiZw3q5tzy/wDFrHgBniP40=
17+
github.com/basecamp/hey-sdk/go v0.1.1/go.mod h1:vHKyozk7SNjDrGqgdYIelXjiwBk6az0KSC+73Wt3dbI=
1818
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
1919
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
2020
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=

internal/auth/auth.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,16 @@ func (m *Manager) AccessToken(ctx context.Context) (string, error) {
6868
}
6969
}
7070

71-
if creds.AccessToken == "" {
72-
return "", fmt.Errorf("stored credentials have empty access token")
71+
if creds.AccessToken != "" {
72+
return creds.AccessToken, nil
73+
}
74+
75+
// Fall back to session cookie for cookie-based auth.
76+
if creds.SessionCookie != "" {
77+
return creds.SessionCookie, nil
7378
}
7479

75-
return creds.AccessToken, nil
80+
return "", fmt.Errorf("no access token or session cookie available")
7681
}
7782

7883
// AuthenticateRequest sets the appropriate auth header on an HTTP request.
@@ -217,6 +222,11 @@ func (m *Manager) Refresh(ctx context.Context) error {
217222
return fmt.Errorf("not authenticated: %w", err)
218223
}
219224

225+
// Cookie-based auth doesn't support refresh; treat as no-op.
226+
if creds.RefreshToken == "" && creds.SessionCookie != "" {
227+
return nil
228+
}
229+
220230
return m.refreshLocked(ctx, creds)
221231
}
222232

internal/client/entries.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ var (
1717
srcdocRe = regexp.MustCompile(`(?s)srcdoc="([^"]*trix-content[^"]*)"`)
1818
)
1919

20-
func (c *Client) GetTopicEntries(id int) ([]models.Entry, error) {
20+
func (c *Client) GetTopicEntries(id int64) ([]models.Entry, error) {
2121
path := fmt.Sprintf("/topics/%d/entries", id)
2222
data, err := c.GetHTML(path)
2323
if err != nil {
@@ -76,7 +76,7 @@ func parseTopicEntriesHTML(html string) []models.Entry {
7676

7777
entries := make([]models.Entry, 0, len(entryIDs))
7878
for i, eid := range entryIDs {
79-
id, _ := strconv.Atoi(eid)
79+
id, _ := strconv.ParseInt(eid, 10, 64)
8080
e := models.Entry{
8181
ID: id,
8282
CreatedAt: entryTimes[eid],

internal/cmd/compose.go

Lines changed: 4 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@ import (
77

88
"github.com/spf13/cobra"
99

10-
"github.com/basecamp/hey-sdk/go/pkg/generated"
11-
1210
"github.com/basecamp/hey-cli/internal/editor"
1311
"github.com/basecamp/hey-cli/internal/output"
1412
)
@@ -76,20 +74,15 @@ func (c *composeCommand) run(cmd *cobra.Command, args []string) error {
7674
}
7775

7876
ctx := cmd.Context()
79-
var result any
8077

8178
if c.threadID != "" {
8279
topicID, err := strconv.ParseInt(c.threadID, 10, 64)
8380
if err != nil {
8481
return output.ErrUsage(fmt.Sprintf("invalid thread ID: %s", c.threadID))
8582
}
86-
resp, err := sdk.Messages().CreateTopicMessage(ctx, topicID, generated.CreateTopicMessageJSONRequestBody{
87-
Content: message,
88-
})
89-
if err != nil {
83+
if err := sdk.Messages().CreateTopicMessage(ctx, topicID, message); err != nil {
9084
return convertSDKError(err)
9185
}
92-
result = resp
9386
} else {
9487
to := []string{}
9588
if c.to != "" {
@@ -100,25 +93,15 @@ func (c *composeCommand) run(cmd *cobra.Command, args []string) error {
10093
}
10194
}
10295
}
103-
resp, err := sdk.Messages().Create(ctx, generated.CreateMessageJSONRequestBody{
104-
Subject: c.subject,
105-
Content: message,
106-
To: to,
107-
})
108-
if err != nil {
96+
if err := sdk.Messages().Create(ctx, c.subject, message, to); err != nil {
10997
return convertSDKError(err)
11098
}
111-
result = resp
11299
}
113100

114101
if writer.IsStyled() {
115-
fmt.Fprintf(cmd.OutOrStdout(), "Message sent.%s\n", extractMutationInfoFromResult(result))
102+
fmt.Fprintln(cmd.OutOrStdout(), "Message sent.")
116103
return nil
117104
}
118105

119-
normalized, err := normalizeAny(result)
120-
if err != nil {
121-
return writeOK(nil, output.WithSummary("Message sent"))
122-
}
123-
return writeOK(normalized, output.WithSummary("Message sent"))
106+
return writeOK(nil, output.WithSummary("Message sent"))
124107
}

internal/cmd/config.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,45 @@ func newConfigCommand() *configCommand {
2020
}
2121

2222
configCommand.cmd.AddCommand(newConfigShowCommand())
23+
configCommand.cmd.AddCommand(newConfigSetCommand())
2324

2425
return configCommand
2526
}
2627

28+
func newConfigSetCommand() *cobra.Command {
29+
return &cobra.Command{
30+
Use: "set <key> <value>",
31+
Short: "Set a configuration value in the global config",
32+
Example: ` hey config set base_url http://app.hey.localhost:3003
33+
hey config set base_url https://app.hey.com`,
34+
Args: cobra.ExactArgs(2),
35+
RunE: func(cmd *cobra.Command, args []string) error {
36+
key, value := args[0], args[1]
37+
38+
switch key {
39+
case "base_url":
40+
if err := cfg.SetFromFlag(key, value); err != nil {
41+
return err
42+
}
43+
default:
44+
return output.ErrUsage(fmt.Sprintf("unknown config key: %s (available: base_url)", key))
45+
}
46+
47+
if err := cfg.Save(); err != nil {
48+
return err
49+
}
50+
51+
if writer.IsStyled() {
52+
fmt.Fprintf(cmd.OutOrStdout(), "Set %s = %s\n", key, value)
53+
return nil
54+
}
55+
return writeOK(map[string]string{"key": key, "value": value},
56+
output.WithSummary(fmt.Sprintf("Set %s = %s", key, value)),
57+
)
58+
},
59+
}
60+
}
61+
2762
func newConfigShowCommand() *cobra.Command {
2863
return &cobra.Command{
2964
Use: "show",

0 commit comments

Comments
 (0)