@internetarchive/ia-book-actions 1.4.4-alpha1 → 1.4.4-alpha2

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/index.html CHANGED
@@ -97,6 +97,7 @@
97
97
  border: 2px solid #194880;
98
98
  border-radius: 10px;
99
99
  background: white;
100
+ height: 50px;
100
101
  }
101
102
  .timer-presets button.active-preset {
102
103
  background: #194880;
@@ -137,6 +138,9 @@
137
138
  .loan-status-badge.status-active {
138
139
  background: #2e7d32; /* green: loan is currently checked out */
139
140
  }
141
+ .loan-status-badge.status-renewing {
142
+ background: #b8860b; /* amber: a renew_loan attempt is in flight */
143
+ }
140
144
  .loan-status-badge.status-returned {
141
145
  background: #d9534f; /* red: auto-returned / not on loan */
142
146
  }
@@ -216,7 +220,7 @@
216
220
  console.log = (...consoleArgs) => {
217
221
  __nativeConsoleLog(...consoleArgs);
218
222
 
219
- const loggedPrefixes = ['[ActionsHandlerService]', '[IABookActions]', '[Demo]'];
223
+ const loggedPrefixes = ['[IABookActions]', '[IABookActions]', '[Demo]'];
220
224
  if (typeof consoleArgs[0] !== 'string' || !loggedPrefixes.some((prefix) => consoleArgs[0].startsWith(prefix))) {
221
225
  return;
222
226
  }
@@ -280,7 +284,7 @@
280
284
  const timerPresets = {
281
285
  '15s': { loanTotalTime: 15, loanRenewAtLast: 13, pageChangedInLast: 5 },
282
286
  '30s': { loanTotalTime: 30, loanRenewAtLast: 27, pageChangedInLast: 10 },
283
- '2': { loanTotalTime: 120, loanRenewAtLast: 110, pageChangedInLast: 30 },
287
+ '2': { loanTotalTime: 120, loanRenewAtLast: 95, pageChangedInLast: 15 },
284
288
  '5': { loanTotalTime: 300, loanRenewAtLast: 240, pageChangedInLast: 60 },
285
289
  '10': { loanTotalTime: 600, loanRenewAtLast: 480, pageChangedInLast: 120 },
286
290
  '30': { loanTotalTime: 1800, loanRenewAtLast: 1500, pageChangedInLast: 600 },
@@ -439,6 +443,14 @@
439
443
  await iaBookActions.updateComplete;
440
444
  }
441
445
 
446
+ // showWarningModal() reads its message text from
447
+ // loanRenewHelper.getMessageTexts(loanRenewResult.texts, ...) — that
448
+ // helper only exists, and loanRenewResult.texts only gets populated,
449
+ // once autoLoanRenewChecker() has run at least once (this is what the
450
+ // real periodic timer calls before ever showing the modal). Run it
451
+ // here too so the button shows the same populated message a real
452
+ // warning would.
453
+ await iaBookActions.autoLoanRenewChecker(false);
442
454
  iaBookActions.showWarningModal();
443
455
  });
444
456
 
@@ -578,6 +590,13 @@
578
590
  function computeLoanStatus() {
579
591
  const lendingStatus = iaBookActions.lendingStatus || {};
580
592
 
593
+ // Checked first: autoRenewExpiredLoan() optimistically flips
594
+ // browsingExpired back to false the moment a retry starts (before the
595
+ // renew_loan call has actually landed), so without this check the
596
+ // badge would misleadingly jump straight to ACTIVE during the retry.
597
+ if (iaBookActions.loanRenewInProgress) {
598
+ return { badge: 'RENEWING', className: 'status-renewing' };
599
+ }
581
600
  if (lendingStatus.browsingExpired) {
582
601
  return { badge: 'RETURNED', className: 'status-returned' };
583
602
  }
@@ -662,7 +681,10 @@
662
681
  <button id="show_expired_modal">2. Auto-return due to inactivity<br>(no modal, page stays put)</button>
663
682
  </td>
664
683
  <td class="action-button">
665
- <button class="pageChangedEvent">3. Interact with book (turn page)<br>silently renew if interact in given time</button>
684
+ <button class="pageChangedEvent">
685
+ 3. Interact with book (turn page)<br>
686
+ silently renew while active or after auto-return
687
+ </button>
666
688
  </td>
667
689
  <td class="action-button">
668
690
  <button id="resync_timer">4. Return to tab after time away<br>(visibilitychange — silent retry if expired)</button>
@@ -688,9 +710,6 @@
688
710
  <td class="searchParam">
689
711
  <label><input type="checkbox" name="search" value="Search Param" />Search Param</label>
690
712
  </td>
691
- <td>
692
- <label class="ia-button"><button class="pageChangedEvent">Page Change Event</button></label>
693
- </td>
694
713
  </tr>
695
714
  </table>
696
715
  </fieldset>
package/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "author": "Internet Archive",
9
9
  "license": "AGPL-3.0-only",
10
- "version": "1.4.4-alpha1",
10
+ "version": "1.4.4-alpha2",
11
11
  "main": "index.js",
12
12
  "module": "index.js",
13
13
  "publishConfig": {
@@ -84,10 +84,10 @@ export default async function ActionsHandlerService(options) {
84
84
  .then(data => {
85
85
  // `data` is the parsed version of the JSON returned from the above endpoint.
86
86
  if (!data?.error) {
87
- log(`[ActionsHandlerService] ✓ ${option.action} succeeded`, data);
87
+ log(`[IABookActions] ✓ ${option.action} succeeded`, data);
88
88
  option?.success(data);
89
89
  } else {
90
- log(`[ActionsHandlerService] ✗ ${option.action} failed`, data);
90
+ log(`[IABookActions] ✗ ${option.action} failed`, data);
91
91
  option?.error(data);
92
92
  }
93
93
  });
@@ -53,20 +53,8 @@ export default class ActionsHandler extends LitElement {
53
53
  );
54
54
  });
55
55
 
56
- this.addEventListener('autoRenew', async ({ detail }) => {
57
- this.handleLoanRenewNow();
58
- await this.loanAnanlytics?.storeLoanStatsCount(
59
- this.identifier,
60
- 'autorenew'
61
- );
62
-
63
- const analyticsLabel = detail?.renewType === 'auto' ? analyticsLabels.browseAutoRenew : analyticsLabels.browseManualRenew;
64
- this.loanAnanlytics?.sendEvent(
65
- analyticsCategories.browse,
66
- analyticsActions.browseRenew,
67
- analyticsLabel,
68
- this.identifier
69
- );
56
+ this.addEventListener('autoRenew', ({ detail }) => {
57
+ this.handleLoanRenewNow(detail?.renewType);
70
58
  });
71
59
 
72
60
  this.addEventListener('autoReturn', async () => {
@@ -177,7 +165,7 @@ export default class ActionsHandler extends LitElement {
177
165
  });
178
166
  }
179
167
 
180
- handleLoanRenewNow() {
168
+ handleLoanRenewNow(renewType) {
181
169
  const action = 'renew_loan';
182
170
 
183
171
  ActionsHandlerService({
@@ -192,6 +180,23 @@ export default class ActionsHandler extends LitElement {
192
180
  // Await — loanAutoRenewed listeners read this same cache key
193
181
  // immediately; dispatching before the write lands was a race.
194
182
  await this.setBrowseTimeSession();
183
+
184
+ // Only record the renew analytics event once the renewal has
185
+ // actually succeeded — not merely attempted.
186
+ await this.loanAnanlytics?.storeLoanStatsCount(
187
+ this.identifier,
188
+ 'autorenew'
189
+ );
190
+ const analyticsLabel =
191
+ renewType === 'auto'
192
+ ? analyticsLabels.browseAutoRenew
193
+ : analyticsLabels.browseManualRenew;
194
+ this.loanAnanlytics?.sendEvent(
195
+ analyticsCategories.browse,
196
+ analyticsActions.browseRenew,
197
+ analyticsLabel,
198
+ this.identifier
199
+ );
195
200
  } else {
196
201
  log('RENEW_LOAN ERROR --- ', {
197
202
  action,
@@ -180,6 +180,13 @@ export default class IABookActions extends LitElement {
180
180
  }
181
181
 
182
182
  if (changed.has('loanRenewResult') && this.loanRenewResult.renewNow) {
183
+ // Any renewal — not just the auto-return recovery path — pauses the
184
+ // countdown for the renew_loan round-trip. Flag it here, centrally,
185
+ // so every path (interaction during active reading, periodic
186
+ // checker, auto-return recovery) gets the same "renewing" signal
187
+ // instead of just looking frozen until handleLoanAutoRenewed() clears
188
+ // this and restarts the timer.
189
+ this.loanRenewInProgress = true;
183
190
  window.IALendingIntervals.clearAll();
184
191
  }
185
192
  }
@@ -304,17 +311,27 @@ export default class IABookActions extends LitElement {
304
311
  * dispatched this event from bookreader page changed
305
312
  */
306
313
  window.addEventListener('BookReader:userAction', () => {
314
+ // Capture before autoRenewExpiredLoan() runs — it synchronously flips
315
+ // lendingStatus.browsingExpired to false as an optimistic UI update,
316
+ // so checking the live property afterward would always see it as
317
+ // false and wrongly let autoLoanRenewChecker() run too. That second
318
+ // call would then overwrite loanRenewResult.renewNow (set true by
319
+ // autoRenewExpiredLoan) back to false, since the loanTime cache entry
320
+ // was already deleted by browseHasExpired() — silently killing the
321
+ // in-flight renewal and leaving the countdown stuck.
322
+ const wasExpired = this.lendingStatus.browsingExpired;
323
+
307
324
  log('[IABookActions] BookReader:userAction received', {
308
325
  borrowType: this.borrowType,
309
- browsingExpired: this.lendingStatus.browsingExpired,
326
+ browsingExpired: wasExpired,
310
327
  });
311
328
 
312
329
  // If the loan expired while the tab stayed visible, auto-renew on page turn
313
- if (this.lendingStatus.browsingExpired) {
330
+ if (wasExpired) {
314
331
  this.autoRenewExpiredLoan();
315
332
  }
316
333
 
317
- if (this.borrowType === 'browsed' && !this.lendingStatus.browsingExpired) {
334
+ if (this.borrowType === 'browsed' && !wasExpired) {
318
335
  this.autoLoanRenewChecker(true);
319
336
  }
320
337
  });
@@ -537,7 +554,7 @@ export default class IABookActions extends LitElement {
537
554
  headline: '',
538
555
  showCloseButton: false,
539
556
  closeOnBackdropClick: false,
540
- headerColor: '#194880',
557
+ headerColor: '#d9534f',
541
558
  message:
542
559
  'Due to inactivity, this book was returned, and someone else has now borrowed it. Please try again later.',
543
560
  });
@@ -545,7 +562,7 @@ export default class IABookActions extends LitElement {
545
562
  const customModalContent = html`<br />
546
563
  <div style="text-align: center">
547
564
  <button
548
- style="${modalButtonStyle.iaButton} ${modalButtonStyle.renew}"
565
+ style="${modalButtonStyle.iaButton} ${modalButtonStyle.return}"
549
566
  @click=${() => URLHelper.goToUrl(this.returnUrl, true)}
550
567
  >
551
568
  Okay
@@ -718,14 +735,15 @@ export default class IABookActions extends LitElement {
718
735
 
719
736
  if (resyncd.hasSynced) {
720
737
  secondsLeft = resyncd.whatShouldLeft;
721
- log('[IABookActions] startTimerCountdown: timer re-synced', {
738
+ log('[IABookActions] timer: timer re-synced', {
722
739
  secondsLeft,
723
740
  });
724
741
  }
725
742
 
726
- log('[IABookActions] startTimerCountdown', {
743
+ log('[IABookActions] timer', {
727
744
  secondsLeft,
728
745
  loanRenewAtLast: this.loanRenewTimeConfig.loanRenewAtLast,
746
+ pageChangedInLast: this.loanRenewTimeConfig.pageChangedInLast,
729
747
  });
730
748
 
731
749
  /**
@@ -887,13 +905,16 @@ export default class IABookActions extends LitElement {
887
905
  const action = event?.detail?.action;
888
906
  const errorMsg = event?.detail?.data?.error;
889
907
 
908
+ if (action === 'renew_loan') {
909
+ this.loanRenewInProgress = false;
910
+ }
911
+
890
912
  // For renew_loan failures after auto-renew, show the patron-facing message
891
913
  if (
892
914
  errorMsg &&
893
915
  action === 'renew_loan' &&
894
916
  errorMsg.match(/not available/)
895
917
  ) {
896
- this.loanRenewInProgress = false;
897
918
  this.showLoanUnavailableModal();
898
919
  } else if (errorMsg && action !== 'create_token') {
899
920
  this.showErrorModal(errorMsg, action);
@@ -1,7 +1,9 @@
1
- import { expect, fixture } from '@open-wc/testing';
1
+ import { expect, fixture, aTimeout } from '@open-wc/testing';
2
2
  import sinon from 'sinon';
3
+ import { LocalCache } from '@internetarchive/local-cache';
3
4
 
4
5
  import ActionsHandler from '../../../src/core/services/actions-handler/actions-handler.js';
6
+ import { analyticsCategories, analyticsActions, analyticsLabels } from '../../../src/core/config/analytics-event-and-category.js';
5
7
 
6
8
  // Define a temporary tag for this element for testing
7
9
  const TEST_TAG = 'ia-actions-handler-test';
@@ -89,3 +91,110 @@ describe('ActionsHandler#setStickyAdminAccess', () => {
89
91
  expect(lastSetCookie).to.contain(`${encodeURIComponent('sticky-admin-access')}=${encodeURIComponent('false')}`);
90
92
  });
91
93
  });
94
+
95
+ describe('ActionsHandler#handleLoanRenewNow (WEBDEV-8322)', () => {
96
+ let el;
97
+ let localCache;
98
+ const identifier = 'renew-now-test-book';
99
+
100
+ beforeEach(async () => {
101
+ // Make sure no other test in this run left ?error=true in the URL —
102
+ // ActionsHandlerService reads it directly off window.location.
103
+ const params = new URLSearchParams(window.location.search);
104
+ if (params.has('error')) {
105
+ params.delete('error');
106
+ const query = params.toString();
107
+ window.history.replaceState(
108
+ {},
109
+ '',
110
+ `${window.location.pathname}${query ? `?${query}` : ''}`
111
+ );
112
+ }
113
+
114
+ el = await fixture(`<${TEST_TAG}></${TEST_TAG}>`);
115
+ el.identifier = identifier;
116
+ el.loanTotalTime = 3600;
117
+ localCache = new LocalCache({ namespace: 'loanRenew' });
118
+ el.localCache = localCache;
119
+ // The demo/test environment's ActionsHandlerService fakes a renew_loan
120
+ // success after a 5s delay to simulate a real network round-trip.
121
+ await localCache.delete(`${identifier}-loanTime`);
122
+ });
123
+
124
+ afterEach(() => {
125
+ sinon.restore();
126
+ });
127
+
128
+ it('awaits the loanTime cache write before dispatching loanAutoRenewed, and fires the renew analytics event only on success', async function test() {
129
+ this.timeout(7000);
130
+
131
+ const storeStatsSpy = sinon.spy(el.loanAnanlytics, 'storeLoanStatsCount');
132
+ const sendEventSpy = sinon.spy(el.loanAnanlytics, 'sendEvent');
133
+
134
+ let loanTimeWhenEventFired;
135
+ el.addEventListener('loanAutoRenewed', () => {
136
+ loanTimeWhenEventFired = localCache.get(`${identifier}-loanTime`);
137
+ });
138
+
139
+ el.handleLoanRenewNow('auto');
140
+
141
+ // ActionsHandlerService fakes a 5s delay for renew_loan in this
142
+ // (localhost) test environment — wait it out, matching the existing
143
+ // pattern used elsewhere in this test suite for the same fake delay.
144
+ await aTimeout(5500);
145
+
146
+ // The race this guards against: loanAutoRenewed used to dispatch
147
+ // before setBrowseTimeSession()'s cache write landed, so a listener
148
+ // reading the cache immediately could still see the old/deleted value.
149
+ const resolvedLoanTime = await loanTimeWhenEventFired;
150
+ expect(resolvedLoanTime).to.exist;
151
+ expect(resolvedLoanTime.getTime()).to.be.greaterThan(Date.now());
152
+
153
+ expect(storeStatsSpy.calledWith(identifier, 'autorenew')).to.be.true;
154
+ // storeLoanStatsCount fires its own matrix-stats sendEvent internally,
155
+ // so assert on the specific renew-success event rather than call count.
156
+ expect(
157
+ sendEventSpy.calledWith(
158
+ analyticsCategories.browse,
159
+ analyticsActions.browseRenew,
160
+ analyticsLabels.browseAutoRenew,
161
+ identifier
162
+ )
163
+ ).to.be.true;
164
+ });
165
+
166
+ it('does not fire the renew analytics event when the renewal fails', async () => {
167
+ const sendEventSpy = sinon.spy(el.loanAnanlytics, 'sendEvent');
168
+ const storeStatsSpy = sinon.spy(el.loanAnanlytics, 'storeLoanStatsCount');
169
+
170
+ // Force ActionsHandlerService's shouldReturnError branch for this call.
171
+ const originalSearch = window.location.search;
172
+ const params = new URLSearchParams(originalSearch);
173
+ params.set('error', 'true');
174
+ window.history.pushState({}, '', `?${params.toString()}`);
175
+
176
+ let errorEventDetail;
177
+ el.addEventListener('lendingActionError', ({ detail }) => {
178
+ errorEventDetail = detail;
179
+ });
180
+
181
+ try {
182
+ el.handleLoanRenewNow('auto');
183
+ await aTimeout(200);
184
+
185
+ expect(errorEventDetail?.action).to.equal('renew_loan');
186
+ // dispatchActionError fires its own 'LendingServiceError' analytics
187
+ // event on any error — that's expected. What must NOT fire is the
188
+ // renew-success event this fix gates on `isRenewal`.
189
+ expect(
190
+ sendEventSpy.calledWith(
191
+ analyticsCategories.browse,
192
+ analyticsActions.browseRenew
193
+ )
194
+ ).to.be.false;
195
+ expect(storeStatsSpy.calledWith(identifier, 'autorenew')).to.be.false;
196
+ } finally {
197
+ window.history.pushState({}, '', `${window.location.pathname}${originalSearch}`);
198
+ }
199
+ });
200
+ });
@@ -719,6 +719,127 @@ describe('autoRenewExpiredLoan', () => {
719
719
  });
720
720
  });
721
721
 
722
+ describe('BookReader:userAction race regression (WEBDEV-8322)', () => {
723
+ it('does not let autoLoanRenewChecker clobber an in-flight expired-loan renewal', async () => {
724
+ // Get borrowType derived as 'browsed' first, same as the
725
+ // "Expiring book cancels interval" test above — hasExpired renders
726
+ // leave borrowType untouched, so it must already be 'browsed' before
727
+ // the loan is marked expired.
728
+ const baseStatus = {
729
+ is_lendable: true,
730
+ user_has_browsed: true,
731
+ browsingExpired: false,
732
+ };
733
+ const el = await fixture(
734
+ container({
735
+ userid: '@user1',
736
+ identifier: 'foobar',
737
+ lendingStatus: baseStatus,
738
+ })
739
+ );
740
+ await el.updateComplete;
741
+ expect(el.borrowType).to.equal('browsed');
742
+
743
+ el.lendingStatus = { ...baseStatus, browsingExpired: true };
744
+ await el.updateComplete;
745
+
746
+ const autoLoanRenewCheckerSpy = Sinon.spy(el, 'autoLoanRenewChecker');
747
+
748
+ window.dispatchEvent(new CustomEvent('BookReader:userAction'));
749
+
750
+ // autoRenewExpiredLoan()'s setTimeout(0) sets renewNow=true. If the old
751
+ // bug were present, the still-live lendingStatus.browsingExpired check
752
+ // would (wrongly) also let autoLoanRenewChecker(true) run — which reads
753
+ // the already-deleted loanTime cache key and overwrites renewNow back
754
+ // to false. Give both paths plenty of time to resolve.
755
+ await aTimeout(300);
756
+
757
+ expect(autoLoanRenewCheckerSpy.called).to.be.false;
758
+ expect(el.loanRenewResult.renewNow).to.be.true;
759
+ });
760
+ });
761
+
762
+ describe('handleLendingActionError - loanRenewInProgress reset', () => {
763
+ it('clears loanRenewInProgress on any renew_loan failure, not just "not available"', async () => {
764
+ const el = await fixture(
765
+ container({
766
+ userid: '@user1',
767
+ identifier: 'foobar',
768
+ lendingStatus: { user_has_browsed: true, browsingExpired: true },
769
+ })
770
+ );
771
+ await el.updateComplete;
772
+
773
+ el.loanRenewInProgress = true;
774
+
775
+ el.handleLendingActionError({
776
+ detail: {
777
+ action: 'renew_loan',
778
+ data: { error: 'some other unrelated failure' },
779
+ },
780
+ });
781
+
782
+ expect(el.loanRenewInProgress).to.be.false;
783
+ });
784
+ });
785
+
786
+ describe('showWarningModal', () => {
787
+ it('shows a headline, one Okay button, and an info-icon help link; Okay dismisses without renewing', async () => {
788
+ const el = await fixture(
789
+ container({
790
+ userid: '@user1',
791
+ identifier: 'foobar',
792
+ lendingStatus: { user_has_browsed: true, secondsLeftOnLoan: 100 },
793
+ })
794
+ );
795
+ await el.updateComplete;
796
+
797
+ await el.showWarningModal();
798
+
799
+ const modalManagerEl = document.body.querySelector('modal-manager');
800
+ const modalTemplateEl =
801
+ modalManagerEl.shadowRoot.querySelector('modal-template');
802
+ const headline =
803
+ modalTemplateEl.shadowRoot.querySelector('.headline')?.textContent;
804
+ expect(headline).to.contain('Are you still there?');
805
+
806
+ const helpLink = modalTemplateEl.shadowRoot.querySelector(
807
+ 'a[href="https://help.archive.org/help/borrowing-from-the-lending-library"]'
808
+ );
809
+ expect(helpLink).to.exist;
810
+
811
+ const buttons = modalManagerEl.shadowRoot.querySelectorAll(
812
+ '#book-action-bar-custom-buttons button'
813
+ );
814
+ expect(buttons.length).to.equal(1);
815
+ expect(buttons[0].textContent).to.contain('Okay');
816
+
817
+ buttons[0].click();
818
+
819
+ expect(el.warningModalOpen).to.be.false;
820
+ expect(el.warningModalDismissed).to.be.true;
821
+ expect(el.loanRenewResult.renewNow).to.be.false;
822
+ });
823
+
824
+ it('does not reopen while already open', async () => {
825
+ const el = await fixture(
826
+ container({
827
+ userid: '@user1',
828
+ identifier: 'foobar',
829
+ lendingStatus: { user_has_browsed: true, secondsLeftOnLoan: 100 },
830
+ })
831
+ );
832
+ await el.updateComplete;
833
+
834
+ await el.showWarningModal();
835
+ const showModalSpy = Sinon.spy(el.modal, 'showModal');
836
+
837
+ await el.showWarningModal();
838
+
839
+ expect(showModalSpy.called).to.be.false;
840
+ });
841
+ });
842
+
722
843
  describe('Shared Resize Observer', () => {
723
844
  it('can receive a Shared Resize Observer', async () => {
724
845
  const sharedObserverStub = new SharedResizeObserver();