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

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-alpha3",
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
  });
@@ -398,6 +415,16 @@ export default class IABookActions extends LitElement {
398
415
 
399
416
  this.modal?.closeModal();
400
417
 
418
+ // The loan genuinely lapsed while the tab was hidden/backgrounded, so
419
+ // BookReader's page-image access (issued via create_token, not
420
+ // renew_loan) died with it. Resetting this lets the token poller's
421
+ // next successful create_token call re-run lendingBarPostInit() —
422
+ // whatever re-initializes BookReader (e.g. br.init()) — instead of
423
+ // silently skipping it because a token was already issued once this
424
+ // page load. Without this, the loan's countdown resets but BookReader
425
+ // keeps using its now-dead session, so page images break.
426
+ this.postInitComplete = false;
427
+
401
428
  // Optimistically flip browsingExpired back to false so the action bar
402
429
  // stays red (patronIsReadingAction) while the renew_loan request is in
403
430
  // flight, instead of showing the blue "Borrow" state.
@@ -537,7 +564,7 @@ export default class IABookActions extends LitElement {
537
564
  headline: '',
538
565
  showCloseButton: false,
539
566
  closeOnBackdropClick: false,
540
- headerColor: '#194880',
567
+ headerColor: '#d9534f',
541
568
  message:
542
569
  'Due to inactivity, this book was returned, and someone else has now borrowed it. Please try again later.',
543
570
  });
@@ -545,7 +572,7 @@ export default class IABookActions extends LitElement {
545
572
  const customModalContent = html`<br />
546
573
  <div style="text-align: center">
547
574
  <button
548
- style="${modalButtonStyle.iaButton} ${modalButtonStyle.renew}"
575
+ style="${modalButtonStyle.iaButton} ${modalButtonStyle.return}"
549
576
  @click=${() => URLHelper.goToUrl(this.returnUrl, true)}
550
577
  >
551
578
  Okay
@@ -718,14 +745,15 @@ export default class IABookActions extends LitElement {
718
745
 
719
746
  if (resyncd.hasSynced) {
720
747
  secondsLeft = resyncd.whatShouldLeft;
721
- log('[IABookActions] startTimerCountdown: timer re-synced', {
748
+ log('[IABookActions] timer: timer re-synced', {
722
749
  secondsLeft,
723
750
  });
724
751
  }
725
752
 
726
- log('[IABookActions] startTimerCountdown', {
753
+ log('[IABookActions] timer', {
727
754
  secondsLeft,
728
755
  loanRenewAtLast: this.loanRenewTimeConfig.loanRenewAtLast,
756
+ pageChangedInLast: this.loanRenewTimeConfig.pageChangedInLast,
729
757
  });
730
758
 
731
759
  /**
@@ -887,13 +915,16 @@ export default class IABookActions extends LitElement {
887
915
  const action = event?.detail?.action;
888
916
  const errorMsg = event?.detail?.data?.error;
889
917
 
918
+ if (action === 'renew_loan') {
919
+ this.loanRenewInProgress = false;
920
+ }
921
+
890
922
  // For renew_loan failures after auto-renew, show the patron-facing message
891
923
  if (
892
924
  errorMsg &&
893
925
  action === 'renew_loan' &&
894
926
  errorMsg.match(/not available/)
895
927
  ) {
896
- this.loanRenewInProgress = false;
897
928
  this.showLoanUnavailableModal();
898
929
  } else if (errorMsg && action !== 'create_token') {
899
930
  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();