Skip to content

Commit 9f62e68

Browse files
committed
Add live Granola end-to-end coverage
1 parent 48322c6 commit 9f62e68

6 files changed

Lines changed: 817 additions & 0 deletions

File tree

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
name: granola-live-e2e
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
paths:
8+
- ".github/workflows/granola-live-e2e.yml"
9+
- "Cargo.toml"
10+
- "Cargo.lock"
11+
- "crates/locality-connector/**"
12+
- "crates/locality-core/**"
13+
- "crates/locality-granola/**"
14+
- "crates/locality-store/**"
15+
- "crates/localityd/**"
16+
- "crates/loc-cli/**"
17+
- "crates/locality-fuse/**"
18+
- "tests/live_granola_vfs_read.sh"
19+
schedule:
20+
- cron: "23 15 * * 2"
21+
workflow_dispatch:
22+
23+
concurrency:
24+
group: granola-live-e2e
25+
cancel-in-progress: false
26+
27+
permissions:
28+
contents: read
29+
30+
jobs:
31+
connector-live:
32+
name: Granola public API live integrity
33+
runs-on: ubuntu-latest
34+
timeout-minutes: 15
35+
environment: granola-live-e2e
36+
env:
37+
GRANOLA_API_KEY: ${{ secrets.GRANOLA_API_KEY }}
38+
LOCALITY_GRANOLA_LIVE_NOTE_ID: ${{ secrets.LOCALITY_GRANOLA_LIVE_NOTE_ID }}
39+
steps:
40+
- uses: actions/checkout@v4
41+
- uses: dtolnay/rust-toolchain@stable
42+
- uses: Swatinem/rust-cache@v2
43+
- name: Check live Granola secrets
44+
shell: bash
45+
run: |
46+
missing=()
47+
[ -n "$GRANOLA_API_KEY" ] || missing+=("GRANOLA_API_KEY")
48+
[ -n "$LOCALITY_GRANOLA_LIVE_NOTE_ID" ] || missing+=("LOCALITY_GRANOLA_LIVE_NOTE_ID")
49+
if [ "${#missing[@]}" -gt 0 ]; then
50+
echo "::error::Live Granola e2e is missing required environment secrets: ${missing[*]}"
51+
exit 1
52+
fi
53+
- name: Run live public API integrity test
54+
run: cargo test -p locality-granola --test live_integrity -- --ignored --test-threads=1
55+
56+
linux-fuse-live:
57+
name: Granola CLI daemon and Linux FUSE live e2e
58+
runs-on: ubuntu-latest
59+
timeout-minutes: 30
60+
environment: granola-live-e2e
61+
env:
62+
GRANOLA_API_KEY: ${{ secrets.GRANOLA_API_KEY }}
63+
LOCALITY_GRANOLA_LIVE_NOTE_ID: ${{ secrets.LOCALITY_GRANOLA_LIVE_NOTE_ID }}
64+
LOCALITY_LIVE_GRANOLA_VFS: "1"
65+
steps:
66+
- uses: actions/checkout@v4
67+
- uses: dtolnay/rust-toolchain@stable
68+
- uses: Swatinem/rust-cache@v2
69+
- name: Install FUSE dependencies
70+
run: |
71+
sudo apt-get update
72+
sudo apt-get install -y \
73+
fuse3 \
74+
libfuse3-dev \
75+
libglib2.0-dev \
76+
libgtk-3-dev \
77+
libwebkit2gtk-4.1-dev \
78+
libayatana-appindicator3-dev \
79+
librsvg2-dev \
80+
patchelf \
81+
pkg-config \
82+
sqlite3
83+
- name: Check live Granola secrets and FUSE device
84+
shell: bash
85+
run: |
86+
missing=()
87+
[ -n "$GRANOLA_API_KEY" ] || missing+=("GRANOLA_API_KEY")
88+
[ -n "$LOCALITY_GRANOLA_LIVE_NOTE_ID" ] || missing+=("LOCALITY_GRANOLA_LIVE_NOTE_ID")
89+
if [ "${#missing[@]}" -gt 0 ]; then
90+
echo "::error::Live Granola e2e is missing required environment secrets: ${missing[*]}"
91+
exit 1
92+
fi
93+
if [ ! -e /dev/fuse ]; then
94+
echo "::error::/dev/fuse is unavailable; the real Linux FUSE e2e cannot run"
95+
exit 1
96+
fi
97+
- name: Run live mounted-filesystem test
98+
run: tests/live_granola_vfs_read.sh

Makefile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,11 @@ test-rust: ## Run all Rust workspace tests.
195195
test-linux-fuse: ## Run the optional Linux FUSE smoke test when enabled by env vars.
196196
tests/linux_fuse_smoke.sh
197197

198+
.PHONY: test-live-granola
199+
test-live-granola: ## Run live Granola API integrity and optional Linux FUSE tests.
200+
$(CARGO) test -p locality-granola --test live_integrity -- --ignored --test-threads=1
201+
tests/live_granola_vfs_read.sh
202+
198203
.PHONY: test-linux-publish-config
199204
test-linux-publish-config: ## Validate Linux package publish configuration.
200205
tests/linux_publish_config.sh
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
use std::collections::BTreeSet;
2+
3+
use chrono::{DateTime, TimeDelta, Utc};
4+
use locality_core::LocalityError;
5+
use locality_granola::{
6+
GranolaApi, GranolaContentKind, GranolaNativeBundle, GranolaNote, HttpGranolaApiClient,
7+
render_granola_note,
8+
};
9+
10+
const LIVE_KEY_ENV: &str = "GRANOLA_API_KEY";
11+
const LIVE_NOTE_ENV: &str = "LOCALITY_GRANOLA_LIVE_NOTE_ID";
12+
const NOTE_SCAN_LIMIT: usize = 12;
13+
14+
#[test]
15+
#[ignore = "requires GRANOLA_API_KEY; reads real Granola notes without mutating them"]
16+
fn live_public_api_paginates_fetches_transcript_and_renders_canonical_files() {
17+
let api_key = required_secret_env(LIVE_KEY_ENV);
18+
let api = HttpGranolaApiClient::new(api_key);
19+
20+
let first_page = api
21+
.list_notes(None, 30, None, None, None)
22+
.unwrap_or_else(|error| panic!("Granola list-notes request failed: {error}"));
23+
assert!(
24+
!first_page.notes.is_empty(),
25+
"Granola list-notes returned no accessible notes"
26+
);
27+
assert!(
28+
first_page.notes.len() <= 30,
29+
"Granola exceeded page_size=30"
30+
);
31+
32+
if first_page.has_more {
33+
let cursor = first_page
34+
.cursor
35+
.as_deref()
36+
.filter(|cursor| !cursor.is_empty())
37+
.expect("Granola reported has_more without a cursor");
38+
let second_page = api
39+
.list_notes(Some(cursor), 30, None, None, None)
40+
.unwrap_or_else(|error| panic!("Granola cursor pagination failed: {error}"));
41+
assert!(
42+
!second_page.notes.is_empty(),
43+
"Granola cursor returned an empty follow-up page"
44+
);
45+
let first_ids = first_page
46+
.notes
47+
.iter()
48+
.map(|note| note.id.as_str())
49+
.collect::<BTreeSet<_>>();
50+
assert!(
51+
second_page
52+
.notes
53+
.iter()
54+
.all(|note| !first_ids.contains(note.id.as_str())),
55+
"Granola cursor pagination repeated a note from the first page"
56+
);
57+
}
58+
59+
let note = live_note_with_transcript(&api, &first_page.notes);
60+
assert!(!note.id.trim().is_empty(), "Granola note id was empty");
61+
assert_eq!(
62+
note.object, "note",
63+
"Granola returned an unexpected object type"
64+
);
65+
assert!(
66+
chrono::DateTime::parse_from_rfc3339(&note.created_at).is_ok(),
67+
"Granola note created_at was not RFC 3339"
68+
);
69+
assert!(
70+
chrono::DateTime::parse_from_rfc3339(&note.updated_at).is_ok(),
71+
"Granola note updated_at was not RFC 3339"
72+
);
73+
assert!(
74+
note.web_url.starts_with("https://"),
75+
"Granola note web_url was not HTTPS"
76+
);
77+
78+
let metadata_only = api.get_note(&note.id, false).unwrap_or_else(|error| {
79+
panic!("Granola get-note request without transcript failed: {error}")
80+
});
81+
assert_eq!(
82+
metadata_only.id, note.id,
83+
"Granola get-note variants returned different note ids"
84+
);
85+
assert!(
86+
metadata_only.title == note.title
87+
&& metadata_only.created_at == note.created_at
88+
&& metadata_only.updated_at == note.updated_at
89+
&& metadata_only.summary_text == note.summary_text
90+
&& metadata_only.summary_markdown == note.summary_markdown,
91+
"Granola transcript inclusion changed note metadata or summary fields"
92+
);
93+
94+
let summary = render_granola_note(&GranolaNativeBundle {
95+
content_kind: GranolaContentKind::Summary,
96+
note: note.clone(),
97+
})
98+
.expect("render live Granola summary");
99+
let transcript = render_granola_note(&GranolaNativeBundle {
100+
content_kind: GranolaContentKind::Transcript,
101+
note: note.clone(),
102+
})
103+
.expect("render live Granola transcript");
104+
105+
assert_frontmatter_contract(&summary.frontmatter, &note.id, "summary");
106+
assert_frontmatter_contract(&transcript.frontmatter, &note.id, "transcript");
107+
assert!(
108+
!summary.body.trim().is_empty(),
109+
"rendered Granola summary body was empty"
110+
);
111+
let transcript_chunk_count = note.transcript.as_ref().map_or(0, Vec::len);
112+
assert_compact_transcript_contract(&transcript.body, transcript_chunk_count);
113+
114+
let note_updated_at = DateTime::parse_from_rfc3339(&note.updated_at)
115+
.expect("live Granola note updated_at was already validated")
116+
.with_timezone(&Utc);
117+
let updated_after = (note_updated_at - TimeDelta::days(2))
118+
.format("%Y-%m-%d")
119+
.to_string();
120+
api.list_notes(None, 1, None, None, Some(&updated_after))
121+
.unwrap_or_else(|error| {
122+
panic!("Granola incremental updated_after request failed: {error}")
123+
});
124+
}
125+
126+
fn live_note_with_transcript(
127+
api: &HttpGranolaApiClient,
128+
summaries: &[locality_granola::GranolaNoteSummary],
129+
) -> GranolaNote {
130+
if let Ok(note_id) = std::env::var(LIVE_NOTE_ENV)
131+
&& !note_id.trim().is_empty()
132+
{
133+
match api.get_note(note_id.trim(), true) {
134+
Ok(note)
135+
if note
136+
.transcript
137+
.as_ref()
138+
.is_some_and(|chunks| !chunks.is_empty())
139+
&& has_summary(&note) =>
140+
{
141+
return note;
142+
}
143+
Ok(_) | Err(LocalityError::RemoteNotFound(_)) => {}
144+
Err(error) => panic!("Configured Granola live note could not be fetched: {error}"),
145+
}
146+
}
147+
148+
for summary in summaries.iter().take(NOTE_SCAN_LIMIT) {
149+
let note = api
150+
.get_note(&summary.id, true)
151+
.unwrap_or_else(|error| panic!("Granola transcript fetch failed: {error}"));
152+
if note
153+
.transcript
154+
.as_ref()
155+
.is_some_and(|chunks| !chunks.is_empty())
156+
&& has_summary(&note)
157+
{
158+
return note;
159+
}
160+
}
161+
panic!(
162+
"None of the first {NOTE_SCAN_LIMIT} Granola notes had both a summary and retained transcript; set {LIVE_NOTE_ENV} to a stable qualifying note"
163+
);
164+
}
165+
166+
fn has_summary(note: &GranolaNote) -> bool {
167+
note.summary_markdown
168+
.as_deref()
169+
.is_some_and(|summary| !summary.trim().is_empty())
170+
|| !note.summary_text.trim().is_empty()
171+
}
172+
173+
fn assert_frontmatter_contract(frontmatter: &str, note_id: &str, kind: &str) {
174+
assert!(
175+
frontmatter.contains(" connector: granola\n"),
176+
"Granola frontmatter omitted connector identity"
177+
);
178+
assert!(
179+
frontmatter.contains(&format!(" content_kind: {kind}\n")),
180+
"Granola frontmatter used the wrong content kind"
181+
);
182+
assert!(
183+
frontmatter.contains(note_id),
184+
"Granola frontmatter omitted the durable note id"
185+
);
186+
for field in [
187+
"web_url",
188+
"created_at",
189+
"updated_at",
190+
"owner",
191+
"attendees",
192+
"folders",
193+
] {
194+
assert!(
195+
frontmatter.contains(&format!(" {field}:")),
196+
"Granola frontmatter omitted required field `{field}`"
197+
);
198+
}
199+
}
200+
201+
fn assert_compact_transcript_contract(body: &str, expected_heading_count: usize) {
202+
let headings = body
203+
.lines()
204+
.filter(|line| line.starts_with("**Me") || line.starts_with("**Them"))
205+
.collect::<Vec<_>>();
206+
assert!(
207+
!headings.is_empty(),
208+
"rendered Granola transcript had no speaker turns"
209+
);
210+
assert_eq!(
211+
headings.len(),
212+
expected_heading_count,
213+
"rendered Granola transcript did not preserve one heading per chunk"
214+
);
215+
for heading in headings {
216+
let heading = heading
217+
.strip_prefix("**")
218+
.and_then(|value| value.strip_suffix("**"))
219+
.expect("Granola transcript heading was not bold Markdown");
220+
let (speaker, time) = heading
221+
.split_once(" · ")
222+
.expect("Granola transcript heading was not speaker-first");
223+
assert!(
224+
speaker == "Me"
225+
|| speaker == "Them"
226+
|| (speaker.starts_with("Me (") && speaker.ends_with(')'))
227+
|| (speaker.starts_with("Them (") && speaker.ends_with(')')),
228+
"Granola transcript heading did not lead with Me or Them"
229+
);
230+
let normalized_speaker = speaker.to_ascii_lowercase();
231+
assert!(
232+
!normalized_speaker.ends_with(" (microphone)")
233+
&& !normalized_speaker.ends_with(" (speaker)"),
234+
"Granola transcript heading exposed the repeated capture source"
235+
);
236+
let time = time
237+
.strip_suffix(" UTC")
238+
.expect("Granola transcript heading omitted UTC");
239+
for part in time.split('–') {
240+
assert_compact_time(part);
241+
}
242+
}
243+
}
244+
245+
fn assert_compact_time(value: &str) {
246+
let parts = value.split(':').collect::<Vec<_>>();
247+
assert_eq!(parts.len(), 3, "Granola transcript time was not HH:MM:SS");
248+
assert!(
249+
parts
250+
.iter()
251+
.all(|part| part.len() == 2 && part.chars().all(|character| character.is_ascii_digit())),
252+
"Granola transcript time was not compact numeric HH:MM:SS"
253+
);
254+
}
255+
256+
fn required_secret_env(name: &str) -> String {
257+
std::env::var(name)
258+
.ok()
259+
.filter(|value| !value.trim().is_empty())
260+
.unwrap_or_else(|| panic!("set {name} to run the live Granola integrity test"))
261+
}

0 commit comments

Comments
 (0)