UNPKG

scriptable-testlab

Version:

A lightweight, efficient tool designed to manage and update scripts for Scriptable.

655 lines (534 loc) โ€ข 17.1 kB
# scriptable-testlab > A comprehensive testing framework for simulating the Scriptable iOS app runtime environment [![npm version](https://badge.fury.io/js/scriptable-testlab.svg)](https://badge.fury.io/js/scriptable-testlab) [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) [![Node.js Version](https://img.shields.io/node/v/scriptable-testlab.svg)](https://nodejs.org) ## Introduction scriptable-testlab is a testing framework specifically designed for the Scriptable iOS app. It provides a complete mock runtime environment that enables developers to write and run unit tests without depending on physical iOS devices. The framework strictly implements types defined in `@types/scriptable-ios` to ensure type safety and precise API simulation. ### Core Features - ๐Ÿ”„ Complete Scriptable API simulation with strict type safety - ๐Ÿงช Comprehensive mock implementations for all Scriptable modules - ๐Ÿ“ฑ Device-independent testing environment with runtime simulation - ๐Ÿ” Type-safe development experience with full TypeScript support - โšก Fast and lightweight runtime based on scriptable-abstract - ๐Ÿ› ๏ธ Seamless Jest integration with full testing utilities - ๐Ÿ“Š Built-in test coverage support and mock assertions - ๐Ÿ”Œ Modular architecture with pluggable components ## Implementation Status Current implementation covers: - โœ… Core Runtime Environment - โœ… Device and System APIs - โœ… UI Components and Widgets - โœ… Network and Data Operations - โœ… File System Operations - โœ… Calendar and Reminders - โœ… Media and Images - โœ… Security and Keychain - โœ… Location Services - โœ… Notifications ## Installation ```bash # Using npm npm install --save-dev scriptable-testlab # Using yarn yarn add -D scriptable-testlab # Using pnpm (recommended) pnpm add -D scriptable-testlab ``` ## Basic Usage ### Runtime Setup ```typescript import {runtime} from 'scriptable-testlab'; describe('Runtime Tests', () => { beforeEach(() => { runtime.setupMocks(); runtime.configure({ device: { appearance: { isUsingDarkAppearance: true, }, }, }); }); afterEach(() => { runtime.clearMocks(); }); test('should handle system settings', () => { expect(Device.isUsingDarkAppearance()).toBe(true); }); }); ``` ### Device Configuration ```typescript import {runtime} from 'scriptable-testlab'; describe('Device Tests', () => { beforeEach(() => { runtime.setupMocks(); runtime.configure({ device: { model: 'iPhone', systemVersion: '16.0', appearance: { isUsingDarkAppearance: true, }, }, }); }); afterEach(() => { runtime.clearMocks(); }); test('should configure device settings', () => { expect(Device.model()).toBe('iPhone'); expect(Device.systemVersion()).toBe('16.0'); expect(Device.isUsingDarkAppearance()).toBe(true); }); }); ``` ### Network Operations ```typescript import {runtime} from 'scriptable-testlab'; describe('Network Tests', () => { let request: Request; beforeEach(() => { runtime.setupMocks(); request = new Request('https://api.example.com'); }); afterEach(() => { runtime.clearMocks(); }); test('should handle basic GET request', async () => { const mockedRequest = request as MockedObject<MockRequest>; mockedRequest.loadJSON.mockImplementation(async () => { mockedRequest.setState({ response: { statusCode: 200, headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({message: 'Success', data: {id: 1}}), }, }); }); const _response = await request.loadJSON(); expect(request.response?.statusCode).toBe(200); expect(request.response?.headers['Content-Type']).toBe('application/json'); expect(request.response?.body).toBe(JSON.stringify({message: 'Success', data: {id: 1}})); }); test('should handle POST request with custom headers', async () => { const mockedRequest = request as MockedObject<MockRequest>; request.method = 'POST'; request.headers = { 'Content-Type': 'application/json', }; request.body = JSON.stringify({data: 'test'}); mockedRequest.loadJSON.mockImplementation(async () => { mockedRequest.setState({ response: { statusCode: 200, headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({success: true}), }, }); }); const _response = await request.loadJSON(); expect(request.response?.statusCode).toBe(200); expect(request.response?.headers['Content-Type']).toBe('application/json'); expect(request.response?.body).toBe(JSON.stringify({success: true})); }); test('should handle different response types', async () => { const mockedRequest = request as MockedObject<MockRequest>; // String response mockedRequest.loadString.mockImplementation(async () => { mockedRequest.setState({ response: { statusCode: 200, headers: { 'Content-Type': 'text/plain', }, body: 'Hello World', }, }); return 'Hello World'; }); const textResponse = await request.loadString(); expect(typeof textResponse).toBe('string'); expect(textResponse).toBe('Hello World'); expect(request.response?.headers['Content-Type']).toBe('text/plain'); // JSON response mockedRequest.loadJSON.mockImplementation(async () => { mockedRequest.setState({ response: { statusCode: 200, headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({message: 'Hello'}), }, }); return {message: 'Hello'}; }); const jsonResponse = await request.loadJSON(); expect(typeof jsonResponse).toBe('object'); expect(jsonResponse).toEqual({message: 'Hello'}); expect(request.response?.headers['Content-Type']).toBe('application/json'); }); test('should handle request errors', async () => { const mockedRequest = request as MockedObject<MockRequest>; request.url = 'invalid-url'; mockedRequest.load.mockRejectedValue(new Error('Invalid URL')); await expect(request.load()).rejects.toThrow('Invalid URL'); }); }); ``` ### Location Services ```typescript import {runtime} from 'scriptable-testlab'; describe('Location Tests', () => { beforeEach(() => { runtime.setupMocks(); runtime.configure({ location: { latitude: 37.7749, longitude: -122.4194, altitude: 0, horizontalAccuracy: 10, verticalAccuracy: 10, }, }); }); afterEach(() => { runtime.clearMocks(); }); test('should handle location services', async () => { const location = runtime.location; expect(location.latitude).toBe(37.7749); expect(location.longitude).toBe(-122.4194); expect(location.altitude).toBe(0); }); }); ``` ### File System Operations ```typescript import {runtime} from 'scriptable-testlab'; describe('FileSystem Tests', () => { beforeEach(() => { runtime.setupMocks(); const fm = FileManager.local(); fm.writeString('test.txt', 'Hello World'); }); afterEach(() => { runtime.clearMocks(); }); test('should handle file operations', () => { const fm = FileManager.local(); expect(fm.readString('test.txt')).toBe('Hello World'); expect(fm.fileExists('test.txt')).toBe(true); // Write new content fm.writeString('test.txt', 'Updated content'); expect(fm.readString('test.txt')).toBe('Updated content'); }); }); ``` ### Notification Testing ```typescript import {runtime} from 'scriptable-testlab'; describe('Notification Tests', () => { beforeEach(() => { runtime.setupMocks(); MockNotification.reset(); }); afterEach(() => { runtime.clearMocks(); }); test('should handle notifications', async () => { const notification = new Notification(); notification.title = 'Test Title'; notification.body = 'Test Body'; notification.subtitle = 'Test Subtitle'; await notification.schedule(); const pending = await Notification.allPending(); expect(pending).toHaveLength(1); expect(pending[0].title).toBe('Test Title'); expect(pending[0].body).toBe('Test Body'); expect(pending[0].subtitle).toBe('Test Subtitle'); }); }); ``` ### Calendar Integration ```typescript import {runtime} from 'scriptable-testlab'; describe('Calendar Tests', () => { beforeEach(() => { runtime.setupMocks(); MockCalendar.clearAll(); }); afterEach(() => { runtime.clearMocks(); }); test('should handle calendar operations', async () => { // Create a calendar const calendars = await Calendar.forEvents(); const calendar = calendars[0]; // Create an event const event = new CalendarEvent(); event.title = 'Test Event'; event.notes = 'Event description'; event.startDate = new Date('2024-01-01T10:00:00'); event.endDate = new Date('2024-01-01T11:00:00'); event.isAllDay = false; // Verify the event properties expect(event.title).toBe('Test Event'); expect(event.notes).toBe('Event description'); expect(event.isAllDay).toBe(false); }); }); ``` ### Script Global Object ```typescript import {runtime} from 'scriptable-testlab'; describe('Script Tests', () => { beforeEach(() => { runtime.setupMocks(); }); afterEach(() => { runtime.clearMocks(); }); test('should handle script operations', () => { // Set script name runtime.script.setState({name: 'test-script'}); expect(Script.name()).toBe('test-script'); // Set shortcut output const shortcutData = {result: 'success', value: 42}; Script.setShortcutOutput(shortcutData); expect(runtime.script.state.shortcutOutput).toEqual(shortcutData); // Set widget const widget = new ListWidget(); widget.addText('Hello World'); Script.setWidget(widget); expect(runtime.script.state.widget).toBe(widget); // Complete script expect(() => Script.complete()).not.toThrow(); }); test('should handle script in different contexts', () => { // Configure script to run in widget runtime.configure({ widget: { widgetFamily: 'medium', runsInWidget: true, }, }); expect(config.runsInWidget).toBe(true); expect(config.widgetFamily).toBe('medium'); // Configure script to run with Siri runtime.configure({ widget: { runsWithSiri: true, }, }); expect(config.runsWithSiri).toBe(true); // Configure script to run from home screen runtime.configure({ widget: { runsFromHomeScreen: true, }, }); expect(config.runsFromHomeScreen).toBe(true); }); }); ``` ### Pasteboard Operations ```typescript import {runtime} from 'scriptable-testlab'; describe('Pasteboard Tests', () => { let mockedPasteboard: MockedObject<MockPasteboard>; beforeEach(() => { runtime.setupMocks(); mockedPasteboard = Pasteboard as MockedObject<MockPasteboard>; mockedPasteboard.clear(); }); afterEach(() => { runtime.clearMocks(); }); test('should handle text operations', () => { // Copy and paste text const text = 'Hello Scriptable'; Pasteboard.copy(text); expect(Pasteboard.paste()).toBe(text); // Copy and paste string const str = 'Test String'; Pasteboard.copyString(str); expect(Pasteboard.pasteString()).toBe(str); }); test('should handle image operations', () => { // Create a test image const image = new MockImage(); // Copy and paste image Pasteboard.copyImage(image); expect(Pasteboard.pasteImage()).toBe(image); expect(mockedPasteboard.hasImages()).toBe(true); }); test('should handle multiple items', () => { const url = 'https://example.com'; const text = 'Example Text'; // Set multiple items mockedPasteboard.setItems([{text}, {url}]); // Verify items expect(Pasteboard.paste()).toBe(text); expect(mockedPasteboard.hasURLs()).toBe(true); expect(mockedPasteboard.getURLs()).toContain(url); }); }); ``` ### Safari Operations ```typescript import {runtime} from 'scriptable-testlab'; describe('Safari Tests', () => { let mockedSafari: MockedObject<MockSafari>; beforeEach(() => { runtime.setupMocks(); mockedSafari = Safari as MockedObject<MockSafari>; mockedSafari.setState({ currentURL: null, inBackground: false, openMethod: null, fullscreen: false, }); }); afterEach(() => { runtime.clearMocks(); }); test('should demonstrate basic Safari operations', () => { // Example 1: Open the URL in your browser const browserUrl = 'https://example.com'; Safari.open(browserUrl); expect(mockedSafari['state']).toEqual({ currentURL: browserUrl, inBackground: false, openMethod: 'browser', fullscreen: false, }); // Example 2: Open URL in app (full screen) const appUrl = 'https://example.com/app'; Safari.openInApp(appUrl, true); expect(mockedSafari['state']).toEqual({ currentURL: appUrl, inBackground: false, openMethod: 'app', fullscreen: true, }); // Example 3: Open URL in app (not full screen) const appUrl2 = 'https://example.com/app2'; Safari.openInApp(appUrl2); expect(mockedSafari['state']).toEqual({ currentURL: appUrl2, inBackground: false, openMethod: 'app', fullscreen: false, }); }); test('should demonstrate error handling', () => { // Example 4: Handling invalid URLs expect(() => Safari.open('invalid-url')).toThrow('Invalid URL'); // Example 5: Handling an invalid URL protocol expect(() => Safari.open('ftp://example.com')).toThrow('Invalid URL scheme'); }); test('should demonstrate URL validation', () => { // Example 6: Verify a valid URL const validUrls = ['http://example.com', 'https://example.com', 'https://sub.domain.com/path?query=1']; validUrls.forEach(url => { expect(() => Safari.open(url)).not.toThrow(); expect(Safari.openInApp(url)).resolves.not.toThrow(); }); }); }); ``` ### Console and Logging ```typescript import {runtime} from 'scriptable-testlab'; describe('Console Tests', () => { beforeEach(() => { runtime.setupMocks(); jest.spyOn(console, 'log'); jest.spyOn(console, 'warn'); jest.spyOn(console, 'error'); }); afterEach(() => { runtime.clearMocks(); jest.restoreAllMocks(); }); test('should handle different log levels', () => { // Standard log console.log('Info message'); expect(console.log).toHaveBeenCalledWith('Info message'); // Warning log console.warn('Warning message'); expect(console.warn).toHaveBeenCalledWith('Warning message'); // Error log console.error('Error message'); expect(console.error).toHaveBeenCalledWith('Error message'); }); test('should handle object logging', () => { const obj = {key: 'value'}; console.log(JSON.stringify(obj)); expect(console.log).toHaveBeenCalledWith(JSON.stringify(obj)); }); }); ``` ### Module Import ```typescript import {runtime} from 'scriptable-testlab'; describe('Module Import Tests', () => { beforeEach(() => { runtime.setupMocks(); jest.resetModules(); // Create mock modules jest.mock( 'test-module', () => ({ testFunction: () => 'test', }), {virtual: true}, ); jest.mock( 'relative/path/module', () => ({ relativeFunction: () => 'relative', }), {virtual: true}, ); jest.mock( 'invalid-module', () => { const error = new Error('Unexpected token'); error.name = 'SyntaxError'; throw error; }, {virtual: true}, ); }); test('should handle module imports', () => { const module = importModule('test-module'); expect(module).toBeDefined(); expect(typeof (module as {testFunction: () => string}).testFunction).toBe('function'); expect((module as {testFunction: () => string}).testFunction()).toBe('test'); const relativeModule = importModule('relative/path/module'); expect(relativeModule).toBeDefined(); expect(typeof (relativeModule as {relativeFunction: () => string}).relativeFunction).toBe('function'); expect((relativeModule as {relativeFunction: () => string}).relativeFunction()).toBe('relative'); }); test('should handle module import errors', () => { expect(() => { importModule('non-existent-module'); }).toThrow('Module not found: non-existent-module'); expect(() => { importModule('invalid-module'); }).toThrow('Syntax error in module: invalid-module'); }); }); ``` ## License This project is licensed under the Apache-2.0 License - see the [LICENSE](LICENSE) file for details.