@playpilot/tpi 4.0.0-beta.1 → 4.0.1

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.
Files changed (30) hide show
  1. package/dist/link-injections.js +10 -9
  2. package/eslint.config.js +15 -0
  3. package/package.json +3 -2
  4. package/release.js +31 -0
  5. package/src/lib/enums/TrackingEvent.ts +4 -0
  6. package/src/lib/linkInjection.ts +7 -5
  7. package/src/lib/scss/global.scss +18 -6
  8. package/src/lib/tracking.ts +1 -1
  9. package/src/lib/types/playlink.d.ts +2 -0
  10. package/src/routes/+page.svelte +43 -31
  11. package/src/routes/components/ContextMenu.svelte +1 -2
  12. package/src/routes/components/Editorial/AIIndicator.svelte +6 -6
  13. package/src/routes/components/Editorial/Alert.svelte +12 -2
  14. package/src/routes/components/Editorial/Editor.svelte +3 -2
  15. package/src/routes/components/Editorial/EditorItem.svelte +23 -2
  16. package/src/routes/components/Editorial/ManualInjection.svelte +1 -1
  17. package/src/routes/components/Editorial/ReportIssueModal.svelte +156 -0
  18. package/src/routes/components/Modal.svelte +5 -2
  19. package/src/routes/components/Playlinks.svelte +24 -5
  20. package/src/routes/components/Popover.svelte +2 -1
  21. package/src/routes/components/Title.svelte +1 -1
  22. package/src/routes/components/TitlePopover.svelte +1 -1
  23. package/src/tests/lib/auth.test.js +1 -1
  24. package/src/tests/lib/linkInjection.test.js +1 -1
  25. package/src/tests/routes/+page.test.js +31 -2
  26. package/src/tests/routes/components/Editorial/AiIndicator.test.js +7 -0
  27. package/src/tests/routes/components/Editorial/Alert.test.js +12 -0
  28. package/src/tests/routes/components/Editorial/EditorItem.test.js +13 -4
  29. package/src/tests/routes/components/Editorial/ReportIssueModal.test.js +63 -0
  30. package/src/tests/routes/components/Playlinks.test.js +23 -3
@@ -36,16 +36,16 @@
36
36
  }
37
37
  </script>
38
38
 
39
- <div class="heading" use:heading={2}>{t('Where To Stream Online')}</div>
39
+ <div class="heading" use:heading={3}>{t('Where To Stream Online')}</div>
40
40
 
41
41
  <div class="playlinks" class:list>
42
- {#each mergedPlaylink as { name, url, logo_url, extra_info: { category } }}
43
- <a href={url} target="_blank" class="playlink" onclick={() => onclick(name)} data-playlink={name} rel="sponsored">
42
+ {#each mergedPlaylink as { name, url, logo_url, highlighted, cta_text, extra_info: { category } }}
43
+ <a href={url} target="_blank" class="playlink" class:highlighted={highlighted && cta_text} onclick={() => onclick(name)} data-playlink={name} rel="sponsored">
44
44
  <img src={removeImageUrlPrefix(logo_url)} alt="" height="32" width="32" />
45
45
 
46
46
  <div>
47
47
  <span class="name">{name}</span>
48
- <div class="category">{categoryStrings[category] || t('Stream')}</div>
48
+ <div class="category">{cta_text || categoryStrings[category] || t('Stream')}</div>
49
49
  </div>
50
50
 
51
51
  {#if list}
@@ -112,6 +112,7 @@
112
112
  }
113
113
 
114
114
  .playlink {
115
+ position: relative;
115
116
  display: flex;
116
117
  align-items: center;
117
118
  gap: margin(0.75);
@@ -121,7 +122,7 @@
121
122
  border-radius: var(--playpilot-playlink-border-radius, margin(0.5));
122
123
  color: var(--playpilot-playlink-text-color, var(--playpilot-text-color-alt)) !important;
123
124
  font-weight: var(--playpilot-playlink-font-weight, inherit);
124
- font-style: var(--playpilot-playlink-font-style, normal);
125
+ font-style: var(--playpilot-playlink-font-style, normal) !important;
125
126
  text-decoration: none !important;
126
127
  white-space: nowrap;
127
128
  font-size: var(--playpilot-playlinks-font-size, margin(0.75));
@@ -134,6 +135,23 @@
134
135
  text-decoration: none !important;
135
136
  }
136
137
 
138
+ &.highlighted {
139
+ &::before {
140
+ content: "";
141
+ z-index: 1;
142
+ display: block;
143
+ position: absolute;
144
+ top: 0;
145
+ right: 0;
146
+ bottom: 0;
147
+ left: 0;
148
+ border-radius: inherit;
149
+ box-shadow: inset 0 0 0 2px currentColor;
150
+ opacity: 0.35;
151
+ pointer-events: none;
152
+ }
153
+ }
154
+
137
155
  img {
138
156
  margin: 0;
139
157
  }
@@ -163,6 +181,7 @@
163
181
  }
164
182
 
165
183
  .arrow {
184
+ display: flex;
166
185
  margin-left: auto;
167
186
  padding: 0 margin(0.5);
168
187
  }
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { onMount, setContext, tick, type Snippet } from 'svelte'
3
+ import { prefersReducedMotion } from 'svelte/motion'
3
4
  import { fly } from 'svelte/transition'
4
5
 
5
6
  interface Props {
@@ -59,7 +60,7 @@
59
60
  </script>
60
61
 
61
62
  <div class="popover" class:flip bind:this={element} style:--max-height={maxHeight ? maxHeight + 'px' : null} tabindex="-1" aria-hidden="true">
62
- <div class="dialog" transition:fly|global={{ duration: 100, y: 10 }} data-view-transition-old>
63
+ <div class="dialog" transition:fly|global={{ duration: prefersReducedMotion.current ? 0 : 100, y: 10 }} data-view-transition-old>
63
64
  {@render children()}
64
65
  </div>
65
66
  </div>
@@ -34,7 +34,7 @@
34
34
  </div>
35
35
  {/if}
36
36
 
37
- <div class="heading" use:heading class:truncate={small}>{title.title}</div>
37
+ <div class="heading" use:heading={2} class:truncate={small} id="title">{title.title}</div>
38
38
 
39
39
  <div class="info">
40
40
  <div class="imdb">
@@ -42,7 +42,7 @@
42
42
  }
43
43
  </script>
44
44
 
45
- <div class="title-popover" bind:this={element} data-playpilot-title-popover>
45
+ <div class="title-popover" bind:this={element} data-playpilot-title-popover role="region" aria-labelledby="title">
46
46
  <Popover bind:maxHeight>
47
47
  <Title {title} small compact={!!maxHeight && maxHeight < 250} />
48
48
  </Popover>
@@ -70,7 +70,7 @@ describe('$lib/auth', () => {
70
70
 
71
71
  it('Should not authorize is fetch response was negative', async () => {
72
72
  fakeFetch({ response: '', ok: false, status: 403 })
73
- let authorized = await authorize('https://example.com/some-path?articleReplacementEditToken=some-token')
73
+ const authorized = await authorize('https://example.com/some-path?articleReplacementEditToken=some-token')
74
74
 
75
75
  expect(authorized).not.toBeTruthy()
76
76
  expect(track).toHaveBeenCalled()
@@ -850,7 +850,7 @@ describe('linkInjection.js', () => {
850
850
  const result = getLinkInjectionElements(parent, '[data-exclude]')
851
851
  expect(result).toHaveLength(1)
852
852
  expect(result[0].innerText).toBe('I am a regular element')
853
- });
853
+ })
854
854
 
855
855
  it('Should return paragraphs fully even if they contain no direct text nodes, skipping empty paragraphs', () => {
856
856
  document.body.innerHTML = `<section>
@@ -9,6 +9,7 @@ import { authorize, getAuthToken, removeAuthCookie } from '$lib/auth'
9
9
  import { generateInjection } from '../helpers'
10
10
  import { injectLinksInDocument } from '$lib/linkInjection'
11
11
  import { isCrawler } from '$lib/crawler'
12
+ import { getFullUrlPath } from '$lib/url'
12
13
 
13
14
  vi.mock('$lib/api', () => ({
14
15
  fetchLinkInjections: vi.fn(() => {}),
@@ -53,7 +54,7 @@ vi.mock('$lib/crawler', () => ({
53
54
  }))
54
55
 
55
56
  vi.mock('$lib/url', () => ({
56
- getFullUrlPath: () => '/test',
57
+ getFullUrlPath: vi.fn(),
57
58
  }))
58
59
 
59
60
  describe('$routes/+page.svelte', () => {
@@ -61,6 +62,7 @@ describe('$routes/+page.svelte', () => {
61
62
  document.body.innerHTML = ''
62
63
  vi.resetAllMocks()
63
64
  vi.mocked(injectLinksInDocument).mockReturnValue([])
65
+ vi.mocked(getFullUrlPath).mockReturnValue('/test')
64
66
  })
65
67
 
66
68
  afterEach(() => {
@@ -340,6 +342,22 @@ describe('$routes/+page.svelte', () => {
340
342
  expect(pollLinkInjections).not.toHaveBeenCalled()
341
343
  })
342
344
 
345
+ it('Should log error if error occurs during injection', async () => {
346
+ // @ts-ignore
347
+ vi.mocked(pollLinkInjections).mockResolvedValueOnce({ ai_injections: [], link_injections: [] })
348
+ vi.mocked(injectLinksInDocument).mockImplementationOnce(() => {
349
+ throw new Error('Some error')
350
+ })
351
+
352
+ render(page)
353
+
354
+ await new Promise(res => setTimeout(res)) // Await potential fetches
355
+
356
+ await waitFor(() => {
357
+ expect(track).toHaveBeenCalledWith(TrackingEvent.InjectionError, null, { message: 'Some error' })
358
+ })
359
+ })
360
+
343
361
  describe('Config', () => {
344
362
  describe('exclude_urls_pattern', () => {
345
363
  it('Should not inject if config exclude_urls_pattern matches current url', async () => {
@@ -351,7 +369,7 @@ describe('$routes/+page.svelte', () => {
351
369
  expect(pollLinkInjections).not.toHaveBeenCalled()
352
370
  })
353
371
 
354
- it('Should not inject if config exclude_urls_pattern matches current url with more complex regex pattern', async () => {
372
+ it('Should not inject if config exclude_urls_pattern matches current url with regex pattern', async () => {
355
373
  vi.mocked(fetchConfig).mockResolvedValueOnce({ exclude_urls_pattern: '^/test$' })
356
374
 
357
375
  render(page)
@@ -360,6 +378,17 @@ describe('$routes/+page.svelte', () => {
360
378
  expect(pollLinkInjections).not.toHaveBeenCalled()
361
379
  })
362
380
 
381
+ // Real example from DigitalSpy
382
+ it('Should not inject if config exclude_urls_pattern matches current url with more complex regex pattern', async () => {
383
+ vi.mocked(getFullUrlPath).mockReturnValueOnce('https://www.digitalspy.com/soaps/coronation-street/a65476444/coronation-street-james-bailey-exit-dee-dee')
384
+ vi.mocked(fetchConfig).mockResolvedValueOnce({ exclude_urls_pattern: 'preview/|^(?!.*/(movies|tv)/).*' })
385
+
386
+ render(page)
387
+
388
+ await waitFor(() => expect(fetchConfig).toHaveBeenCalled())
389
+ expect(pollLinkInjections).not.toHaveBeenCalled()
390
+ })
391
+
363
392
  it('Should not inject if config returns an error', async () => {
364
393
  vi.mocked(fetchConfig).mockRejectedValueOnce('null')
365
394
 
@@ -38,6 +38,13 @@ describe('AIIndicator.svelte', () => {
38
38
  expect(getByText('25%')).toBeTruthy()
39
39
  })
40
40
 
41
+ it('Should floor the percentage label if a decimal is given', () => {
42
+ const { getByText, getByTestId } = render(AIIndicator, { aiRunning: true, automationEnabled: true, percentage: 25.55 })
43
+
44
+ expect(getByTestId('loading-bar').style.width).toBe('25.55%')
45
+ expect(getByText('25%')).toBeTruthy()
46
+ })
47
+
41
48
  it('Should limit progress to minimum value in loading bar style only', () => {
42
49
  const { getByText, getByTestId } = render(AIIndicator, { aiRunning: true, automationEnabled: true, percentage: 0 })
43
50
 
@@ -21,4 +21,16 @@ describe('Alert.svelte', () => {
21
21
 
22
22
  expect(container.querySelector('.alert')?.classList).toContain('warning')
23
23
  })
24
+
25
+ it('Should include fixed class if prop is given', () => {
26
+ const { container } = render(Alert, { children, fixed: true })
27
+
28
+ expect(container.querySelector('.alert')?.classList).toContain('fixed')
29
+ })
30
+
31
+ it('Should not include fixed class if prop is not given', () => {
32
+ const { container } = render(Alert, { children })
33
+
34
+ expect(container.querySelector('.alert')?.classList).not.toContain('fixed')
35
+ })
24
36
  })
@@ -165,14 +165,14 @@ describe('EditorItem.svelte', () => {
165
165
  expect(onremove).toHaveBeenCalled()
166
166
  })
167
167
 
168
- it('Should display an inactive injection as being so with text and classname', async () => {
168
+ it('Should display an inactive injection as being so with text and classname', () => {
169
169
  const { getByText, container } = render(EditorItem, { linkInjection: { ...linkInjection, inactive: true } })
170
170
 
171
171
  expect(getByText('Inactive')).toBeTruthy()
172
172
  expect(container.querySelector('.inactive')).toBeTruthy()
173
173
  })
174
174
 
175
- it('Should not display an active injection as being inactive', async () => {
175
+ it('Should not display an active injection as being inactive', () => {
176
176
  const { queryByText, container } = render(EditorItem, { linkInjection })
177
177
 
178
178
  expect(queryByText('Inactive')).not.toBeTruthy()
@@ -180,15 +180,24 @@ describe('EditorItem.svelte', () => {
180
180
  expect(container.querySelector('.inactive')).not.toBeTruthy()
181
181
  })
182
182
 
183
- it('Should display an icon when playlink types are invalid', async () => {
183
+ it('Should display an icon when playlink types are invalid', () => {
184
184
  const { getByLabelText } = render(EditorItem, { linkInjection: { ...linkInjection, in_text: false } })
185
185
 
186
186
  expect(getByLabelText('Invalid playlink settings')).toBeTruthy()
187
187
  })
188
188
 
189
- it('Should not display an icon when playlink types are valid', async () => {
189
+ it('Should not display an icon when playlink types are valid', () => {
190
190
  const { queryByLabelText } = render(EditorItem, { linkInjection: { ...linkInjection, in_text: true } })
191
191
 
192
192
  expect(queryByLabelText('Invalid playlink settings')).not.toBeTruthy()
193
193
  })
194
+
195
+ it('Should mount component when clicking report issue button', async () => {
196
+ const { getByLabelText, getByText } = render(EditorItem, { linkInjection })
197
+
198
+ await fireEvent.click(getByLabelText('More options'))
199
+ await fireEvent.click(getByText('Report issue'))
200
+
201
+ expect(getByText('What would you like to report?')).toBeTruthy()
202
+ })
194
203
  })
@@ -0,0 +1,63 @@
1
+ import { fireEvent, render } from '@testing-library/svelte'
2
+ import { describe, expect, it, vi, beforeEach } from 'vitest'
3
+
4
+ import ReportIssueModal from '../../../../routes/components/Editorial/ReportIssueModal.svelte'
5
+ import { track } from '$lib/tracking'
6
+ import { TrackingEvent } from '$lib/enums/TrackingEvent'
7
+ import { generateInjection } from '../../../helpers'
8
+
9
+ vi.mock('$lib/tracking', () => ({
10
+ track: vi.fn(),
11
+ }))
12
+
13
+ describe('ReportIssueModal.svelte', () => {
14
+ beforeEach(() => {
15
+ vi.resetAllMocks()
16
+ })
17
+
18
+ const onclose = vi.fn()
19
+ const linkInjection = generateInjection('a word', 'Some sentence with a word')
20
+
21
+ it('Should enable button after selecting issue', async () => {
22
+ const { getByRole, getAllByRole, getByText } = render(ReportIssueModal, { onclose, linkInjection })
23
+
24
+ expect(/** @type {HTMLButtonElement } */ (getByText('Send report')).disabled).toBeTruthy()
25
+
26
+ await fireEvent.click(getByRole('combobox'))
27
+ await fireEvent.click(getAllByRole('option')[1])
28
+
29
+ expect(/** @type {HTMLButtonElement } */ (getByText('Send report')).disabled).not.toBeTruthy()
30
+ })
31
+
32
+ it('Should submit form when clicked', async () => {
33
+ const { getByRole, getAllByRole, getByText } = render(ReportIssueModal, { onclose, linkInjection })
34
+
35
+ await fireEvent.click(getByRole('combobox'))
36
+ await fireEvent.click(getAllByRole('option')[1])
37
+ await fireEvent.click(getByText('Send report'))
38
+
39
+ expect(track).toHaveBeenCalledWith(
40
+ TrackingEvent.ManualReport,
41
+ linkInjection.title_details,
42
+ {
43
+ report_reason: /** @type {HTMLOptionElement} */ (getAllByRole('option')[1]).value,
44
+ sid: linkInjection.sid,
45
+ title: linkInjection.title,
46
+ sentence: linkInjection.sentence,
47
+ failed: linkInjection.failed?.toString(),
48
+ failed_message: linkInjection.failed_message,
49
+ manual: linkInjection.manual?.toString(),
50
+ },
51
+ )
52
+
53
+ expect(getByText('Report has been sent', { exact: false }))
54
+ })
55
+
56
+ it('Should not submit form when no reason is selected', async () => {
57
+ const { getByText } = render(ReportIssueModal, { onclose, linkInjection })
58
+
59
+ await fireEvent.click(getByText('Send report'))
60
+
61
+ expect(track).not.toHaveBeenCalled()
62
+ })
63
+ })
@@ -22,6 +22,7 @@ describe('Playlinks.svelte', () => {
22
22
  { name: 'Some playlink', logo_url: 'logo', extra_info: { category: 'SVOD' } },
23
23
  { name: 'Some other playlink', logo_url: 'logo', extra_info: { category: 'SVOD' } },
24
24
  ]
25
+ // @ts-ignore
25
26
  const { getByText } = render(Playlinks, { playlinks, title })
26
27
 
27
28
  expect(getByText('Some playlink')).toBeTruthy()
@@ -29,7 +30,7 @@ describe('Playlinks.svelte', () => {
29
30
  })
30
31
 
31
32
  it('Should show empty state when no playlinks were given', () => {
32
- /** @type {PlaylinkData[]} */
33
+ /** @type {import('$lib/types/playlink').PlaylinkData[]} */
33
34
  const playlinks = []
34
35
  const { container } = render(Playlinks, { playlinks, title })
35
36
 
@@ -43,6 +44,7 @@ describe('Playlinks.svelte', () => {
43
44
  { name: 'Some rent playlink', logo_url: 'logo', extra_info: { category: 'RENT' } },
44
45
  { name: 'Some other playlink', logo_url: 'logo', extra_info: { category: 'other' } },
45
46
  ]
47
+ // @ts-ignore
46
48
  const { getByText, getAllByText } = render(Playlinks, { playlinks, title })
47
49
 
48
50
  expect(getAllByText('Stream')).toHaveLength(2)
@@ -55,6 +57,7 @@ describe('Playlinks.svelte', () => {
55
57
  { name: 'Some playlink', logo_url: 'logo', extra_info: { category: 'SVOD' } },
56
58
  { name: 'Some other playlink', logo_url: 'logo', extra_info: { category: 'SVOD' } },
57
59
  ]
60
+ // @ts-ignore
58
61
  const { getByText } = render(Playlinks, { playlinks, title })
59
62
 
60
63
  await fireEvent.click(getByText(playlinks[0].name))
@@ -68,6 +71,7 @@ describe('Playlinks.svelte', () => {
68
71
  const playlinks = [
69
72
  { name: 'Some playlink', logo_url: 'logo', extra_info: { category: 'SVOD' } },
70
73
  ]
74
+ // @ts-ignore
71
75
  const { getByText } = render(Playlinks, { playlinks, title })
72
76
 
73
77
  await fireEvent.click(getByText(playlinks[0].name))
@@ -82,20 +86,36 @@ describe('Playlinks.svelte', () => {
82
86
  { name: 'Some playlink', logo_url: '', extra_info: { category: 'RENT' } },
83
87
  { name: 'Some playlink', logo_url: null, extra_info: { category: 'other' } },
84
88
  ]
89
+ // @ts-ignore
85
90
  const { getAllByText } = render(Playlinks, { playlinks, title })
86
91
 
87
92
  expect(getAllByText('Some playlink')).toHaveLength(1)
88
93
  })
89
94
 
90
- it('Should not have list class by default', async () => {
95
+ it('Should not have list class by default', () => {
91
96
  const { container } = render(Playlinks, { playlinks: [], title })
92
97
 
93
98
  expect(container.querySelector('.list')).not.toBeTruthy()
94
99
  })
95
100
 
96
- it('Should have list class when prop is given', async () => {
101
+ it('Should have list class when prop is given', () => {
97
102
  const { container } = render(Playlinks, { playlinks: [], title, list: true })
98
103
 
99
104
  expect(container.querySelector('.list')).toBeTruthy()
100
105
  })
106
+
107
+ it('Should highlight and replace category text for playlinks that have highlighted as true', () => {
108
+ const playlinks = [
109
+ { name: 'Some playlink', logo_url: 'logo', extra_info: { category: 'SVOD' } },
110
+ { name: 'Some other playlink', logo_url: 'logo', highlighted: true, cta_text: '', extra_info: { category: 'BUY' } },
111
+ { name: 'Some highlighted playlink', logo_url: 'logo', highlighted: true, cta_text: 'Some CTA', extra_info: { category: 'BUY' } },
112
+ ]
113
+ // @ts-ignore
114
+ const { getByText } = render(Playlinks, { playlinks, title })
115
+
116
+ expect(getByText('Some playlink').closest('.playlink')?.classList).not.toContain('highlighted')
117
+ expect(getByText('Some other playlink').closest('.playlink')?.classList).not.toContain('highlighted')
118
+ expect(getByText('Some highlighted playlink').closest('.playlink')?.classList).toContain('highlighted')
119
+ expect(getByText('Some CTA')).toBeTruthy()
120
+ })
101
121
  })