@startsimpli/integrations 0.2.11 → 0.2.14

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/integrations",
3
- "version": "0.2.11",
3
+ "version": "0.2.14",
4
4
  "description": "Provider-agnostic types, API clients, hooks, and UI primitives for email + calendar integrations across StartSimpli apps.",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -627,3 +627,168 @@ describe('no per-vendor components', () => {
627
627
  expect(offenders).toEqual([]);
628
628
  });
629
629
  });
630
+
631
+ // --------------------------------------------------------------------------
632
+ // settings on a CONNECTED card (bd startsim-768w.38.31)
633
+ // --------------------------------------------------------------------------
634
+ //
635
+ // A connector's `config_schema` has always shipped on every catalog row and has
636
+ // always been rendered — but only on the CONNECT form, once, before the account
637
+ // exists. After that there was no way to change a setting from the product at
638
+ // all. `ReplaceKeyForm` even says so, pointing at "the connection's own
639
+ // settings" as though that surface existed.
640
+ //
641
+ // It has to exist for `ingest_transcripts`, because the whole privacy posture is
642
+ // that transcripts are off until an admin deliberately turns them on — and a
643
+ // switch nobody can reach is not a decision anybody gets to make.
644
+
645
+ /** The Granola shape: a boolean, a number and a free-text list. */
646
+ const TRANSCRIPT_CONFIG = [
647
+ {
648
+ key: 'ingest_transcripts',
649
+ label: 'Store what was said, not just the summary',
650
+ type: 'boolean',
651
+ required: false,
652
+ default: false,
653
+ help: 'On: the full word-for-word transcript is stored too.',
654
+ },
655
+ {
656
+ key: 'transcript_retention_days',
657
+ label: 'Delete stored transcripts after (days)',
658
+ type: 'number',
659
+ required: false,
660
+ default: 90,
661
+ },
662
+ ];
663
+
664
+ const CONFIGURABLE_CATALOG = {
665
+ results: [
666
+ {
667
+ ...CATALOG.results[0],
668
+ config_schema: { version: 1, fields: TRANSCRIPT_CONFIG },
669
+ },
670
+ ],
671
+ };
672
+
673
+ function configurableApi(overrides: Partial<ConnectionsApi> = {}): ConnectionsApi {
674
+ return stubApi({
675
+ connectorCatalog: vi.fn().mockResolvedValue(CONFIGURABLE_CATALOG),
676
+ connections: vi.fn().mockResolvedValue({
677
+ ...CONNECTED,
678
+ results: [{ ...CONNECTED.results[0], config: { transcript_retention_days: 90 } }],
679
+ }),
680
+ ...overrides,
681
+ });
682
+ }
683
+
684
+ async function openSettings(api: ConnectionsApi) {
685
+ render(<ConnectionsPage api={api} foundrySlug="acme" />);
686
+ const card = await screen.findByText('Ops n8n');
687
+ const section = card.closest('div[data-connection]') ?? card.parentElement!;
688
+ await userEvent.click(await within(section as HTMLElement).findByRole('button', { name: /Settings/i }));
689
+ return section as HTMLElement;
690
+ }
691
+
692
+ describe('CredentialSchemaForm — a boolean is a checkbox', () => {
693
+ it('renders a declared boolean as a checkbox, not a text box', () => {
694
+ // `bool("false")` is `True`. A privacy switch rendered as somewhere to type
695
+ // the word "false" is a switch that is on when the admin believes it is off.
696
+ renderForm(TRANSCRIPT_CONFIG);
697
+ const input = screen.getByLabelText(/Store what was said/i);
698
+ expect(input).toHaveAttribute('type', 'checkbox');
699
+ });
700
+
701
+ it('reports a boolean edit as a real boolean, not a string', () => {
702
+ const { onChange } = renderForm(TRANSCRIPT_CONFIG);
703
+ screen.getByLabelText(/Store what was said/i).click();
704
+ expect(onChange).toHaveBeenCalledWith('ingest_transcripts', true);
705
+ });
706
+
707
+ it('shows the declared default so an untouched field is not a guess', () => {
708
+ renderForm(TRANSCRIPT_CONFIG);
709
+ expect(screen.getByLabelText(/Store what was said/i)).not.toBeChecked();
710
+ expect(screen.getByLabelText(/Delete stored transcripts/i)).toHaveValue(90);
711
+ });
712
+
713
+ it('renders the help text, which is where the consequence is stated', () => {
714
+ renderForm(TRANSCRIPT_CONFIG);
715
+ expect(screen.getByText(/word-for-word transcript is stored/i)).toBeInTheDocument();
716
+ });
717
+ });
718
+
719
+ describe('ConnectionCard — a connected account has settings', () => {
720
+ it('renders the connector’s config schema for an existing connection', async () => {
721
+ const section = await openSettings(configurableApi());
722
+ expect(within(section).getByLabelText(/Store what was said/i)).toBeInTheDocument();
723
+ // The STORED value, not the declared default — a form that shows the default
724
+ // over a stored setting tells the admin their last change did not take.
725
+ expect(within(section).getByLabelText(/Delete stored transcripts/i)).toHaveValue(90);
726
+ });
727
+
728
+ it('does not offer settings for a connector that declares none', async () => {
729
+ render(<ConnectionsPage api={stubApi()} foundrySlug="acme" />);
730
+ const card = await screen.findByText('Ops n8n');
731
+ const section = (card.closest('div[data-connection]') ?? card.parentElement!) as HTMLElement;
732
+ expect(within(section).queryByRole('button', { name: /Settings/i })).toBeNull();
733
+ });
734
+
735
+ it('saves through updateConnection, sending only the fields it changed', async () => {
736
+ const updateConnection = vi.fn().mockResolvedValue(CONNECTED.results[0]);
737
+ const section = await openSettings(configurableApi({ updateConnection }));
738
+
739
+ await userEvent.click(within(section).getByLabelText(/Store what was said/i));
740
+ await userEvent.click(within(section).getByRole('button', { name: /^Save settings$/i }));
741
+
742
+ await waitFor(() => expect(updateConnection).toHaveBeenCalled());
743
+ const [, , payload] = updateConnection.mock.calls[0];
744
+ expect(payload.config).toEqual({ ingest_transcripts: true });
745
+ // The API merges, so an untouched field must NOT be echoed back — resending
746
+ // it turns "I changed one thing" into "I reassert everything", and races an
747
+ // edit made in another tab.
748
+ expect(payload.config).not.toHaveProperty('transcript_retention_days');
749
+ });
750
+
751
+ it('sends false when a checkbox is turned OFF, rather than omitting it', async () => {
752
+ // The connect form drops falsy values (`if (v) config[k] = v`), which for a
753
+ // checkbox means "off" is indistinguishable from "untouched" — and with a
754
+ // merging API that makes the off position unreachable.
755
+ const updateConnection = vi.fn().mockResolvedValue(CONNECTED.results[0]);
756
+ const section = await openSettings(
757
+ configurableApi({
758
+ updateConnection,
759
+ connections: vi.fn().mockResolvedValue({
760
+ ...CONNECTED,
761
+ results: [{ ...CONNECTED.results[0], config: { ingest_transcripts: true } }],
762
+ }),
763
+ }),
764
+ );
765
+
766
+ await userEvent.click(within(section).getByLabelText(/Store what was said/i));
767
+ await userEvent.click(within(section).getByRole('button', { name: /^Save settings$/i }));
768
+
769
+ await waitFor(() => expect(updateConnection).toHaveBeenCalled());
770
+ expect(updateConnection.mock.calls[0][2].config).toEqual({ ingest_transcripts: false });
771
+ });
772
+
773
+ it('warns, before saving, what turning transcripts on will do', async () => {
774
+ const section = await openSettings(configurableApi());
775
+ await userEvent.click(within(section).getByLabelText(/Store what was said/i));
776
+ expect(
777
+ within(section).getByText(/word-for-word transcript is stored/i),
778
+ ).toBeInTheDocument();
779
+ });
780
+
781
+ it('surfaces the API’s refusal rather than looking saved', async () => {
782
+ // The connector refuses `transcript_retention_days: 0` with nobody's name
783
+ // against it. That 400 has to land on the form, next to the field.
784
+ const updateConnection = vi.fn().mockRejectedValue(
785
+ Object.assign(new Error('transcript_retention_days=0 means keeping verbatim meeting speech forever'), {
786
+ status: 400,
787
+ }),
788
+ );
789
+ const section = await openSettings(configurableApi({ updateConnection }));
790
+ await userEvent.click(within(section).getByLabelText(/Store what was said/i));
791
+ await userEvent.click(within(section).getByRole('button', { name: /^Save settings$/i }));
792
+ expect(await within(section).findByText(/keeping verbatim meeting speech forever/i)).toBeInTheDocument();
793
+ });
794
+ });
@@ -89,7 +89,25 @@ describe('batching — N documents, ONE call', () => {
89
89
  };
90
90
  const text = documentText(long, 500);
91
91
  expect(text.length).toBeLessThan(700);
92
- expect(text).toContain('[truncated]');
92
+ // The marker now names the size of the cut, because "…[truncated]" told a
93
+ // reader that something was missing without telling them whether it was a
94
+ // sentence or forty minutes.
95
+ expect(text).toMatch(/\[truncated \d+ characters\]/);
96
+ });
97
+
98
+ it('spends the budget on the summary before the transcript', () => {
99
+ // A prefix cut over `body` + sections is what this used to be, and once
100
+ // `ingest_transcripts` lands `body` IS the transcript — so the entire 6,000
101
+ // character per-document budget would have gone on the opening minutes of
102
+ // the meeting and the summary would never have reached the model at all.
103
+ const long = {
104
+ ...FIXTURE_DOCUMENTS[0],
105
+ summary: FIXTURE_DOCUMENTS[0].summary,
106
+ body: `${'x'.repeat(50_000)} the last thing anybody said`,
107
+ };
108
+ const text = documentText(long, 800);
109
+ expect(text).toContain('Agreed to ship the connector RED suite first');
110
+ expect(text).toContain('the last thing anybody said');
93
111
  });
94
112
  });
95
113
 
@@ -106,7 +106,10 @@ describe('Markdown is rendered, not printed', () => {
106
106
  describe('LibraryPage renders a document’s own Markdown', () => {
107
107
  it('shows the summary as prose, not as source', async () => {
108
108
  const user = userEvent.setup();
109
- const document: SourceDocument = { ...FIXTURE_DOCUMENTS[0], body: MARKDOWN_BODY };
109
+ // The SUMMARY is the field rendered as prose under the heading "Summary".
110
+ // It used to be `body`, which was only ever the summary because nothing had
111
+ // delivered a full text yet.
112
+ const document: SourceDocument = { ...FIXTURE_DOCUMENTS[0], summary: MARKDOWN_BODY };
110
113
  const api = createFixtureCorpusApi({ scope: SCOPE, documents: [document], moneyEvents: [] });
111
114
  render(<LibraryPage api={api} scope={SCOPE} />);
112
115
 
@@ -107,7 +107,7 @@ describe('LibraryPage — the only connected source is Dropbox', () => {
107
107
  });
108
108
 
109
109
  describe('LibraryPage — the same screen, Granola only', () => {
110
- it('renders long-form under Granola’s word for it, from the same code', async () => {
110
+ it('renders the verbatim text separately from the summary, collapsed', async () => {
111
111
  const user = userEvent.setup();
112
112
  const { api } = singleSourceCorpus('granola', {
113
113
  source: 'granola', label: 'Granola', documentCount: 4,
@@ -117,7 +117,16 @@ describe('LibraryPage — the same screen, Granola only', () => {
117
117
 
118
118
  await user.click(await screen.findByText('Acme <> StartSimpli weekly sync'));
119
119
  const detail = await screen.findByRole('region', { name: 'Record detail' });
120
- expect(within(detail).getByText('Transcript')).toBeInTheDocument();
120
+ // "Full text" and not "Transcript", because the canonical mapping puts a
121
+ // meeting transcript in the SAME field a PDF's extracted text goes in — the
122
+ // corpus is source-agnostic and this screen must stay that way. A source
123
+ // that wants its own word for it contributes a named `section`, which the
124
+ // Dropbox row above proves still works.
125
+ expect(within(detail).getByText('Full text')).toBeInTheDocument();
126
+ // The summary is still the thing rendered as prose, and the verbatim text
127
+ // is behind a disclosure rather than in front of whoever opened the record.
128
+ expect(within(detail).getByText('Summary')).toBeInTheDocument();
129
+ expect(within(detail).getByText(/Agreed to ship the connector RED suite/)).toBeInTheDocument();
121
130
  });
122
131
  });
123
132
 
@@ -247,6 +247,33 @@ describe('TrackerPage — what is outstanding', () => {
247
247
  render(<TrackerPage api={makeApi({ rows: [] })} scope={SCOPE} now={NOW} />);
248
248
  expect(await screen.findByText(/Nothing has been pulled out of your meetings yet/)).toBeInTheDocument();
249
249
  });
250
+
251
+ it('does NOT say the workspace is empty when a VIEW is empty', async () => {
252
+ // Browser-verified failure on the deployed tenant: the Overdue tab said
253
+ // "Nothing has been pulled out of your meetings yet" over ninety-seven open
254
+ // items, because nothing carried a due date. Advice that is false is worse
255
+ // than no advice.
256
+ const rows = [commitment({ externalId: 'commit:x', title: 'An open item' })];
257
+ const api: CommitmentTrackerApi = {
258
+ async listCommitments(query: CommitmentQuery = {}) {
259
+ // The server filters: the overdue query matches nothing, an unfiltered
260
+ // count-only probe still reports the workspace has rows.
261
+ const filtered = Boolean(query.dueBefore);
262
+ return {
263
+ count: filtered ? 0 : rows.length,
264
+ next: null, previous: null,
265
+ results: filtered ? [] : rows,
266
+ appliedFilters: ['status', 'dueBefore'],
267
+ };
268
+ },
269
+ };
270
+ render(<TrackerPage api={api} scope={SCOPE} now={NOW} />);
271
+ await screen.findByText('An open item');
272
+ await userEvent.click(screen.getByRole('tab', { name: 'Overdue' }));
273
+
274
+ expect(await screen.findByText(/Nothing matches this view/)).toBeInTheDocument();
275
+ expect(screen.queryByText(/Nothing has been pulled out of your meetings yet/)).not.toBeInTheDocument();
276
+ });
250
277
  });
251
278
 
252
279
  describe('TrackerPage — running the extraction', () => {
@@ -256,6 +283,9 @@ describe('TrackerPage — running the extraction', () => {
256
283
  nextOffset: null,
257
284
  fromRules: 24,
258
285
  fromModel: 11,
286
+ ungrounded: 2,
287
+ unconfirmed: 3,
288
+ verified: true,
259
289
  created: 30,
260
290
  merged: 5,
261
291
  resolved: 2,
@@ -306,3 +336,43 @@ describe('TrackerPage — running the extraction', () => {
306
336
  expect(screen.queryByRole('button', { name: 'Pull from meetings' })).not.toBeInTheDocument();
307
337
  });
308
338
  });
339
+
340
+ // ---------------------------------------------------------------------------
341
+ // the run says what the verification threw away (bd startsim-768w.38.31)
342
+ // ---------------------------------------------------------------------------
343
+
344
+ describe('TrackerPage — the run reports its own precision work', () => {
345
+ const BASE = {
346
+ documentsProcessed: 16, totalDocuments: 16, nextOffset: null,
347
+ fromRules: 67, fromModel: 35, ungrounded: 2, unconfirmed: 59, verified: true,
348
+ created: 102, merged: 0, resolved: 0, needsReview: [], droppedOutOfScope: 0,
349
+ provider: { provider: 'anthropic', model: 'claude-sonnet-5', live: true },
350
+ };
351
+
352
+ async function runWith(result: Record<string, unknown>) {
353
+ const api = makeApi({ extract: vi.fn(async () => result) as never });
354
+ render(<TrackerPage api={api} scope={SCOPE} />);
355
+ await userEvent.click(await screen.findByRole('button', { name: /Pull from meetings/i }));
356
+ return screen;
357
+ }
358
+
359
+ it('says how many proposed items were dropped, and why', async () => {
360
+ // Measured over the sixteen real meetings, two thirds of what the model
361
+ // proposes does not survive the check. Hiding that would make the model look
362
+ // better than it is and the list less arguable than it should be.
363
+ await runWith(BASE);
364
+ expect(await screen.findByText(/61 more were proposed and dropped/)).toBeInTheDocument();
365
+ expect(screen.getByText(/2 quoted something the record does not say/)).toBeInTheDocument();
366
+ expect(screen.getByText(/59 could not be confirmed/)).toBeInTheDocument();
367
+ });
368
+
369
+ it('says the check did not run rather than implying it did', async () => {
370
+ await runWith({ ...BASE, verified: false, unconfirmed: 0, ungrounded: 0 });
371
+ expect(await screen.findByText(/were NOT checked against the meetings/)).toBeInTheDocument();
372
+ });
373
+
374
+ it('stays quiet when the model contributed nothing at all', async () => {
375
+ await runWith({ ...BASE, fromModel: 0, ungrounded: 0, unconfirmed: 0 });
376
+ expect(screen.queryByText(/proposed and dropped/)).toBeNull();
377
+ });
378
+ });