Skip to content

Commit

Permalink
Add geolocation observers
Browse files Browse the repository at this point in the history
  • Loading branch information
ezzatron committed Aug 7, 2024
1 parent c9dcefb commit 3dfe2d7
Show file tree
Hide file tree
Showing 10 changed files with 970 additions and 11 deletions.
99 changes: 99 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,108 @@ Versioning].

### Added

- Added [geolocation observers].
- The `createAPIs()` and `createWrappedAPIs()` functions now accept an
`acquireDelay` option, which is passed along to the location services.

[geolocation observers]: #geolocation-observers

#### Geolocation observers

This release adds geolocation observers, which can be used to wait for specific
changes to the coordinates or errors produced by a Geolocation API. This can be
useful for testing scenarios where you want to wait for a specific state to be
reached before continuing.

You can create a permission observer by calling the
`createGeolocationObserver()` function. Once you have an observer, you can wait
for specific sets of coordinates by calling `observer.waitForCoordinates()`, or
wait for specific geolocation errors by calling
`observer.waitForPositionError()`.

```ts
import {
createAPIs,
createCoordinates,
createGeolocationObserver,
PERMISSION_DENIED,
POSITION_UNAVAILABLE,
} from "fake-geolocation";

const { geolocation, user } = createAPIs();

// We need some coords to start with
const coordsA = createCoordinates({ latitude: 1, longitude: 2 });
const coordsB = createCoordinates({ latitude: 3, longitude: 4 });

// Jump to some coords and grant permission
user.jumpToCoordinates(coordsA);
user.grantPermission({ name: "geolocation" });

// Start watching the position
let position: GeolocationPosition | undefined;
let error: GeolocationPositionError | undefined;
geolocation.watchPosition(
(p) => {
position = p;
},
(e) => {
error = e;
},
);

// Create an observer
const observer = createGeolocationObserver(geolocation);

// Wait for the position to be at coordsA
await observer.waitForCoordinates(coordsA);
// Outputs "true"
console.log(position?.coords.latitude === coordsA.latitude);

// Wait for the position to be at coordsA OR coordsB
await observer.waitForCoordinates([coordsA, coordsB]);
// Outputs "true"
console.log(position?.coords.latitude === coordsA.latitude);

// Wait for the position to have a latitude of 1
await observer.waitForCoordinates({ latitude: 1 });
// Outputs "true"
console.log(position?.coords.latitude === 1);

// Wait for the position to be at coordsB, while running a task
await observer.waitForCoordinates(coordsB, async () => {
user.jumpToCoordinates(coordsB);
});
// Outputs "true"
console.log(position?.coords.latitude === coordsB.latitude);

// Wait for the position to be at coordsB, using high accuracy
await observer.waitForCoordinates(coordsB, undefined, {
enableHighAccuracy: true,
});
// Outputs "true"
console.log(position?.coords.latitude === coordsB.latitude);

user.disableLocationServices();

// Wait for a POSITION_UNAVAILABLE error
await observer.waitForPositionError(POSITION_UNAVAILABLE);
// Outputs "true"
console.log(error?.code === POSITION_UNAVAILABLE);

// Wait for a POSITION_UNAVAILABLE OR PERMISSION_DENIED error
await observer.waitForPositionError([POSITION_UNAVAILABLE, PERMISSION_DENIED]);
// Outputs "true"
console.log(error?.code === POSITION_UNAVAILABLE);

// Wait for a PERMISSION_DENIED error, while running a task
await observer.waitForPositionError(PERMISSION_DENIED, async () => {
user.denyPermission({ name: "geolocation" });
});
// Outputs "true"
console.log(error?.code === PERMISSION_DENIED);
```

### Fixed

- Errors that are thrown asynchronously now use `queueMicrotask()` instead of
Expand Down
5 changes: 4 additions & 1 deletion src/geolocation-coordinates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ export type GeolocationCoordinatesParameters = Pick<
| "speed"
>;

export type PartialGeolocationCoordinates =
Partial<GeolocationCoordinatesParameters>;

export function createCoordinates({
latitude = 0,
longitude = 0,
Expand All @@ -19,7 +22,7 @@ export function createCoordinates({
altitudeAccuracy = null,
heading = null,
speed = null,
}: Partial<GeolocationCoordinatesParameters> = {}): globalThis.GeolocationCoordinates {
}: PartialGeolocationCoordinates = {}): globalThis.GeolocationCoordinates {
canConstruct = true;

return new GeolocationCoordinates({
Expand Down
142 changes: 142 additions & 0 deletions src/geolocation-observer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import type { PartialGeolocationCoordinates } from "./geolocation-coordinates.js";
import { type GeolocationPositionErrorCode } from "./geolocation-position-error.js";

export type GeolocationObserver = {
waitForCoordinates(
matcherOrMatchers?:
| PartialGeolocationCoordinates
| PartialGeolocationCoordinates[],
task?: () => Promise<void>,
positionOptions?: PositionOptions,
): Promise<void>;
waitForPositionError(
codeOrCodes?: GeolocationPositionErrorCode | GeolocationPositionErrorCode[],
task?: () => Promise<void>,
positionOptions?: PositionOptions,
): Promise<void>;
};

export function createGeolocationObserver(
geolocation: Geolocation,
): GeolocationObserver {
return {
async waitForCoordinates(matcherOrMatchers = [], task, positionOptions) {
const matchers = normalizeMatchers(matcherOrMatchers);

await Promise.all([
new Promise<void>((resolve) => {
const stop = watchPositionRetry(
({ coords }) => {
if (!isMatchingCoords(matchers, coords)) return;
stop();
resolve();
},
undefined,
positionOptions,
);
}),
Promise.resolve(task?.()),
]);
},

async waitForPositionError(codeOrCodes = [], task, positionOptions) {
const codes = normalizeCodes(codeOrCodes);

await Promise.all([
new Promise<void>((resolve) => {
const stop = watchPositionRetry(
undefined,
(error) => {
if (!isMatchingError(codes, error)) return;
stop();
resolve();
},
positionOptions,
);
}),
Promise.resolve(task?.()),
]);
},
};

function isMatchingCoords(
matchers: PartialGeolocationCoordinates[],
coords: GeolocationCoordinates,
): boolean {
if (matchers.length < 1) return true;

nextMatcher: for (const matcher of matchers) {
for (const property in matcher) {
if (
matcher[property as keyof typeof matcher] !==
coords[property as keyof typeof coords]
) {
continue nextMatcher;
}
}

return true;
}

return false;
}

function isMatchingError(
codes: GeolocationPositionErrorCode[],
error: GeolocationPositionError,
): boolean {
if (codes.length < 1) return true;

return codes.includes(error.code as GeolocationPositionErrorCode);
}

function watchPositionRetry(
successCallback?: PositionCallback,
errorCallback?: PositionErrorCallback,
options: PositionOptions = {},
): () => void {
let isStopped = false;
let watchId: number = 0;

startWatch();

function startWatch() {
if (isStopped) return;

watchId = geolocation.watchPosition(
(p) => {
/* v8 ignore next: race condition failsafe */
if (isStopped) return;

successCallback?.(p);
},
(e) => {
/* v8 ignore next: race condition failsafe */
if (isStopped) return;

geolocation.clearWatch(watchId);
setTimeout(startWatch, 20);
errorCallback?.(e);
},
options,
);
}

return () => {
isStopped = true;
geolocation.clearWatch(watchId);
};
}
}

function normalizeMatchers(
matchers: PartialGeolocationCoordinates | PartialGeolocationCoordinates[],
): PartialGeolocationCoordinates[] {
return Array.isArray(matchers) ? matchers : [matchers];
}

function normalizeCodes(
codes: GeolocationPositionErrorCode | GeolocationPositionErrorCode[],
): GeolocationPositionErrorCode[] {
return Array.isArray(codes) ? codes : [codes];
}
11 changes: 8 additions & 3 deletions src/geolocation-position-error.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
const PERMISSION_DENIED: globalThis.GeolocationPositionError["PERMISSION_DENIED"] = 1;
const POSITION_UNAVAILABLE: globalThis.GeolocationPositionError["POSITION_UNAVAILABLE"] = 2;
const TIMEOUT: globalThis.GeolocationPositionError["TIMEOUT"] = 3;
export const PERMISSION_DENIED: globalThis.GeolocationPositionError["PERMISSION_DENIED"] = 1;
export const POSITION_UNAVAILABLE: globalThis.GeolocationPositionError["POSITION_UNAVAILABLE"] = 2;
export const TIMEOUT: globalThis.GeolocationPositionError["TIMEOUT"] = 3;

export type GeolocationPositionErrorCode =
| typeof PERMISSION_DENIED
| typeof POSITION_UNAVAILABLE
| typeof TIMEOUT;

let canConstruct = false;

Expand Down
7 changes: 5 additions & 2 deletions src/geolocation-position.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import type { GeolocationCoordinatesParameters } from "./geolocation-coordinates.js";
import type {
GeolocationCoordinatesParameters,
PartialGeolocationCoordinates,
} from "./geolocation-coordinates.js";
import { createCoordinates } from "./geolocation-coordinates.js";

export type GeolocationPositionParameters = {
Expand All @@ -13,7 +16,7 @@ const internal = new WeakMap<
let canConstruct = false;

export function createPosition(
coords: Partial<GeolocationCoordinatesParameters> = {},
coords: PartialGeolocationCoordinates = {},
timestamp: number = 0,
isHighAccuracy: boolean = true,
): globalThis.GeolocationPosition {
Expand Down
11 changes: 10 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,18 @@ export {
GeolocationCoordinates,
createCoordinates,
} from "./geolocation-coordinates.js";
export type { GeolocationCoordinatesParameters } from "./geolocation-coordinates.js";
export type {
GeolocationCoordinatesParameters,
PartialGeolocationCoordinates,
} from "./geolocation-coordinates.js";
export { createGeolocationObserver } from "./geolocation-observer.js";
export type { GeolocationObserver } from "./geolocation-observer.js";
export {
GeolocationPositionError,
GeolocationPositionErrorCode,
PERMISSION_DENIED,
POSITION_UNAVAILABLE,
TIMEOUT,
createPermissionDeniedError,
createPositionUnavailableError,
createTimeoutError,
Expand Down
4 changes: 2 additions & 2 deletions src/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
} from "fake-permissions";
import {
createCoordinates,
type GeolocationCoordinatesParameters,
type PartialGeolocationCoordinates,
} from "./geolocation-coordinates.js";
import { MutableLocationServices } from "./location-services.js";

Expand Down Expand Up @@ -46,7 +46,7 @@ export function createUser({
coords: GeolocationCoordinates,
) => GeolocationCoordinates;
normalizeCoordinates?: (
coords: Partial<GeolocationCoordinatesParameters>,
coords: PartialGeolocationCoordinates,
) => GeolocationCoordinates;
permissionStore: PermissionStore;
}): User {
Expand Down
5 changes: 3 additions & 2 deletions test/vitest/create-coordinates.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
createCoordinates,
GeolocationCoordinates,
type GeolocationCoordinatesParameters,
type PartialGeolocationCoordinates,
} from "fake-geolocation";
import { describe, expect, it } from "vitest";

Expand Down Expand Up @@ -35,7 +36,7 @@ describe("createCoordinates()", () => {
});

describe("when some properties are provided", () => {
const properties: Partial<GeolocationCoordinatesParameters> = {
const properties: PartialGeolocationCoordinates = {
latitude: 11,
longitude: 22,
accuracy: 33,
Expand Down Expand Up @@ -77,7 +78,7 @@ describe("createCoordinates()", () => {
});

describe("when explicit undefined properties are provided", () => {
const properties: Partial<GeolocationCoordinatesParameters> = {
const properties: PartialGeolocationCoordinates = {
latitude: undefined,
longitude: undefined,
altitude: undefined,
Expand Down
Loading

0 comments on commit 3dfe2d7

Please sign in to comment.