also looking at this
test(ios): preserve native shortcuts through bundle initialization
#423
3 files
+91
-0
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -121,6 +121,42 @@ final class NativeShortcutsUnitTests: XCTestCase { | ||
| 121 | 121 | } |
| 122 | 122 | } |
| 123 | 123 | |
| 124 | func testScreenAndAppScriptRegisterAndClearAfterPartialOrNullNamespaceReplacement() async throws { | |
| 125 | for appScript in [false, true] { | |
| 126 | let screen = CraftNativeScreenController(config: CraftConfig(), isAppScript: appScript) | |
| 127 | screen.loadViewIfNeeded() | |
| 128 | defer { screen.viewDidDisappear(false) } | |
| 129 | for replacement in ["{extra:42}", "null", "undefined"] { | |
| 130 | screen.evaluateScript(""" | |
| 131 | globalThis.__shortcutAnswer = null; | |
| 132 | craft.shortcuts = \(replacement); | |
| 133 | craft.shortcuts.set([{type:'partial',title:'Partial'}]).then(function(result) { | |
| 134 | __shortcutAnswer = {count:result.count, extra:craft.shortcuts.extra}; | |
| 135 | }).catch(function(error) { __shortcutAnswer = {error:String(error)}; }); | |
| 136 | """) | |
| 137 | for _ in 0..<100 { | |
| 138 | if screen.evaluateScript("__shortcutAnswer !== null")?.toBool() == true { break } | |
| 139 | try await Task.sleep(nanoseconds: 20_000_000) | |
| 140 | } | |
| 141 | let result = try XCTUnwrap(screen.evaluateScript("__shortcutAnswer")?.toDictionary()) | |
| 142 | XCTAssertNil(result["error"]) | |
| 143 | XCTAssertEqual(result["count"] as? Int, 1) | |
| 144 | XCTAssertEqual(result["extra"] as? Int, 42) | |
| 145 | XCTAssertEqual(UIApplication.shared.shortcutItems?.map(\.type), ["partial"]) | |
| 146 | screen.evaluateScript(""" | |
| 147 | globalThis.__shortcutCleared = null; | |
| 148 | craft.shortcuts.clear().then(function(result) { __shortcutCleared = result; }); | |
| 149 | """) | |
| 150 | for _ in 0..<100 { | |
| 151 | if screen.evaluateScript("__shortcutCleared === true")?.toBool() == true { break } | |
| 152 | try await Task.sleep(nanoseconds: 20_000_000) | |
| 153 | } | |
| 154 | XCTAssertTrue(screen.evaluateScript("__shortcutCleared === true")?.toBool() == true) | |
| 155 | XCTAssertTrue(UIApplication.shared.shortcutItems?.isEmpty ?? true) | |
| 156 | } | |
| 157 | } | |
| 158 | } | |
| 159 | ||
| 124 | 160 | func testColdAndWarmShortcutTapsReachTheAppHandlerExactlyOnce() { |
| 125 | 161 | let events = CraftEventManager() |
| 126 | 162 | events.configure(appScript: true, page: false) |
| @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' | ||
| 3 | 3 | import { gunzipSync } from 'node:zlib' |
| 4 | 4 | import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs' |
| 5 | 5 | import { join, resolve } from 'node:path' |
| 6 | import { createContext, runInContext } from 'node:vm' | |
| 6 | 7 | import { afterAll, describe, expect, it } from 'bun:test' |
| 7 | 8 | import { addSchemeBuildTargets, addSchemeTestTargets, insertProjectTargetsBeforeSchemes } from '../scripts/insert-project-targets' |
| 8 | 9 | import { |
| @@ -646,17 +647,55 @@ describe('Craft iOS builder', () => { | ||
| 646 | 647 | expect(source).toContain('guard width > 0, abs(width - reportedViewportWidth) >= 0.5 else { return }') |
| 647 | 648 | }) |
| 648 | 649 | |
| 649 | it('keeps the shortcuts module present on a native screen, answering unsupported', () => { | |
| 650 | // Added in 17e09f4d and deleted 28 minutes later by a merge from main | |
| 651 | // (b1f32471) that nothing caught, because nothing asserted on it. The | |
| 652 | // module has to exist even though a JavaScriptCore screen cannot register | |
| 653 | // quick actions: without it `craft.shortcuts` is undefined and STX falls | |
| 654 | // through to an undefined namespace instead of answering `unsupported`. | |
| 650 | it('preserves native shortcut requests through the full prelude and bundle namespace replacement', async () => { | |
| 655 | 651 | const source = readFileSync(join(import.meta.dir, '..', 'templates', 'CraftNativeScreen.swift'), 'utf8') |
| 656 | expect(source).toContain("keep('shortcuts', {") | |
| 657 | expect(source).toContain('set: function() { return Promise.resolve(false); }') | |
| 658 | expect(source).toContain('clear: function() { return Promise.resolve(false); }') | |
| 659 | expect(source).toContain('onShortcut: function() { return function() {}; }') | |
| 652 | // Execute the actual two template preludes, including any later keep() | |
| 653 | // registrations. A source-string assertion cannot catch accessor replacement. | |
| 654 | function prelude(anchor: string): string { | |
| 655 | const marker = source.indexOf(anchor) | |
| 656 | expect(marker).toBeGreaterThan(-1) | |
| 657 | const opening = 'jsContext.evaluateScript("""' | |
| 658 | const start = source.lastIndexOf(opening, marker) + opening.length | |
| 659 | const end = source.indexOf('""")', marker) | |
| 660 | expect(end).toBeGreaterThan(start) | |
| 661 | return source.slice(start, end) | |
| 662 | } | |
| 663 | const requests: Array<{ module: string, method: string, args: unknown[] }> = [] | |
| 664 | const sandbox: { | |
| 665 | __stxNativeBridge: { capabilities: string[], postMessage: (raw: string) => void } | |
| 666 | __stxNativeCallback?: (message: unknown) => void | |
| 667 | } = { | |
| 668 | __stxNativeBridge: { | |
| 669 | capabilities: ['shortcuts'], | |
| 670 | postMessage(raw) { | |
| 671 | const message = JSON.parse(raw) | |
| 672 | requests.push({ module: message.payload.module, method: message.payload.method, args: message.payload.args }) | |
| 673 | sandbox.__stxNativeCallback?.({ type: 'API_RESPONSE', payload: { | |
| 674 | requestId: message.id, data: message.payload.method === 'set' ? { count: 1 } : true, | |
| 675 | } }) | |
| 676 | }, | |
| 677 | }, | |
| 678 | } | |
| 679 | const context = createContext(sandbox) | |
| 680 | runInContext(prelude('var bridge = globalThis.__stxNativeBridge;'), context) | |
| 681 | const nativeSetter = runInContext('Object.getOwnPropertyDescriptor(craft, "shortcuts").set', context) | |
| 682 | runInContext(prelude('function keep(name, extras)'), context) | |
| 683 | // Exercise bootstrap *before* loadBundle's reinstall can hide the regression, | |
| 684 | // and normal calls after that reinstall too. | |
| 685 | for (const reinstall of [false, true]) { | |
| 686 | if (reinstall) runInContext('__craftInstallNativeDialog()', context) | |
| 687 | for (const replacement of ['{extra:42}', '{extra:42,set:function(){return false},clear:function(){return false}}', 'null', 'undefined']) { | |
| 688 | runInContext(`craft.shortcuts = ${replacement}`, context) | |
| 689 | expect(await runInContext('craft.shortcuts.set([{type:"saved",title:"Saved"}])', context)).toEqual({ count: 1 }) | |
| 690 | expect(await runInContext('craft.shortcuts.clear()', context)).toBe(true) | |
| 691 | expect(runInContext('craft.shortcuts.extra', context)).toBe(42) | |
| 692 | } | |
| 693 | if (!reinstall) expect(runInContext('Object.getOwnPropertyDescriptor(craft, "shortcuts").set', context)).toBe(nativeSetter) | |
| 694 | } | |
| 695 | expect(requests).toEqual(Array.from({ length: 8 }, () => [ | |
| 696 | { module: 'Shortcuts', method: 'set', args: [[{ type: 'saved', title: 'Saved' }]] }, | |
| 697 | { module: 'Shortcuts', method: 'clear', args: [] }, | |
| 698 | ]).flat()) | |
| 660 | 699 | }) |
| 661 | 700 | |
| 662 | 701 | it('advertises mounted-view focus and measurement in native screens', () => { |
| @@ -7017,15 +7017,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate, | ||
| 7017 | 7017 | keep('navigation', { |
| 7018 | 7018 | setOptions: function(options) { craftNativeSetOptions(JSON.stringify(options || {})); } |
| 7019 | 7019 | }); |
| 7020 | // Dynamic home-screen quick actions belong to the app shell, | |
| 7021 | // not an individual JavaScriptCore screen. Keep the typed | |
| 7022 | // module present so STX can answer `unsupported` honestly | |
| 7023 | // instead of falling through to an undefined namespace. | |
| 7024 | keep('shortcuts', { | |
| 7025 | set: function() { return Promise.resolve(false); }, | |
| 7026 | clear: function() { return Promise.resolve(false); }, | |
| 7027 | onShortcut: function() { return function() {}; } | |
| 7028 | }); | |
| 7020 | // shortcuts already has a capability-owned accessor above. | |
| 7021 | // Do not wrap it in keep(): its adopt setter would let a | |
| 7022 | // bundle replace native set/clear during bootstrap. | |
| 7029 | 7023 | })(); |
| 7030 | 7024 | """) |
| 7031 | 7025 | } |