@hot-updater/test-utils 1.0.0-rc.35 → 1.0.0-rc.37

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/dist/index.mjs CHANGED
@@ -2902,10 +2902,11 @@ const storage = {
2902
2902
  resolveFileUrl: async (storageUri) => `https://storage.example.com/${encodeURIComponent(storageUri)}`
2903
2903
  };
2904
2904
  /**
2905
- * Core's bundles, releases, channels, and a client API key, and 25 Insights
2905
+ * Core's bundles, releases, channels, and a client API key, and 26 Insights
2906
2906
  * events: installs 1–24 ten minutes apart from T0, then install 1 again two
2907
- * days later; and the day of history before T0, as a production database
2908
- * holds, so a read that scans beyond its window examines more rows.
2907
+ * days later, back on bundle-a and then on bundle-b; and the day of history
2908
+ * before T0, as a production database holds, so a read that scans beyond its
2909
+ * window examines more rows.
2909
2910
  */
2910
2911
  const seed = async (database) => {
2911
2912
  const core = database.core;
@@ -2951,8 +2952,17 @@ const seed = async (database) => {
2951
2952
  for (let n = 1; n <= 24; n += 1) await api.insights.recordEvent(eventOf(n));
2952
2953
  await api.insights.recordEvent(eventOf(25, {
2953
2954
  install_id: "install-1",
2955
+ from_release_id: "release-b",
2956
+ from_bundle_id: "bundle-b",
2957
+ to_release_id: "release-a",
2958
+ to_bundle_id: "bundle-a",
2954
2959
  received_at_ms: T0 + 2 * DAY
2955
2960
  }));
2961
+ await api.insights.recordEvent(eventOf(26, {
2962
+ install_id: "install-1",
2963
+ user_id: "user-odd",
2964
+ received_at_ms: T0 + 2 * DAY + 6e5
2965
+ }));
2956
2966
  for (let n = 1; n <= 72; n += 1) await api.insights.recordEvent(historyOf(n));
2957
2967
  await api.insights.recordEvent(failureOf(1, 3));
2958
2968
  await api.insights.recordEvent(failureOf(2, 5));
@@ -3277,9 +3287,9 @@ const READ_BUDGETS = [
3277
3287
  },
3278
3288
  intervalMs: DAY
3279
3289
  }),
3280
- adapter: reads(0, 0, 4, 30),
3290
+ adapter: reads(0, 0, 6, 30),
3281
3291
  engine: {
3282
- calls: 4,
3292
+ calls: 6,
3283
3293
  rows: 4
3284
3294
  },
3285
3295
  check: ({ versions }) => expect(versions).toEqual([{
@@ -3386,11 +3396,16 @@ const READ_BUDGETS = [
3386
3396
  }),
3387
3397
  budget({
3388
3398
  api: "record an insights event: 2 dependent rounds of batch gets and 1 write",
3389
- read: ({ insights }) => insights.recordEvent(eventOf(26, {
3399
+ read: ({ insights }) => insights.recordEvent(eventOf(27, {
3390
3400
  install_id: "install-2",
3401
+ user_id: "user-even",
3402
+ from_release_id: "release-b",
3403
+ from_bundle_id: "bundle-b",
3404
+ to_release_id: "release-c",
3405
+ to_bundle_id: "bundle-c",
3391
3406
  received_at_ms: T0 + 3 * HOUR
3392
3407
  })),
3393
- adapter: reads(4, 6, 0, 0),
3408
+ adapter: reads(6, 14, 0, 0),
3394
3409
  engine: {
3395
3410
  calls: 2,
3396
3411
  rows: 1
@@ -4261,20 +4276,31 @@ const registerInsightsModelTests = (state) => {
4261
4276
  limit: 1
4262
4277
  }), [expected]);
4263
4278
  }
4264
- await expect(model.findLatestEvents({ installId: unchanged.install_id })).resolves.toEqual([unchanged]);
4279
+ const firstSeen = {
4280
+ ...unchanged,
4281
+ metadata: {
4282
+ ...unchanged.metadata,
4283
+ change: {
4284
+ kinds: ["first_seen"],
4285
+ previous: null
4286
+ }
4287
+ }
4288
+ };
4289
+ await expect(model.findLatestEvents({ installId: unchanged.install_id })).resolves.toEqual([firstSeen]);
4265
4290
  await expectInsightsIndex(() => model.listEvents({
4266
4291
  filter: { kind: "all" },
4267
4292
  sinceMs: 100,
4268
4293
  beforeReceivedAtMs: 200,
4269
4294
  limit: 10
4270
4295
  }), [
4296
+ firstSeen,
4271
4297
  excluded[4],
4272
4298
  recovered,
4273
4299
  excluded[3],
4274
4300
  excluded[2],
4275
4301
  applied
4276
4302
  ]);
4277
- await expect(model.countEvents({
4303
+ await expectInsightsIndex(() => model.countEvents({
4278
4304
  filter: {
4279
4305
  platform: "ios",
4280
4306
  channel: "production",
@@ -4283,7 +4309,7 @@ const registerInsightsModelTests = (state) => {
4283
4309
  },
4284
4310
  sinceMs: 100,
4285
4311
  beforeReceivedAtMs: 200
4286
- })).rejects.toThrow();
4312
+ }), 0);
4287
4313
  await expectInsightsIndex(() => model.countLatestEvents({
4288
4314
  platform: "ios",
4289
4315
  channel: "production",
@@ -4444,7 +4470,9 @@ const registerInsightsModelTests = (state) => {
4444
4470
  id: createBundleEventRowFixture("9804", 3e3).id,
4445
4471
  type: "RECOVERED",
4446
4472
  from_release_id: releaseB,
4473
+ from_bundle_id: base.to_bundle_id,
4447
4474
  to_release_id: releaseA,
4475
+ to_bundle_id: base.from_bundle_id,
4448
4476
  received_at_ms: 3e3
4449
4477
  };
4450
4478
  for (const event of [
@@ -4497,7 +4525,7 @@ const registerInsightsModelTests = (state) => {
4497
4525
  await expectInsightsIndex(() => hourly({
4498
4526
  ...scope,
4499
4527
  type: "RECOVERED",
4500
- fromBundleId: base.from_bundle_id
4528
+ fromBundleId: base.to_bundle_id
4501
4529
  }), points(1, 0, 0));
4502
4530
  const usage = await model.getAppUsage({
4503
4531
  channel: "production",
@@ -4517,14 +4545,62 @@ const registerInsightsModelTests = (state) => {
4517
4545
  appVersion: "1.0.0",
4518
4546
  platform: "ios",
4519
4547
  releaseId: releaseA,
4548
+ builtinBundleId: null,
4520
4549
  installations: 1
4521
4550
  }, {
4522
4551
  appVersion: "1.0.0",
4523
4552
  platform: "ios",
4524
4553
  releaseId: releaseB,
4554
+ builtinBundleId: null,
4525
4555
  installations: 1
4526
4556
  }]));
4527
4557
  });
4558
+ it("distributes installations on their native build's built-in bundle by its ID", async () => {
4559
+ const model = state.getDatabase();
4560
+ const channel = "builtin-distribution";
4561
+ const launch = (suffix, bundleId, minBundleId) => {
4562
+ const base = createBundleEventRowFixture(suffix, 1e3);
4563
+ return {
4564
+ ...base,
4565
+ type: "UNCHANGED",
4566
+ channel,
4567
+ from_bundle_id: null,
4568
+ to_bundle_id: bundleId,
4569
+ metadata: {
4570
+ ...base.metadata,
4571
+ update_strategy: null,
4572
+ min_bundle_id: minBundleId
4573
+ }
4574
+ };
4575
+ };
4576
+ const builtin = createBundleEventRowFixture("9901", 0).id;
4577
+ await record(model, launch("9902", builtin, builtin));
4578
+ await record(model, launch("9903", builtin, builtin));
4579
+ await record(model, launch("9904", createBundleEventRowFixture("9905", 0).id, builtin));
4580
+ const usage = await model.getAppUsage({
4581
+ channel,
4582
+ platform: "ios",
4583
+ timeRange: {
4584
+ start: 0,
4585
+ end: 36e5
4586
+ },
4587
+ intervalMs: 36e5
4588
+ });
4589
+ expect(usage.bundleDistribution).toEqual(expect.arrayContaining([{
4590
+ appVersion: "1.0.0",
4591
+ platform: "ios",
4592
+ releaseId: null,
4593
+ builtinBundleId: builtin,
4594
+ installations: 2
4595
+ }, {
4596
+ appVersion: "1.0.0",
4597
+ platform: "ios",
4598
+ releaseId: null,
4599
+ builtinBundleId: null,
4600
+ installations: 1
4601
+ }]));
4602
+ expect(usage.bundleDistribution).toHaveLength(2);
4603
+ });
4528
4604
  it("pages insights events newest first with a stable cursor", async () => {
4529
4605
  const model = state.getDatabase();
4530
4606
  const first = createBundleEventRowFixture("701", 100);
@@ -4555,24 +4631,26 @@ const registerInsightsModelTests = (state) => {
4555
4631
  });
4556
4632
  it("filters installation movements before applying the page limit", async () => {
4557
4633
  const model = state.getDatabase();
4634
+ const unrelated = createMovementEvent("712", 250, "UPDATE_APPLIED", "install-other");
4635
+ const applied = createMovementEvent("713", 200, "UPDATE_APPLIED", "install-target");
4636
+ const recovered = createMovementEvent("714", 100, "RECOVERED", "install-target");
4558
4637
  const unchanged = {
4559
- ...createBundleEventRowFixture("711", 300),
4638
+ ...applied,
4639
+ id: createBundleEventRowFixture("711", 300).id,
4560
4640
  type: "UNCHANGED",
4561
- install_id: "install-target",
4562
4641
  from_bundle_id: null,
4642
+ from_release_id: null,
4563
4643
  metadata: {
4564
- ...createBundleEventRowFixture("711", 300).metadata,
4644
+ ...applied.metadata,
4565
4645
  update_strategy: null
4566
- }
4646
+ },
4647
+ received_at_ms: 300
4567
4648
  };
4568
- const unrelated = createMovementEvent("712", 250, "UPDATE_APPLIED", "install-other");
4569
- const applied = createMovementEvent("713", 200, "UPDATE_APPLIED", "install-target");
4570
- const recovered = createMovementEvent("714", 100, "RECOVERED", "install-target");
4571
4649
  for (const row of [
4572
- unchanged,
4650
+ recovered,
4573
4651
  unrelated,
4574
4652
  applied,
4575
- recovered
4653
+ unchanged
4576
4654
  ]) await model.recordEvent({ event: row });
4577
4655
  await expectInsightsIndex(() => model.listEvents({
4578
4656
  filter: {
@@ -4595,6 +4673,200 @@ const registerInsightsModelTests = (state) => {
4595
4673
  limit: 1
4596
4674
  }), [recovered]);
4597
4675
  });
4676
+ it("keeps an UNCHANGED report only when it changes what its installation runs, and counts the release it launched", async () => {
4677
+ const model = state.getDatabase();
4678
+ const day = 864e5;
4679
+ const releaseA = "00000000-0000-7000-8000-000000009a01";
4680
+ const releaseB = "00000000-0000-7000-8000-000000009a02";
4681
+ const base = createBundleEventRowFixture("9910", 1e3);
4682
+ const launch = (suffix, receivedAtMs, fields = {}) => ({
4683
+ ...base,
4684
+ id: createBundleEventRowFixture(suffix, receivedAtMs).id,
4685
+ type: "UNCHANGED",
4686
+ install_id: "install-kept",
4687
+ from_bundle_id: null,
4688
+ from_release_id: null,
4689
+ to_bundle_id: base.from_bundle_id,
4690
+ to_release_id: releaseA,
4691
+ metadata: {
4692
+ ...base.metadata,
4693
+ update_strategy: null
4694
+ },
4695
+ received_at_ms: receivedAtMs,
4696
+ ...fields
4697
+ });
4698
+ const first = launch("9911", 1e3);
4699
+ const same = launch("9912", 86401e3);
4700
+ const moved = launch("9913", 86402e3, {
4701
+ to_bundle_id: base.to_bundle_id,
4702
+ to_release_id: releaseB
4703
+ });
4704
+ for (const row of [
4705
+ first,
4706
+ same,
4707
+ moved
4708
+ ]) await record(model, row);
4709
+ await expectInsightsIndex(() => model.listEvents({
4710
+ filter: {
4711
+ kind: "installationMovement",
4712
+ installId: "install-kept"
4713
+ },
4714
+ beforeReceivedAtMs: 2 * day,
4715
+ limit: 10
4716
+ }), [{
4717
+ ...moved,
4718
+ metadata: {
4719
+ ...moved.metadata,
4720
+ implied_download: true,
4721
+ change: {
4722
+ kinds: ["bundle", "release"],
4723
+ previous: {
4724
+ bundle_id: base.from_bundle_id,
4725
+ release_id: releaseA,
4726
+ app_version: base.app_version,
4727
+ channel: base.channel
4728
+ }
4729
+ }
4730
+ }
4731
+ }, {
4732
+ ...first,
4733
+ metadata: {
4734
+ ...first.metadata,
4735
+ change: {
4736
+ kinds: ["first_seen"],
4737
+ previous: null
4738
+ }
4739
+ }
4740
+ }]);
4741
+ await expectInsightsIndex(() => model.countEvents({
4742
+ filter: {
4743
+ platform: "ios",
4744
+ channel: "production",
4745
+ type: "UNCHANGED",
4746
+ toBundleId: base.to_bundle_id
4747
+ },
4748
+ sinceMs: 0,
4749
+ beforeReceivedAtMs: 2 * day
4750
+ }), 1);
4751
+ const activity = await model.getReleaseActivity({ releases: [{
4752
+ releaseId: releaseA,
4753
+ platform: "ios",
4754
+ channel: "production"
4755
+ }, {
4756
+ releaseId: releaseB,
4757
+ platform: "ios",
4758
+ channel: "production"
4759
+ }] });
4760
+ expect(activity.data.map(({ metrics }) => metrics)).toEqual([{
4761
+ downloads: 0,
4762
+ applies: 0,
4763
+ failedLaunches: 0
4764
+ }, {
4765
+ downloads: 1,
4766
+ applies: 1,
4767
+ failedLaunches: 0
4768
+ }]);
4769
+ });
4770
+ it("counts each launch and download once, so a bundle's launches match its release and its downloads cover launches and crashes", async () => {
4771
+ const model = state.getDatabase();
4772
+ const day = 864e5;
4773
+ const releaseA = "00000000-0000-7000-8000-000000009b01";
4774
+ const releaseB = "00000000-0000-7000-8000-000000009b02";
4775
+ const base = createBundleEventRowFixture("9920", 1e3);
4776
+ const bundleA = base.from_bundle_id;
4777
+ const bundleB = base.to_bundle_id;
4778
+ let sequence = 9920;
4779
+ const report = (install, receivedAtMs, fields) => {
4780
+ sequence += 1;
4781
+ return {
4782
+ ...base,
4783
+ id: createBundleEventRowFixture(String(sequence), receivedAtMs).id,
4784
+ install_id: install,
4785
+ from_release_id: releaseA,
4786
+ to_release_id: releaseB,
4787
+ received_at_ms: receivedAtMs,
4788
+ ...fields
4789
+ };
4790
+ };
4791
+ const launch = (install, at, bundle) => report(install, at, {
4792
+ type: "UNCHANGED",
4793
+ from_bundle_id: null,
4794
+ from_release_id: null,
4795
+ to_bundle_id: bundle,
4796
+ to_release_id: bundle === bundleA ? releaseA : releaseB,
4797
+ metadata: {
4798
+ ...base.metadata,
4799
+ update_strategy: null
4800
+ }
4801
+ });
4802
+ const download = (install, at) => report(install, at, { type: "UPDATE_DOWNLOADED" });
4803
+ const apply = (install, at) => report(install, at, { type: "UPDATE_APPLIED" });
4804
+ const rows = [
4805
+ launch("i1", 1e3, bundleA),
4806
+ download("i1", 2e3),
4807
+ apply("i1", 3e3),
4808
+ launch("i2", 1e3, bundleA),
4809
+ launch("i2", 86401e3, bundleB),
4810
+ launch("i3", 1e3, bundleA),
4811
+ download("i3", 2e3),
4812
+ launch("i3", 86402e3, bundleB),
4813
+ launch("i4", 1e3, bundleA),
4814
+ apply("i4", 3e3),
4815
+ download("i4", 4e3),
4816
+ launch("i5", 1e3, bundleA),
4817
+ download("i5", 2e3),
4818
+ report("i5", 3e3, {
4819
+ type: "RECOVERED",
4820
+ from_release_id: releaseB,
4821
+ from_bundle_id: bundleB,
4822
+ to_release_id: releaseA,
4823
+ to_bundle_id: bundleA
4824
+ })
4825
+ ];
4826
+ for (const row of rows) await record(model, row);
4827
+ const scope = {
4828
+ platform: "ios",
4829
+ channel: "production"
4830
+ };
4831
+ const range = {
4832
+ sinceMs: 0,
4833
+ beforeReceivedAtMs: 2 * day
4834
+ };
4835
+ const count = (filter) => model.countEvents({
4836
+ filter,
4837
+ ...range
4838
+ });
4839
+ const [{ metrics }] = (await model.getReleaseActivity({ releases: [{
4840
+ releaseId: releaseB,
4841
+ ...scope
4842
+ }] })).data;
4843
+ expect(metrics).toEqual({
4844
+ downloads: 5,
4845
+ applies: 4,
4846
+ failedLaunches: 1
4847
+ });
4848
+ await expectInsightsIndex(async () => await count({
4849
+ ...scope,
4850
+ type: "UPDATE_APPLIED",
4851
+ toBundleId: bundleB
4852
+ }) + await count({
4853
+ ...scope,
4854
+ type: "UNCHANGED",
4855
+ toBundleId: bundleB
4856
+ }), metrics.applies);
4857
+ const implied = (await model.listEvents({
4858
+ filter: { kind: "all" },
4859
+ ...range,
4860
+ limit: 100
4861
+ })).filter(({ metadata }) => metadata.implied_download === true).length;
4862
+ await expectInsightsIndex(() => count({
4863
+ ...scope,
4864
+ type: "UPDATE_DOWNLOADED",
4865
+ toBundleId: bundleB
4866
+ }), metrics.downloads - implied);
4867
+ expect(implied).toBe(2);
4868
+ expect(metrics.downloads).toBeGreaterThanOrEqual(metrics.applies + metrics.failedLaunches);
4869
+ });
4598
4870
  it("lists an update failure where its target's list and installation history read it, and a failed check in no bundle list, moving no latest event", async () => {
4599
4871
  const model = state.getDatabase();
4600
4872
  const applied = createMovementEvent("721", 100, "UPDATE_APPLIED", "install-failing");
@@ -4806,10 +5078,11 @@ const setupInsightsHttpTestSuite = (options) => {
4806
5078
  httpResponse
4807
5079
  });
4808
5080
  const data = (await (await client.admin(`/installations/${encodeURIComponent(installId)}/events`)).json()).data;
4809
- expect(data).toHaveLength(type === "UNCHANGED" ? 0 : 1);
4810
- if (type !== "UNCHANGED") expect(data[0]).toMatchObject({
5081
+ expect(data).toHaveLength(1);
5082
+ expect(data[0]).toMatchObject({
4811
5083
  type,
4812
- httpResponse
5084
+ httpResponse,
5085
+ ...type === "UNCHANGED" ? { change: { kinds: ["first_seen"] } } : {}
4813
5086
  });
4814
5087
  });
4815
5088
  it("records an update failure in installation history and the failures read", async () => {
@@ -127,6 +127,7 @@ const setupClientPlugins = (plugins, options = {}) => {
127
127
  sdkVersion: options.sdkVersion ?? "0.0.0-test",
128
128
  getInstallId: () => read(options.installId ?? "00000000-0000-4000-8000-000000000000"),
129
129
  getAppVersion: () => read(options.appVersion === void 0 ? "1.0.0" : options.appVersion),
130
+ getMinBundleId: () => options.minBundleId ?? "00000000-0000-0000-0000-000000000000",
130
131
  getBundleId: () => read(options.bundleId ?? "00000000-0000-0000-0000-000000000000"),
131
132
  getChannel: () => read(options.channel ?? "production"),
132
133
  getCohort: () => read(options.cohort ?? "1"),
@@ -26,6 +26,12 @@ interface ClientPluginTestOptions {
26
26
  readonly isDebugBuild?: boolean;
27
27
  /** The running bundle. Default: `00000000-0000-0000-0000-000000000000`. */
28
28
  readonly bundleId?: TestValue<string>;
29
+ /**
30
+ * The native build's built-in bundle. Default:
31
+ * `00000000-0000-0000-0000-000000000000`, so the default bundle is the
32
+ * built-in one.
33
+ */
34
+ readonly minBundleId?: string;
29
35
  /** Default: `production`. */
30
36
  readonly channel?: TestValue<string>;
31
37
  /** Default: `1`. */
@@ -26,6 +26,12 @@ interface ClientPluginTestOptions {
26
26
  readonly isDebugBuild?: boolean;
27
27
  /** The running bundle. Default: `00000000-0000-0000-0000-000000000000`. */
28
28
  readonly bundleId?: TestValue<string>;
29
+ /**
30
+ * The native build's built-in bundle. Default:
31
+ * `00000000-0000-0000-0000-000000000000`, so the default bundle is the
32
+ * built-in one.
33
+ */
34
+ readonly minBundleId?: string;
29
35
  /** Default: `production`. */
30
36
  readonly channel?: TestValue<string>;
31
37
  /** Default: `1`. */
@@ -126,6 +126,7 @@ const setupClientPlugins = (plugins, options = {}) => {
126
126
  sdkVersion: options.sdkVersion ?? "0.0.0-test",
127
127
  getInstallId: () => read(options.installId ?? "00000000-0000-4000-8000-000000000000"),
128
128
  getAppVersion: () => read(options.appVersion === void 0 ? "1.0.0" : options.appVersion),
129
+ getMinBundleId: () => options.minBundleId ?? "00000000-0000-0000-0000-000000000000",
129
130
  getBundleId: () => read(options.bundleId ?? "00000000-0000-0000-0000-000000000000"),
130
131
  getChannel: () => read(options.channel ?? "production"),
131
132
  getCohort: () => read(options.cohort ?? "1"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hot-updater/test-utils",
3
- "version": "1.0.0-rc.35",
3
+ "version": "1.0.0-rc.37",
4
4
  "type": "module",
5
5
  "description": "Test utilities for Hot Updater packages",
6
6
  "sideEffects": false,
@@ -50,10 +50,10 @@
50
50
  "mysql2": "*",
51
51
  "pg": "*",
52
52
  "vitest": "^4.0.0",
53
- "@hot-updater/plugin-insights": "^1.0.0-rc.35",
54
- "@hot-updater/server": "^1.0.0-rc.35",
55
- "@hot-updater/plugin-core": "^1.0.0-rc.35",
56
- "@hot-updater/protocol": "^1.0.0-rc.35"
53
+ "@hot-updater/plugin-insights": "^1.0.0-rc.37",
54
+ "@hot-updater/plugin-core": "^1.0.0-rc.37",
55
+ "@hot-updater/protocol": "^1.0.0-rc.37",
56
+ "@hot-updater/server": "^1.0.0-rc.37"
57
57
  },
58
58
  "peerDependenciesMeta": {
59
59
  "@electric-sql/pglite": {
@@ -83,11 +83,11 @@
83
83
  "typescript": "npm:@typescript/typescript6@6.0.2",
84
84
  "vitest": "4.1.4",
85
85
  "workspace-tools": "^0.41.7",
86
- "@hot-updater/plugin-insights": "1.0.0-rc.35",
87
- "@hot-updater/protocol": "1.0.0-rc.35",
88
- "@hot-updater/js": "1.0.0-rc.35",
89
- "@hot-updater/server": "1.0.0-rc.35",
90
- "@hot-updater/plugin-core": "1.0.0-rc.35"
86
+ "@hot-updater/plugin-insights": "1.0.0-rc.37",
87
+ "@hot-updater/protocol": "1.0.0-rc.37",
88
+ "@hot-updater/js": "1.0.0-rc.37",
89
+ "@hot-updater/plugin-core": "1.0.0-rc.37",
90
+ "@hot-updater/server": "1.0.0-rc.37"
91
91
  },
92
92
  "inlinedDependencies": {
93
93
  "@nodelib/fs.scandir": "2.1.5",